The Complete Guide
From first setup to advanced features, learn how to get the most out of your AI agent.
Talk to CofoundersThe CUA API gives your code the ability to see and interact with any screen. Send a screenshot and a natural language instruction — receive structured mouse clicks, keyboard inputs, and scroll commands with exact coordinates.
Send your key as an X-API-Key header or Authorization: Bearer. Sign up to create API keys. Charges debit your prepaid developer API wallet (1 credit = 1¢ = $0.01) — separate from your Coasty app subscription, even on Unlimited. Fees are charged before execution and auto-refunded on failure.
X-API-Key: sk-coasty-live-your_key_here
# or, equivalently:
Authorization: Bearer sk-coasty-live-your_key_here"Bearer " prefix into an X-API-Key value.X-Credits-Charged + X-Credits-Remaining; the body usage has credits_charged + cost_cents (both 0 on test keys).sk-coasty-test-) never bill and use mock VMs; the wire format matches production.Idempotency-Key header so retries are safe.Choose your language. The predict endpoint is the core of the API — everything else builds on it.
pip install requestsimport requests, base64
API_KEY = "sk-coasty-live-..."
img = base64.b64encode(open("screen.png", "rb").read()).decode()
r = requests.post(
"https://coasty.ai/v1/predict",
headers={"X-API-Key": API_KEY},
json={
"screenshot": img,
"instruction": "Click the search bar and type 'hello'",
},
)
for action in r.json()["actions"]:
print(action["action_type"], action["params"])# Create a session for multi-step tasks
s = requests.post(
"https://coasty.ai/v1/sessions",
headers={"X-API-Key": API_KEY},
json={"cua_version": "v3", "screen_width": 1920, "screen_height": 1080},
).json()
session_id = s["session_id"]
# Send screenshots in a loop
while True:
screenshot = capture_screenshot() # your screenshot function
r = requests.post(
f"https://coasty.ai/v1/sessions/{session_id}/predict",
headers={"X-API-Key": API_KEY},
json={"screenshot": screenshot, "instruction": "Complete the form"},
).json()
for action in r["actions"]:
execute_action(action) # your action executor
if r["status"] in ("done", "fail"):
breakEvery prediction returns structured actions with exact coordinates, a status signal, and token usage.
{
"request_id": "req_abc123",
"actions": [
{
"action_type": "click",
"params": { "x": 512, "y": 340, "button": "left", "clicks": 1 }
},
{
"action_type": "type_text",
"params": { "text": "hello world" }
}
],
"reasoning": "I see a search bar at (512, 340)...",
"status": "continue",
"usage": {
"input_tokens": 1523,
"output_tokens": 245,
"credits_charged": 5,
"cost_cents": 5
}
}Billed responses also carry X-Credits-Charged + X-Credits-Remaining headers. On a sk-coasty-test- key, credits_charged and cost_cents are both 0.
clickMouse click at (x, y)type_textType a stringkey_pressPress a key (enter, tab...)key_comboCombo (ctrl+c, cmd+v...)scrollScroll at a positiondragDrag between two pointsmoveMove cursorwaitPause executiondoneTask completedfailTask impossibleOnly screenshot and instruction are required.
screenshotstringrequiredinstructionstringrequiredcua_version"v3" | "v4" | "v1" (+3 cr)screen_widthintscreen_heightintmax_actionsint (1-10)trajectoryarraysystem_promptstringtoolsstring[]Stateless prediction, sessions, and grounding utilities. All require the X-API-Key header.
/v1/predict5 cr/v1/sessions10 cr/v1/sessions/{id}/predict4 cr/v1/sessions/{id}/resetFree/v1/sessions/{id}Free/v1/ground3 cr/v1/parseFree/v1/modelsFree/v1/usageFree/v1/sessionsFree+2 cr ($0.02)+1 cr ($0.01)+3 cr ($0.03)+1 cr ($0.01)predict, ground and sessions are screen-agnostic: a screenshot goes in, coordinates and actions come out. The pixels can come from anywhere — your own desktop, a browser, a phone emulator, a VNC frame. Coasty VMs are one execution target, not the only one.
Coordinates & scaling — read this first
Coordinates come back in the SAME space as the screenshot you sent. If you downscale (e.g. a 2560x1440 desktop resized to 1280x720 to save a credit), multiply returned x/y by your scale factor before clicking — and pass the DOWNSCALED size as screen_width/height. Sending full resolution with the real width/height also works (coordinates map 1:1) and costs +1 credit above 1280x720. Mismatched screenshot vs screen_width/height is the number-one cause of "it clicks the wrong place".
Safety on a real desktop
You are giving a model control of a real mouse and keyboard. Keep pyautogui.FAILSAFE on (mouse to a corner aborts), run with a step cap, send an Idempotency-Key on every predict so a network retry can never double-execute a step, and use the 'Cautious (non-destructive)' preset when the screen can reach anything irreversible.
Screenshot → predict → execute → repeat until status leaves continue. The full pattern below runs on YOUR machine: sessions keep history server-side, the Idempotency-Key makes every step retry-safe, and the executor maps each returned action to a real input event.
# Automate YOUR screen — no VM needed. pip install requests mss pyautogui pillow
import base64, io, time, uuid, requests, mss, pyautogui
from PIL import Image
API, KEY = "https://coasty.ai/v1", "sk-coasty-test-..." # test key = free while you build
HDRS = {"X-API-Key": KEY}
pyautogui.FAILSAFE = True # slam the mouse into a corner to abort instantly
REAL_W, REAL_H = pyautogui.size() # your actual desktop resolution
SEND_W, SEND_H = 1280, 720 # what we tell the model (SD = 1 credit cheaper)
SX, SY = REAL_W / SEND_W, REAL_H / SEND_H # scale model coords -> real pixels
def screenshot_b64():
with mss.mss() as sct:
shot = sct.grab(sct.monitors[1]) # primary monitor
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
img = img.resize((SEND_W, SEND_H)) # MUST match screen_width/height
buf = io.BytesIO(); img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
def execute(a):
t, p = a["action_type"], a["params"]
if t == "click":
pyautogui.click(p["x"] * SX, p["y"] * SY,
clicks=p.get("clicks", 1), button=p.get("button", "left"))
elif t == "type_text": pyautogui.write(p["text"], interval=0.02)
elif t == "key_press": pyautogui.press(p["keys"])
elif t == "key_combo": pyautogui.hotkey(*p["keys"])
elif t == "scroll": pyautogui.scroll(p["clicks"]) # +up / -down, like pyautogui
elif t == "drag":
pyautogui.moveTo(p["x1"] * SX, p["y1"] * SY)
pyautogui.dragTo(p["x2"] * SX, p["y2"] * SY, duration=0.4)
elif t == "wait": time.sleep(p["seconds"])
return t
# A session keeps screenshot history so the model remembers what it already did.
sess = requests.post(f"{API}/sessions", headers=HDRS, json={
"cua_version": "v3",
"screen_width": SEND_W, "screen_height": SEND_H,
# Optional best-practice steering (Starter+). Pick a preset from the docs:
"instructions": "Click the visual center of elements. If the target is not visible, scroll toward it, never guess.",
}).json()
sid = sess["session_id"]
task = "Open the calculator and compute 42 * 17"
try:
for step in range(25):
r = requests.post(
f"{API}/sessions/{sid}/predict",
headers={**HDRS, "Idempotency-Key": f"step-{sid}-{step}-{uuid.uuid4().hex[:8]}"},
json={"screenshot": screenshot_b64(), "instruction": task},
timeout=120,
).json()
print(f"step {r['step']}: {r['reasoning'][:80]}")
for a in r["actions"]:
if execute(a) in ("done", "fail"):
raise SystemExit(f"finished: {r['status']} — {r['reasoning']}")
time.sleep(0.5) # let the UI settle
finally:
requests.delete(f"{API}/sessions/{sid}", headers=HDRS) # stop the session clock
clickx, y, button?=left, clicks?=1pyautogui.click(x, y, clicks=p.get("clicks", 1), button=p.get("button", "left"))movex, ypyautogui.moveTo(x, y)type_texttextpyautogui.write(p["text"], interval=0.02)key_presskeys (list, pressed in order)pyautogui.press(p["keys"])key_combokeys (held together)pyautogui.hotkey(*p["keys"])scrollclicks (+up / −down), direction?=vertical, x?, y?pyautogui.scroll(p["clicks"]) — or pyautogui.hscroll() for horizontaldragx1, y1, x2, y2, button?pyautogui.moveTo(x1, y1); pyautogui.dragTo(x2, y2, duration=0.4)waitsecondstime.sleep(p["seconds"])done—task finished — stop the loopfail—agent is blocked — stop and inspect `reasoning`rawcode (pyautogui source)fallback: log it; exec only if you trust the sandboxBest-practice steering for the `instructions` field — appended to the base agent prompt (unlike system_prompt, which replaces it). Pick the preset that matches your job, copy it, and pass it on session create or any predict call. Custom prompts require Starter or higher.
Default pick — careful clicking on real desktops where mis-clicks have consequences.
Be precise. Before clicking, confirm the target element is actually visible in the CURRENT screenshot — never click from memory of a previous screen. Click the visual center of elements, not their edges. If the element you need is not visible, scroll toward where it should be instead of guessing coordinates. If two elements look similar, prefer the one whose text matches the task exactly. After typing into a field, verify focus landed in the right field before continuing.requests.post(f"{API}/sessions", headers=HDRS, json={
"cua_version": "v3",
"screen_width": 1280, "screen_height": 720,
"instructions": PRESET, # the text above — applies to every step in the session
})instructions is additive steering on top of the tuned base prompt — start here.system_prompt fully replaces the base prompt: more power, more ways to break grounding — reach for it only when a preset plus task phrasing can't express what you need. Both draw from the same per-tier character budget (Starter 2,000 / Pro 4,000 / Enterprise 16,000; +1 credit / $0.01 per call when the prompt is strictly over 500 chars — exactly 500 is free).
Provision a sandbox or production VM, then drive it with actions, terminal commands, browser automation, or file operations. Sandbox keys (sk-coasty-test-*) return mock VMs with no AWS billing.
machines:readlist, get, screenshotmachines:writeprovision, start, stop, terminateactions:execclick, type, scroll, browser_*terminal:execshell command executionfiles:readread, exists, listfiles:writewrite, edit, append, deletebrowser:executearbitrary JS in browsersnapshots:writecreate AMI snapshotsconnection:readfetch SSH key + VNC password20 cr ($0.20) min5 cr/hr ($0.05)9 cr/hr ($0.09)running rate1 cr/hr ($0.01)FreeFree1 cr ($0.01)never destroyedFreesk-coasty-test-* key during development — you get instant mock VMs (id mch_test_…), synthetic action results, and zero billing. The wire format matches production exactly, so you can swap to a live key and ship.Create a VM, list your fleet, and control start/stop/restart/snapshot/terminate. Set ttl_minutes for auto-destroy (extend or clear any time via PATCH). Runtime bills your API wallet per minute at a small surplus over cloud cost. Sandbox keys mock everything in-memory; live keys provision real EC2 / Azure instances.
import requests
# Provision a fresh Linux desktop VM. Sandbox keys (sk-coasty-test-*)
# return a mock machine instantly with no AWS billing.
r = requests.post(
"https://coasty.ai/v1/machines",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "provision-bot-001", # safe to retry
},
json={
"display_name": "automation-bot",
"os_type": "linux",
"desktop_enabled": True,
},
)
machine = r.json()["machine"]
print(machine["id"], machine["status"])/v1/machines/v1/machines/{id}/v1/machines/pricing/v1/machines/{id}/v1/machines/{id}/start/v1/machines/{id}/stop/v1/machines/{id}/restart/v1/machines/{id}/snapshot/v1/machines/{id}Dispatch a single action, or chain up to 50 in one batch. Commands are validated against an explicit allowlist — typos return 422, never reach the VM.
import requests
machine_id = "..." # from provision response
r = requests.post(
f"https://coasty.ai/v1/machines/{machine_id}/actions",
headers={"X-API-Key": "sk-coasty-live-..."},
json={
"command": "click",
"parameters": {"x": 512, "y": 340},
},
)
result = r.json()
print(result["success"], result["duration_ms"], "ms")clickactions:exectypeactions:execkey_pressactions:execkey_comboactions:execscrollactions:execdragactions:execscreenshotactions:execterminal_executeterminal:execfile_readfiles:readfile_writefiles:writebrowser_navigateactions:execbrowser_clickactions:execbrowser_executebrowser:executePOST /v1/machines/{id}/actions/batch
Content-Type: application/json
X-API-Key: sk-coasty-live-...
{
"steps": [
{ "command": "browser_navigate",
"parameters": { "url": "https://example.com/login" } },
{ "command": "browser_type",
"parameters": { "selector": "#email", "text": "[email protected]" } },
{ "command": "browser_type",
"parameters": { "selector": "#password", "text": "***" } },
{ "command": "browser_click",
"parameters": { "selector": "button[type=submit]" } }
],
"stop_on_error": true
}
Returns:
{
"results": [...], // one per step
"completed_count": 4,
"failed_count": 0,
"aborted": false,
"request_id": "req_..."
}Typed convenience endpoints over /actions. Same dispatch path, ergonomic URL shapes, identical scope rules.
import requests
# Run a shell command (PowerShell on Windows, bash on Linux).
# Output is truncated VM-side to 5000 chars.
r = requests.post(
f"https://coasty.ai/v1/machines/{machine_id}/terminal",
headers={"X-API-Key": "sk-coasty-live-..."},
json={
"command": "uname -a && uptime",
"timeout_ms": 10_000,
},
)
print(r.json()["result"]["output"])/browser/{op}opennavigateclicktypedomclickablesstateinfoscrollclosescreenshotwaitlist-tabsopen-tabclose-tabswitch-tabBody: { parameters: {…}, timeout_ms? }. browser_execute NOT here — use /actions with browser:execute.
/files/{op}readexistslistlist-directorydownloadlist-downloadswriteeditappenddeletedelete-directory/terminal{ command, timeout_ms?, session_id?, cwd? }PowerShell on Windows, bash on Unix. Output capped at 5000 chars VM-side. Pass session_id to reuse a persistent shell across calls.Requires terminal:exec scope.Full reference. All require X-API-Key (or Authorization: Bearer) except /health.
/v1/machines20 cr gate/v1/machinesFree/v1/machines/{id}Free/v1/machines/pricingFree/v1/machines/{id}Free/v1/machines/{id}Free/v1/machines/{id}/startFree/v1/machines/{id}/stopFree/v1/machines/{id}/restartFree/v1/machines/{id}/snapshot1 cr/v1/machines/{id}/actionsFree/v1/machines/{id}/actions/batchFree/v1/machines/{id}/browser/{op}Free/v1/machines/{id}/terminalFree/v1/machines/{id}/files/{op}Free/v1/machines/{id}/screenshotFree/v1/machines/{id}/connectionFree/v1/machines/healthFreeHand Coasty a machine and a task; it drives the agent loop for you, streams lifecycle, and pauses for a human when needed. Workflows compose many runs with a versioned JSON DSL. Sandbox keys (sk-coasty-test-*) run against mock VMs with no billing.
runs:readlist, get, stream run eventsruns:writestart, cancel, resume (human takeover)workflows:readlist/get workflows + workflow runsworkflows:writecreate, update, delete, start runsX-Credits-Charged + X-Credits-Remaining headers, and the body usage object exposes credits_charged + cost_cents (both 0 on test keys). Each agent step costs 5 cr ($0.05) on v3/v4 and 8 cr ($0.08) on v1, charged from your API wallet after the step completes (idempotent per step; a failed charge stops the run with WALLET_EXHAUSTED). Starting a run requires wallet balance ≥ one step's cost. Each workflow task step is itself a run with identical per-step billing; control-flow steps (assert · if · loop · parallel · retry · human_approval · succeed · fail) are Free. Total spend is capped by budget_cents (default 0 = no budget guard) and max_iterations (≤ 1000). Test-mode runs bill 0.POST /v1/runs starts a durable run and returns status 'queued' plus a one-time webhook_secret (only when you pass webhook_url). Idempotency-Key makes retries safe. Resume only works while status == awaiting_human.
import requests
API_KEY = "sk-coasty-live-..."
# Start a task run. Returns status "queued" plus a ONE-TIME webhook_secret
# (only present when you pass webhook_url). Idempotency-Key makes retries safe.
r = requests.post(
"https://coasty.ai/v1/runs",
headers={
"X-API-Key": API_KEY,
"Idempotency-Key": "invoice-run-4821",
},
json={
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Open the invoice in the browser and read the total.",
"cua_version": "v3", # "v4" needs professional tier or above
"max_steps": 40,
"on_awaiting_human": "pause", # pause | fail | cancel
"webhook_url": "https://your.app/hooks/coasty",
},
)
run = r.json()
print(run["id"], run["status"]) # ... queued
webhook_secret = run.get("webhook_secret") # shown once — store it nowmachine_iduuidrequiredtaskstringrequiredcua_version"v3" | "v4" | "v1" (8 cr/step)max_stepsint (1-1000)on_awaiting_human"pause"|"fail"|"cancel"webhook_urlstring (https)Idempotency-Key (≤ 128 chars) makes a retried POST return the original run. webhook_secret is returned ONCE on create, so store it; it is null on get/list.{
"id": "...",
"status": "queued",
"machine_id": "550e8400-...",
"task": "Open the invoice ...",
"cua_version": "v3",
"max_steps": 40,
"on_awaiting_human": "pause",
"step_count": 0,
"awaiting_human_reason": null,
"result": null,
"error": null,
"created_at": "2026-06-08T17:00:00Z",
"usage": { "credits_charged": 0, "cost_cents": 0 },
"webhook_secret": "whsec_shown_once"
}queued → running → (awaiting_human ⇄ running) → succeeded | failed | cancelled | timed_outawaiting_human is only reached when on_awaiting_human == "pause". Terminal states are immutable. POST /v1/runs/{id}/resume is valid only while awaiting a human (otherwise 409 NOT_AWAITING_HUMAN). POST /v1/runs/{id}/cancel works at any non-terminal state.
import requests
# Cancel a run at any non-terminal state.
requests.post(f"https://coasty.ai/v1/runs/{run_id}/cancel",
headers={"X-API-Key": API_KEY})
# Resume ONLY works when status == "awaiting_human" (human takeover). Resuming
# any other state returns 409 NOT_AWAITING_HUMAN.
requests.post(
f"https://coasty.ai/v1/runs/{run_id}/resume",
headers={"X-API-Key": API_KEY},
json={"note": "Approved by ops; the total is correct."},
)GET /v1/runs?status=running&limit=20
X-API-Key: sk-coasty-live-...
# Query params:
# status = queued|running|awaiting_human|succeeded
# |failed|cancelled|timed_out (else 400 INVALID_STATUS_FILTER)
# limit = 1..200 (else 400 INVALID_LIMIT)
#
# GET /v1/runs/{id} fetches one run (404 RUN_NOT_FOUND if not yours).Stream lifecycle live over SSE, or receive HMAC-signed lifecycle webhooks. Both let you react the instant a run needs a human or finishes.
import requests
# Stream lifecycle as Server-Sent Events. Reconnect with Last-Event-ID to
# replay everything after the last seq you saw — no gaps on a dropped socket.
with requests.get(
f"https://coasty.ai/v1/runs/{run_id}/events",
headers={"X-API-Key": API_KEY, "Last-Event-ID": "42"},
stream=True,
) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip()) # status / step / awaiting_human / terminalGET /v1/runs/{id}/eventsServer-Sent Events. With curl, pass -N to disable buffering so frames arrive live.
Each frame has an id: (monotonic seq). Reconnect with Last-Event-ID: <seq> (or ?after=<seq>) to replay everything after that point, with no gaps on a dropped socket.
Event names: status · step · awaiting_human · billing · terminal.
Pass webhook_url on create (HTTPS only) and Coasty POSTs a signed payload on every lifecycle transition.
Verify with the per-run webhook_secret (returned once):
Coasty-Signature: t=<ts>,v1=<hex>
v1 = HMAC-SHA256(secret, "<t>.<body>")Events: run.awaiting_human · run.succeeded · run.failed · run.cancelled · run.timed_out.
A versioned JSON DSL composing many runs with branching, loops, parallelism, asserts, retries, and human approvals. {{var}} references pull from earlier steps' results.
import requests
# A workflow is a versioned JSON DSL composing many runs with branching,
# loops, parallelism, asserts, retries, and human approvals. {{var}} pulls
# from earlier steps' results (bound via save_as / step id).
r = requests.post(
"https://coasty.ai/v1/workflows",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "ar-collections-v1",
},
json={
"name": "AR collections",
"definition": {
"steps": [
{"id": "invoice", "type": "task", "save_as": "invoice",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Open the invoice and read its status + total."},
{"id": "check", "type": "assert",
"condition": {"op": "truthy", "value": "{{invoice.passed}}"},
"message": "Agent failed to read the invoice"},
{"id": "branch", "type": "if",
"condition": {"op": "contains",
"left": "{{invoice.result}}", "right": "PAID"},
"then": [{"id": "ok", "type": "succeed",
"output": {"state": "paid"}}],
"else": [{"id": "ask", "type": "human_approval",
"message": "Invoice unpaid — send reminder?"},
{"id": "fin", "type": "succeed",
"output": {"state": "reminded"}}]},
]
},
},
)
workflow = r.json()
print(workflow["id"], workflow["version"])taskRun an agent task. Binds result via save_as + step id.assertFail the workflow unless a condition holds.ifBranch on a condition: then / else.loopRepeat a body (count, or while a condition).parallelRun independent branches concurrently.human_approvalPause for a human to approve / reject.retryRetry a body on failure.succeedFinish successfully with optional output.failFinish as failed with a message.task steps bill — 5 cr ($0.05) on v3/v4, 8 cr ($0.08) on v1. All other step types are Free.eqneltgtltegtecontainstruthyfalsyexistsandornotStructured + injection-safe (no free-text eval). and/or take conditions: [...]; not takes a single condition.
2008 levels16human_approval, succeed, and fail are not allowed inside a parallel branch.import requests
# Start a run of a SAVED workflow ...
r = requests.post(
f"https://coasty.ai/v1/workflows/{workflow_id}/runs",
headers={"X-API-Key": "sk-coasty-live-...", "Idempotency-Key": "wf-run-9"},
json={"inputs": {"invoice_url": "https://billing.example.com/inv/42"}},
)
wf_run = r.json()
print(wf_run["id"], wf_run["status"]) # ... queued
# ... or run an AD-HOC inline definition without saving it first:
requests.post(
"https://coasty.ai/v1/workflows/runs",
headers={"X-API-Key": "sk-coasty-live-..."},
json={"definition": {"steps": [
{"id": "t", "type": "task",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Take a screenshot and describe the screen."}]}},
)Full reference for runs + workflows. All require X-API-Key (or Authorization: Bearer). POST runs/workflows accept an Idempotency-Key.
/v1/runs5–8 cr/step/v1/runsFree/v1/runs/{id}Free/v1/runs/{id}/eventsFree/v1/runs/{id}/cancelFree/v1/runs/{id}/resumeFree/v1/workflowsFree/v1/workflowsFree/v1/workflows/{id}Free/v1/workflows/{id}Free/v1/workflows/{id}Free/v1/workflows/{id}/runs5–8 cr/step/v1/workflows/runs5–8 cr/step/v1/workflows/runsFree/v1/workflows/runs/{id}Free/v1/workflows/runs/{id}/eventsFree/v1/workflows/runs/{id}/cancelFree/v1/workflows/runs/{id}/resumeFreeCron-fired agent runs, one-shot run_at jobs, plus three trigger kinds (webhook, email, chain). Schedules created via API show up in your /schedules dashboard automatically.
schedules:readlist, get, runs, triggersschedules:writecreate, update, delete, pause, run-nowtriggers:writeadd/remove webhook, email, chain triggers20 cr ($0.20) min20 cr ($0.20) min10 cr/minFreeFreeFreeFreeFreeCreate a cron or one-shot schedule. Pause, resume, run-now, list runs, soft-delete. Idempotency-Key supported on POST.
import requests
# Daily 9:00 AM ET email summary, fired by the Coasty scheduler.
# Per fire: needs >= 20 cr ($0.20) in your API wallet to dispatch (gate only);
# agent runtime then bills your Coasty credit balance at 10 credits/min.
r = requests.post(
"https://coasty.ai/v1/schedules",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "morning-briefing-001",
},
json={
"name": "morning briefing",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task_prompt": "Summarize unread Gmail and post the top 5 to Slack.",
"frequency": "daily",
"time": "09:00",
"timezone": "America/New_York",
},
)
schedule = r.json()
print(schedule["id"], schedule["next_run_at"])every_15_minutes*/15 * * * *every_30_minutes*/30 * * * *hourly0 * * * *every_6_hours0 */6 * * *every_12_hours0 */12 * * *daily0 9 * * * (override with `time`)weekly0 9 * * 1 (override `time`, `day_of_week`)monthly0 9 1 * * (override `time`, `day_of_month`)customsupply your own `cron` fieldPOST /v1/schedules
Content-Type: application/json
X-API-Key: sk-coasty-live-...
{
"name": "launch announcement",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task_prompt": "Post the launch tweet from the draft.",
"run_at": "2099-01-01T17:00:00Z"
}
# Notes:
# * `run_at` and `frequency` are mutually exclusive.
# * Must be in the future (within last 60s tolerated).
# * After firing once, the schedule auto-pauses with paused_reason='one_shot_complete'.max_consecutive_failures (default 5) failed runs. Resume via POST /v1/schedules/{id}/resume. Insufficient credits at fire-time auto-pauses with reason insufficient_credits.Three trigger kinds. Webhook secrets are returned ONCE — store them on creation.
# Add a webhook trigger — returns the signing secret ONCE.
r = requests.post(
f"https://coasty.ai/v1/schedules/{schedule_id}/triggers",
headers={"X-API-Key": "sk-coasty-live-..."},
json={"kind": "webhook", "rate_limit_per_minute": 60},
)
trigger = r.json()
webhook_url = trigger["webhook_url"] # https://coasty.ai/v1/triggers/webhook/whk_...
webhook_secret = trigger["webhook_secret"] # whsec_<64 hex> — STORE THIS
# Save webhook_secret in your secrets manager. We hash + persist it; we
# cannot show it again. Lose it = generate a new trigger.{ kind: "webhook" }webhook_url + webhook_secret (whsec_64hex). Sign every fire with HMAC-SHA256(secret, "{ts}.body") and send Coasty-Signature: t={ts},v1={sig}. Replay window 5 min. Idempotent on identical (id, body) within 60 s.{ kind: "email" }<label>.<rand>@agents.coasty.ai address. Inbound emails fire the schedule. email_label must match ^[a-z0-9][a-z0-9._-]{0,32}[a-z0-9]$.{ kind: "chain" }source_schedule_id completes. Events: on_complete · on_failure · on_any.Max chain depth: 5./v1/schedules/{id}/triggers/v1/schedules/{id}/triggers/v1/schedules/{id}/triggers/{trigger_id}/v1/triggers/email-mailboxPOST /v1/triggers/webhook/{webhook_id} — UNAUTHENTICATED but HMAC-verified. Hit by Stripe, Linear, n8n, anything that can sign a request.
# Customer-side webhook signing — produces a Coasty-Signature header
# the public /v1/triggers/webhook/{id} endpoint accepts.
import hmac, hashlib, time
def sign_coasty_webhook(secret: str, body: bytes) -> dict:
ts = int(time.time())
signed_payload = f"{ts}.".encode("utf-8") + body
sig = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return {"Coasty-Signature": f"t={ts},v1={sig}"}
# Fire the webhook from your own app:
import requests
body = b'{"event":"order.placed","order_id":"123"}'
headers = {**sign_coasty_webhook(webhook_secret, body), "Content-Type": "application/json"}
requests.post(webhook_url, data=body, headers=headers)Coasty-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
# t = current unix timestamp (seconds)
# v1 = lowercase hex HMAC-SHA256(webhook_secret, "<t>.<body>")
# (period as separator; raw body bytes; no newline)
# replay = signatures > 5 min stale are rejected
# dedup = identical (webhook_id, body) within 60 s returns deduplicated=true
# body cap = 1 MB (413 if exceeded)HTTP/1.1 200 OK
Content-Type: application/json
X-Coasty-Request-Id: req_...
X-Coasty-Webhook-Deduplicated: false
{
"received": true,
"schedule_id": "550e8400-...",
"run_id": "550e8400-...",
"deduplicated": false,
"message": "Schedule fire dispatched.",
"request_id": "req_..."
}webhook_secret like a password — it grants the ability to fire your schedule. Coasty stores it server-side and uses it to verify every inbound signature. If leaked: delete the trigger and re-create to rotate.Full reference. Public webhook fire endpoint is the only one without auth (it uses HMAC-signed Coasty-Signature instead).
/v1/schedules20 cr gate/v1/schedulesFree/v1/schedules/{id}Free/v1/schedules/{id}Free/v1/schedules/{id}Free/v1/schedules/{id}/pauseFree/v1/schedules/{id}/resumeFree/v1/schedules/{id}/run20 cr gate/v1/schedules/{id}/runsFree/v1/schedules/{id}/runs/{run_id}Free/v1/schedules/{id}/triggersFree/v1/schedules/{id}/triggersFree/v1/schedules/{id}/triggers/{trigger_id}Free/v1/triggers/email-mailboxFree/v1/triggers/webhook/{webhook_id}FreeDrive Coasty from any MCP-capable client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code Copilot. One install, every Coasty tool available.
MCP (Model Context Protocol) is the open standard, designed by Anthropic and adopted across the agent ecosystem, that lets LLM hosts plug into external tools and data. Coasty's MCP server is a thin wrapper over the /v1 API — same scopes, same billing. It runs locally via npx; your API key never touches a Coasty MCP relay.
npm i -g @coasty/mcpOr just point your MCP host at npx -y @coasty/mcp — zero install needed. Set COASTY_API_KEY in the host config and you're running. Sandbox keys (sk-coasty-test-*) work for free.
Pick your client. Configs are checked into the @coasty/mcp test suite — copying any of these blocks verbatim produces a working install.
// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}
// Restart Claude Desktop. Coasty tools appear under the 🛠 icon.claude mcp add coasty \
--env COASTY_API_KEY=sk-coasty-test-... \
-- npx -y @coasty/mcp
# Verify
claude mcp list
# coasty ✓ connected (24 tools, 2 prompts){
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}
// Cursor → Settings → MCP shows a green dot when reachable.{
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}// VS Code uses "servers" (NOT "mcpServers"). Tools only appear in
// Agent mode — type # in the chat to autocomplete tool names.
{
"servers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}sk-coasty-test-* key while you're iterating — everything works (provision, schedule, run) but no real EC2 / Azure / credit billing. Swap in sk-coasty-live-* when you're ready to ship.23 tools across Predict, Machines, Schedules, and Account. All carry MCP annotations (readOnly / destructive / idempotent) so well-behaved hosts confirm before destructive operations.
coasty_predictScreenshot + goal → list of actionscoasty_groundElement description → (x, y) coordscoasty_parsepyautogui code → structured actions (free)coasty_list_machinesRead-only — your VMscoasty_get_machineRead-only — one VMcoasty_take_machine_screenshotRead-only — current desktop imagecoasty_provision_machineCreate new VM (idempotent w/ key)coasty_terminate_machineDestructive — irreversiblecoasty_start_machineResume a stopped VMcoasty_stop_machinePause running VM (preserves state)coasty_execute_machine_actionDispatch click / type / scroll / browser_* / file_* / etc.coasty_run_terminal_commandShell exec on VM (terminal:exec scope)coasty_list_schedulesRead-onlycoasty_get_scheduleRead-onlycoasty_list_schedule_runsCursor-paginated historycoasty_create_scheduleCron, run-once, or custom — appears in dashboardcoasty_update_schedulePATCH (any field)coasty_delete_scheduleDestructive — soft-deletecoasty_run_schedule_nowManual fire (idempotent w/ key)coasty_pause_scheduleDisable future firescoasty_resume_scheduleRe-enablecoasty_add_triggerWebhook / email / chain (HMAC secret returned ONCE)coasty_remove_triggerDestructivecoasty_get_creditsRead-only — balance + tier + period usagestart_automation_session — pre-fill a chat that picks a VM, screenshots, predicts, and executes toward a goal.debug_failed_run — investigate why a schedule has been failing; proposes concrete fixes.readOnlyHint, destructiveHint, idempotentHint, and openWorldHint so a well-configured host can auto-approve safe reads and require explicit consent for destructive ops.Every error returns the same envelope and an X-Coasty-Request-Id header. The code is stable; the message is for humans. Use code + status to branch.
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This key lacks the scope this route requires.",
"type": "forbidden",
"request_id": "req_8f2c1e9a",
"suggestion": "Re-mint the key with runs:write at /developers.",
"docs_url": "https://coasty.ai/api-docs#errors",
"required_scope": "runs:write", // extra context varies by code
"current_scopes": ["runs:read"]
}
}Body fields: code, message, type, request_id, suggestion, docs_url, plus code-specific context (e.g. required_scope, balance, details).
Headers: X-Coasty-Request-Id (quote it in support tickets) and Link: <docs_url>; rel="help".
Auth failures also send WWW-Authenticate: Bearer; transient errors (timeouts, upstream outages) send Retry-After.
Auto-refunded codes (PREDICTION_FAILED, GROUNDING_FAILED) refund the charge, so you are not billed for a failed model call.
INVALID_LIMITlimit query param out of range; must be 1..200INVALID_STATUS_FILTERUnknown ?status= value on a list endpointFEATURE_NOT_AVAILABLEThe feature is gated off for your tier or this modeINVALID_API_KEYMissing/invalid key. Send X-API-Key OR Authorization: Bearer (sends WWW-Authenticate). Don't paste "Bearer " into X-API-KeyINSUFFICIENT_CREDITSWallet below required (returns required + balance). Add funds, or use a sk-coasty-test- keyWALLET_EXHAUSTEDThe API wallet hit zero mid-requestINSUFFICIENT_SCOPEKey valid but lacks scope (returns required_scope + current_scopes). Re-mint at /developersNOT_FOUNDResource id does not exist or isn't yoursSESSION_NOT_FOUNDSession id unknown (mode-isolated; test ids never match live)RUN_NOT_FOUNDRun id unknown or not owned by your key (mode-isolated)WORKFLOW_NOT_FOUNDWorkflow id unknown or not owned by your key (mode-isolated)INVALID_STATEIllegal transition (returns current_state + allowed_from)NOT_AWAITING_HUMANResumed a run/step that wasn't paused for a humanRESUME_CONFLICTTwo resumes raced; only the first winsIDEMPOTENCY_KEY_REUSEDSame Idempotency-Key reused with a different request bodyPAYLOAD_TOO_LARGEBase64 body over the 10 MB capVALIDATION_ERRORBody failed validation; error.details = field path that failedINVALID_SCREENSHOTScreenshot not valid base64; strip the data: prefix firstINTERNAL_ERRORUnexpected server error; retry, and quote the request_id if it persistsPREDICTION_FAILEDModel run failed; the charge is auto-refundedGROUNDING_FAILEDGrounding failed; auto-refundedUPSTREAM_UNAVAILABLEA dependency is down; retry with backoffUPSTREAM_TIMEOUTUpstream timed out; retry (use Idempotency-Key on POSTs)