API reference
Run autonomous tasks on managed machines, compose them into workflows, and drop to prediction primitives only when you need direct control.
Introduction
Introduction, at a glance
What the API does and how a turn works
task → machine → verified result- 01Describeone observable goal
- 02Attachmanaged or external machine
- 03Runobserve, reason, act
- 04Verifyconfirm terminal state
The task and machine are validated.
Start with a task run: give Coasty a goal and a machine, then let the agent drive to completion. Use workflows when an automation needs many tasks, branches, loops, approvals, or shared outputs. Schedules run a Task later, repeatedly, or from an authenticated webhook. Machines provide the execution target: a managed VM or your enrolled external machine.
The prediction endpoints are lower-level primitives for teams that need to own the control loop themselves. Use sessions for a stateful screenshot loop, predict for a stateless step, grounding for coordinates, and parse for structured actions. Everything is normal HTTPS to https://coasty.ai/v1, so you can choose the highest-level surface that fits the job and drop down only when you need finer control.
Authentication
Authentication, at a glance
API keys, live vs test, and the auth header
X-API-Key: sk-coasty-…- 01Keylive or test identity
- 02Scopeleast privilege check
- 03Routeauthorized operation
- 04Tracerequest ID returned
The raw key arrives over HTTPS.
Every API-key-authenticated request must include your secret key. The four health probes are public, and webhook ingress uses its documented Coasty-Signature HMAC credential instead. For API-key operations, the canonical form is the X-API-Key header, but Authorization: Bearer <key> works too: a blank X-API-Key falls through to the Bearer header. Pick one form and send the raw key. Do not paste the literal text Bearer inside X-API-Key; that is the single most common first-day mistake and it returns 401 INVALID_API_KEY. Keys are created and revoked from the API keys page. Treat a key like a password: keep it server-side, store it in an environment variable, and never commit it or ship it in client-side code.
External-machine driver routes are the exception: enrollment uses the owner API key, then returns a separate one-time device_token. That opaque token uses Authorization: Bearer, is bound to one machine, and cannot list resources, start runs, or act as the owner. See Bring your machine.
X-API-Key: sk-coasty-live-your_key_heresk-coasty-test- key never bills and runs against mock VMs, yet exercises the same core request and response shapes for sandbox-supported operations (its X-Credits-Charged and usage.cost_cents are always 0), so you can build and run CI confidently before flipping to a live key.Run your first task
Run your first task, at a glance
Start an autonomous task run and follow it to completion
POST /v1/runs- 01Createtask + machine + key
- 02Streamwatch ordered SSE
- 03Finishsucceeded or failed
- 04Confirmverify final state
A durable run ID is returned.
Your first autonomous task needs an API key and a machine. Grab a test key from the API keys page (it never bills), then use an existing machine, provision one. You can also enroll your own machine and use the returned id after its driver is connected. Set the key in your shell:
export COASTY_API_KEY="sk-coasty-test-your_key_here"Start the task with POST /v1/runs. Replace the example machine_id, describe the outcome in task, and send an Idempotency-Key so a retried create cannot start a duplicate run. The complete example starts the run and follows it to a terminal state:
import os, time, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
TERMINAL = {"succeeded", "failed", "cancelled", "timed_out"}
# 1. Start a run. Idempotency-Key makes a retried create safe.
run = requests.post(
f"{BASE}/runs",
headers={**HEADERS, "Idempotency-Key": "order-4821"},
json={
"machine_id": "mch_test_0123456789abcdef",
"task": "Open the billing page and download the latest invoice as PDF",
"cua_version": "v5", # any of v1/v3/v4/v5, all tiers; omit to use the v5 default
"max_steps": 40,
"on_awaiting_human": "pause",
},
timeout=30,
).json()
run_id = run["id"]
print(run["status"]) # "queued"
webhook_secret = run.get("webhook_secret") # create/replay response only; store it now
# 2. Poll until terminal.
while True:
run = requests.get(f"{BASE}/runs/{run_id}", headers=HEADERS, timeout=30).json()
print(run["status"], run["steps_completed"], "steps")
if run["status"] in TERMINAL:
break
time.sleep(2)
print(run["result"]) # {"passed": ..., "status": ..., "summary": ...}The create response begins at queued. Coasty then drives the machine, records each step, and finishes as succeeded, failed, cancelled, or timed_out. Your application can poll, subscribe to the event stream, or receive signed webhooks; it does not need to execute each prediction itself.
Task runs
Task runs, at a glance
Give the agent a task and a machine; it drives to done
POST /v1/runs- 01Taskgoal + machine
- 02Loopobserve, predict, act
- 03Guardbudget + policy + deadline
- 04Resultdurable terminal object
Machine and billing authority are reserved.
A run hands the agent a task and a machine, then drives it to completion on our side. The agent loops autonomously, verifies its own work (pass or fail), can pause for a human when it hits a wall, bills managed inference at $0.05 per completed step from your dollar API wallet ($0.08/step on v1) while BYOK bills $0 to Coasty, and streams successfully persisted timeline events with ordered at-least-once replay. Intermediate events can be absent, so the run resource is authoritative. You start one call and watch, instead of running the predict loop yourself.
422 LLM_PROVIDER_UNSUPPORTED before execution; no stored provider key is read or decrypted, and Anthropic/OpenAI is not called or billed.The machine may be kind: managed or kind: external. For an external target, start its driver first and wait for connection_status: connected; the run obtains screenshots and delivers typed actions through the enrolled machine protocol. It never falls back to a hosted VM or asks you to place screenshot bytes in the run request.
Create a run with POST /v1/runs. The two required fields are machine_id and task. The response is an agent.run object with status of queued, plus a create-response-only webhook_secret you store to verify webhooks. Send an Idempotency-Key header to make a retried create safe. That key deduplicates the top-level run resource, not every later OS side effect: use idempotent, checkpointed tasks and verify state before irreversible actions.
import os, time, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
TERMINAL = {"succeeded", "failed", "cancelled", "timed_out"}
# 1. Start a run. Idempotency-Key makes a retried create safe.
run = requests.post(
f"{BASE}/runs",
headers={**HEADERS, "Idempotency-Key": "order-4821"},
json={
"machine_id": "mch_test_0123456789abcdef",
"task": "Open the billing page and download the latest invoice as PDF",
"cua_version": "v5", # any of v1/v3/v4/v5, all tiers; omit to use the v5 default
"max_steps": 40,
"on_awaiting_human": "pause",
},
timeout=30,
).json()
run_id = run["id"]
print(run["status"]) # "queued"
webhook_secret = run.get("webhook_secret") # create/replay response only; store it now
# 2. Poll until terminal.
while True:
run = requests.get(f"{BASE}/runs/{run_id}", headers=HEADERS, timeout=30).json()
print(run["status"], run["steps_completed"], "steps")
if run["status"] in TERMINAL:
break
time.sleep(2)
print(run["result"]) # {"passed": ..., "status": ..., "summary": ...}action_policy is validated at create time, atomically persisted with the run, restored after worker recovery, and inherited by nested CUA delegation. Every proposed batch is checked before dispatch and max_actions is cumulative across the complete execution tree.{
"id": "7a1b2c3d-4e5f-4678-9abc-def012345678",
"object": "agent.run",
"status": "queued",
"machine_id": "mch_test_0123456789abcdef",
"task": "Open the billing page and download the latest invoice as PDF",
"cua_version": "v5",
"instructions": null,
"max_steps": 40,
"on_awaiting_human": "pause",
"steps_completed": 0,
"credits_charged": 0,
"cost_cents": 0,
"result": null,
"error": null,
"awaiting_human_reason": null,
"metadata": {
"team": "finance"
},
"webhook_url": "https://example.com/hooks/coasty",
"created_at": "2026-06-01T12:00:00Z",
"started_at": null,
"awaiting_human_since": null,
"finished_at": null,
"request_id": "req_4f9a2b1c",
"webhook_secret": "whsec_one_time_value_shown_here"
}queued to running, can bounce between running and awaiting_human, and ends in one of succeeded, failed, cancelled, or timed_out. Terminal states are immutable, so it is always safe to stop polling once you reach one. Runs need the runs:read and runs:write scopes, granted to new keys by default.Streaming events
Streaming events, at a glance
Live SSE stream with Last-Event-ID replay
GET /v1/runs/{id}/events- 01ConnectAccept: text/event-stream
- 02Consumestrictly ordered IDs
- 03Persistlast durable cursor
- 04ResumeLast-Event-ID replay
The response remains request-correlated.
GET /v1/runs/{id}/events returns a Server-Sent Events stream so you can follow a run as it happens, instead of polling. Each event has a type and a numeric id (the sequence number). If your connection drops, reconnect and replay everything you missed by sending the last sequence you saw as a Last-Event-ID header, or as the ?after= query parameter. The stream is ordered and at least once: deduplicate by run ID plus sequence, and atomically persist the last processed sequence with your side effects because a disconnect before that commit can replay an event. Successfully persisted frames remain replayable, but intermediate text, reasoning, tool, step, billing, pause/resume, status, and error appends are best-effort and can be absent after a transient storage failure. Terminal status and done are transactionally admitted with Agent Run terminal state. Always reconcile current state with GET /v1/runs/{id}.
import os, httpx
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
run_id = "7a1b2c3d-4e5f-4678-9abc-def012345678"
last_seq = 0 # persist this so a reconnect can replay
# httpx streams the SSE body line by line. Reconnect with Last-Event-ID.
with httpx.stream(
"GET",
f"{BASE}/runs/{run_id}/events",
headers={**HEADERS, "Last-Event-ID": str(last_seq)},
timeout=None,
) as resp:
event_type = "message"
for line in resp.iter_lines():
if line.startswith("id:"):
last_seq = int(line[3:].strip())
elif line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
print(event_type, data)
if event_type == "done":
breakHuman takeover
Human takeover, at a glance
Pause on awaiting_human, hand back with resume
POST /v1/runs/{id}/resume- 01Pauseawaiting_human event
- 02Inspectoperator sees current screen
- 03Decideapprove, reject, or input
- 04Resumeautomation continues
The run commits an awaiting state.
Some steps need a person: a captcha, a one-time code, a judgment call. When the agent reaches one and on_awaiting_human is pause, the run moves to awaiting_human and emits an awaiting_human event with a reason. A human completes the blocking step (in the same machine session), then you hand control back with POST /v1/runs/{id}/resume and an optional note. Resume is only valid while the status is awaiting_human.
import os, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
run_id = "7a1b2c3d-4e5f-4678-9abc-def012345678"
run = requests.get(f"{BASE}/runs/{run_id}", headers=HEADERS, timeout=30).json()
# resume is only valid while status == "awaiting_human".
if run["status"] == "awaiting_human":
print("paused:", run["awaiting_human_reason"])
# ... a human completes the blocking step out of band ...
resumed = requests.post(
f"{BASE}/runs/{run_id}/resume",
headers=HEADERS,
json={"note": "Solved the captcha; continue"},
timeout=30,
).json()
print(resumed["status"]) # back to "running"status == awaiting_human with awaiting_human_reason set). The SSE awaiting_human event and run.awaiting_human webhook are low-latency hints, but both are best-effort and may be missed. After resume, the run returns to running and normally emits a resumed event. Set on_awaiting_human to fail or cancel at create time if you would rather the run stop than wait for a human.Webhooks
Webhooks, at a glance
HMAC-signed run lifecycle callbacks
Coasty → your HTTPS webhook- 01Eventrun state committed
- 02Signtimestamp + HMAC
- 03Deliverbounded retry
- 04Dedupereceiver records event ID
Delivery starts after durable transition.
Pass a webhook_url (https only) when you create a run and we POST a signed callback on lifecycle transitions. The response to your create call includes a webhook_secret only in the create response or an exact bounded Idempotency-Key replay: store it, because GET/list never return it and every callback is signed with it. Each request carries a Coasty-Signature header of the form t=<unix_ts>,v1=<hex>. Each terminal callback also carries Coasty-Delivery: <event_uuid>; the JSON body's id is that same UUID and its logical delivered_at timestamp remains stable across retries.
To verify, build the signed payload as "<t>." + raw_request_body, compute HMAC-SHA256 over it keyed by the webhook_secret, and compare against v1 with a constant-time check. Always hash the raw body bytes, before any JSON re-serialisation. Agent Run terminal delivery is durably queued and at least once, with at most three durably recorded delivery attempts. A worker crash after sending but before recording the outcome can cause additional duplicate HTTP sends after lease recovery. Recorded retryable failures become eligible again after about 30 seconds; redirects and 4xx responses are terminal. Atomically insert Coasty-Delivery into a uniquely constrained receiver table before applying side effects, and return success without repeating work when that UUID already exists. The non-terminal run.awaiting_human callback has up to three in-process attempts but no durable outbox or delivery UUID, so a worker failure can miss it; dedupe that event on (run.id, event, run.awaiting_human_since) and poll the run state.
import hashlib, hmac, os, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
# 1. Create a run with a webhook_url. Create/exact-replay responses return webhook_secret; GET/list do not.
run = requests.post(
f"{BASE}/runs",
headers=HEADERS,
json={
"machine_id": "mch_test_0123456789abcdef",
"task": "Reconcile the invoice against the order",
"webhook_url": "https://example.com/hooks/coasty",
},
timeout=30,
).json()
webhook_secret = run["webhook_secret"] # persist this securely
# 2. In your webhook handler, verify the Coasty-Signature header.
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
# Example (your framework supplies the raw body + header):
# ok = verify(request.body, request.headers["Coasty-Signature"], webhook_secret)Bring your own model
Bring your own model, at a glance
Run the harness on your own Anthropic or OpenAI key
X-LLM-Provider + X-LLM-Api-Key- 01Selectprovider + model
- 02Protectencrypt execution authority
- 03Callprovider processes screen
- 04Recordtokens + $0 platform
Header or encrypted stored key is selected.
By default every LLM call in the computer-use harness runs on Coasty's managed models. With a live Coasty key, BYOK (bring your own key) flips that: opt in and the entire harness (the worker, grounding, the code agent, and compaction; every LLM call) runs on your own Anthropic or OpenAI account instead. Opt-in is always explicit, per request or per stored key. provider: "managed" (or omitting llm entirely) keeps the platform default, unchanged.
There are two ways to hand over a key. Store it once with PUT /v1/llm/keys/{provider} (encrypted with AES-256-GCM at rest and authenticated to your tenant+provider identity; only a 12-lowercase-hex sha256-prefix fingerprint is ever echoed back), or send it per request in headers. A legacy unbound stored envelope is fingerprint-checked and atomically upgraded before its plaintext can reach a provider. A header key takes precedence over the stored key. Sending a key without a provider returns 422.
POST /v1/predict, POST /v1/ground, POST /v1/sessions, and POST /v1/sessions/{id}/predict. Under test auth, predict, ground, and session create require an explicit per-request X-LLM-Api-Key plus X-LLM-Provider. A body llm.provider without that header fails closed with 422 LLM_KEY_NOT_CONFIGURED: “Stored provider keys are unavailable for test API keys. Send X-LLM-Api-Key explicitly for direct BYOK.” A test key never reads or uses a stored live provider key. Session create fixes the explicit header key for inherited session predicts but makes no inference call and bills no provider tokens; actual provider calls occur on predict, ground, and session predict. Those calls debit zero Coasty credits, but Anthropic/OpenAI can bill the supplied provider key. Task runs, both Workflow run starts, and schedules remain deterministic sandbox executions under a test Coasty key only in managed mode (llm.provider: "managed" or no BYOK intent). BYOK headers or provider intent on those async endpoints return 422 LLM_PROVIDER_UNSUPPORTED before execution. “BYOK is unavailable for synthetic test runs, workflows, and schedules. Use managed mode or a live Coasty API key.” This avoids silently ignoring a provider secret. They never decrypt or require a stored provider key and do not call or bill Anthropic/OpenAI. Test keys cannot access stored-key endpoints. Keep real provider secrets out of sandbox requests and CI fixtures. Use an sk-coasty-live- key for real asynchronous Task, Workflow, or schedule execution.X-LLM-Provider: anthropic
X-LLM-Api-Key: sk-ant-your_key_here
X-LLM-Model: claude-sonnet-5With a live Coasty key, BYOK covers seven configuration-root categories, including every LLM-backed execution path: POST /v1/predict, POST /v1/ground, POST /v1/sessions (later session predicts inherit it), POST /v1/runs, POST /v1/workflows/runs (ad-hoc), POST /v1/workflows/{workflow_id}/runs (saved), and POST /v1/schedules. The session family adds the inherited POST /v1/sessions/{id}/predict operation; the schedule family adds run-now, due, webhook, and chain fires. Parse and direct machine-action endpoints do not call an LLM. For a schedule, store the provider key first: a header key is accepted only when it matches the stored key, and each fire resolves the current stored key because request headers no longer exist then. Stored keys are managed through three endpoints, gated by the llm_keys scope (granted to new live keys by default). These endpoints require a live key; sandbox keys cannot read, overwrite, or delete the production credential store:
import os, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
# Store (upsert) your own Anthropic key. Encrypted at rest; never echoed back.
stored = requests.put(
f"{BASE}/llm/keys/anthropic",
headers=HEADERS,
json={"api_key": os.environ["ANTHROPIC_API_KEY"]},
timeout=30,
).json()
print(stored) # {"provider": "anthropic", "key_fingerprint": "a1b2c3d4e5f6", "stored": true}
# List stored keys: provider, fingerprint, timestamps. Never the key itself.
keys = requests.get(f"{BASE}/llm/keys", headers=HEADERS, timeout=30).json()
for k in keys["keys"]:
print(k["provider"], k["key_fingerprint"])
# Delete when you rotate away (404 LLM_KEY_NOT_FOUND when none is stored)
requests.delete(f"{BASE}/llm/keys/anthropic", headers=HEADERS, timeout=30)Those seven start/create endpoints accept an llm object that selects the provider and, optionally, a model per harness role. It deliberately has no api_key field (a 422 if you attempt one): keys ride headers or the encrypted store only, so they can never be echoed in run objects, webhooks, or idempotency replays. The runnable examples require COASTY_API_KEY to be a live key and COASTY_MACHINE_ID to identify a real managed or developer-owned external machine; BYOK intentionally rejects sandbox authentication and mch_test_* machines.
import os, requests
BASE = "https://coasty.ai/v1"
AUTH_HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
assert AUTH_HEADERS["X-API-Key"].startswith("sk-coasty-live-"), "BYOK requires a live Coasty key"
CREATE_HEADERS = dict(AUTH_HEADERS)
# Set USE_HEADER_BYOK=1 to send a request-scoped provider key; otherwise the
# previously stored Anthropic key is used. Exactly one run is created.
if os.getenv("USE_HEADER_BYOK") == "1":
CREATE_HEADERS.update({
"X-LLM-Provider": "anthropic",
"X-LLM-Api-Key": os.environ["ANTHROPIC_API_KEY"],
"X-LLM-Model": "claude-sonnet-5",
})
# Start a run on YOUR Anthropic key (stored earlier via PUT /llm/keys/anthropic).
# The llm block deliberately has NO api_key field (422 if you try): keys ride
# headers or the encrypted store only, never request bodies.
run = requests.post(
f"{BASE}/runs",
headers=CREATE_HEADERS,
json={
"machine_id": os.environ["COASTY_MACHINE_ID"],
"task": "Open the billing page and download the latest invoice as PDF",
"llm": {
"provider": "anthropic", # or "openai"; "managed" = platform default
"model": "claude-sonnet-5", # any model on your account (vision-capable)
"compaction_model": "claude-haiku-4-5", # optional per-role override
},
},
timeout=30,
).json()
# The run echoes a non-secret llm block; the key itself is never returned.
run = requests.get(f"{BASE}/runs/{run['id']}", headers=AUTH_HEADERS, timeout=30).json()
print(run["llm"]) # {"provider": ..., "model": ..., "key_fingerprint": ..., "key_source": ..., "key_scrubbed": ...}Per-role overrides exist for tuning cost against quality: run compaction on a cheaper model while the worker stays on the default, for example. With a live Coasty key, runs and workflow runs snapshot the key encrypted into their run record. This lets crash-recovery on another replica keep using your key; the ciphertext is scrubbed the moment the run reaches a terminal state. Under test auth, BYOK intent returns 422 LLM_PROVIDER_UNSUPPORTED before any snapshot or execution; only managed-mode runs use deterministic sandbox execution. GET /v1/runs/{id} echoes a non-secret llm block: {provider, model, key_fingerprint, key_source, key_scrubbed}. Workflow-run responses expose the same credential-free block. Schedules are different: live schedules store and return only the non-secret provider/model-role preference and key fingerprint, never a key, ciphertext, or key source. That preference is immutable for the schedule: PATCH rejects llm, so delete and recreate the schedule to change provider or role models. Rotate the stored key at any time; the next fire uses it without recreating the schedule. Managed-mode test-key schedules stay deterministic sandbox executions and do not resolve or call a provider; test-auth BYOK intent is rejected before schedule creation. Delete the stored key and future firings fail loudly with LLM_KEY_NOT_CONFIGURED; they never silently run on platform keys. Deletion stops future stored-key lookups, but it does not revoke the encrypted snapshot already held by an active run or workflow. Cancel each active execution to stop further provider calls; terminal transition then scrubs its ciphertext.
grounding_model to experiment before committing a cheaper or different model to that role.POST /v1/sessions resolves the BYOK provider/key once and keeps that fixed create-time configuration in the owning process's memory. Every later POST /v1/sessions/{id}/predict inherits it. Deleting or rotating the stored key does not revoke or update an active session. Use DELETE /v1/sessions/{id} (or let it expire) to stop further session calls; provider-side revocation also stops the key upstream.LLM_* codes. Provider authentication, rate-limit, quota, connection/timeout, and server failures are classified into the stable codes below. Other provider client rejections can use the endpoint's ordinary failure code, but they still never trigger platform fallback: LLM_KEY_NOT_CONFIGURED, LLM_KEY_INVALID, LLM_MODEL_INVALID, LLM_PROVIDER_AUTH_FAILED, LLM_PROVIDER_RATE_LIMITED, LLM_PROVIDER_QUOTA_EXCEEDED, and LLM_PROVIDER_ERROR.Screenshot flow: send the current PNG/JPEG as raw base64 or an exact data URI to POST /v1/predict with an instruction such as "Click Continue". Use X-LLM-Provider: anthropic plus $ANTHROPIC_API_KEY, or X-LLM-Provider: openai plus $OPENAI_API_KEY. The body is identical for both providers. The returned structured click/type/key actions use the echoed screen_width and screen_height coordinate space.
# Anthropic
curl https://coasty.ai/v1/predict -H "X-API-Key: $COASTY_API_KEY" \
-H "X-LLM-Provider: anthropic" -H "X-LLM-Api-Key: $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"screenshot":"<base64-png-or-jpeg>","instruction":"Click Continue"}'
# OpenAI — same screenshot body
curl https://coasty.ai/v1/predict -H "X-API-Key: $COASTY_API_KEY" \
-H "X-LLM-Provider: openai" -H "X-LLM-Api-Key: $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"screenshot":"<base64-png-or-jpeg>","instruction":"Click Continue"}'Billing: every BYOK LLM operation debits zero Coasty platform credits. Provider-direct predict, ground, and session-predict responses report credits_charged: 0, cost_cents: 0, real provider input/output tokens, non-secret provider/model/key attribution, and platform_cost_exempt: true. Session create reports zero tokens because it configures the inherited provider key without making an inference call. Live asynchronous run and workflow creation responses cannot report future tokens; terminal run results expose usage when execution produced it. Live schedule create, get, list, PATCH, pause, and resume responses expose the non-secret llm preference. Schedule run-history records deliberately do not contain llm or future token totals. Anthropic or OpenAI bills actual tokens only when a provider-direct call occurs. Managed-mode test Task runs, Workflow task children, and schedules produce no provider token charge. BYOK intent on those test-auth async endpoints is rejected with 422 LLM_PROVIDER_UNSUPPORTED before execution. Managed requests keep the published Coasty prices; separately chosen machine, cloud, network, or third-party costs are not waived.
Data routing: when a provider-direct BYOK call occurs, Coasty transmits your prompt and screenshots to the selected Anthropic/OpenAI account. Its retention, training, region, and compliance settings are controlled by your provider agreement and account. Test-key Task, Workflow, and schedule sandbox execution sends none of that content to the configured provider. Direct BYOK predict, ground, and session-predict use a fail-closed durability boundary. Before provider egress, one transaction commits the exact caller instruction, optional custom system_prompt / instructions, selected CUA version, non-secret attribution, the input screenshot, and every direct-predict trajectory screenshot. With screenshot encryption enabled, those payloads are stored as AES-256-GCM ciphertext at rest with authenticated tenant, operation, and screenshot-slot identity; with it disabled, the exact base64 is retained for the account-lifetime audit. If the encryption preference cannot be resolved or opted-in encryption cannot be honored, provider egress is blocked. If that transaction fails, the provider is not called. After inference, the validated public response, response-visible actions/reasoning/canonical raw code, token counters, and final audit row are checkpointed before durable usage admission and idempotency completion. A same-key retry after a later settlement failure replays that checkpoint without another provider call.
That no-recall guarantee is scoped to a stable operation identity. Send an Idempotency-Key on provider-direct predict, ground, and session-predict whenever you need retry safety. An identical retry with the same key replays or safely quarantines the original operation without a second provider inference. Without an Idempotency-Key, a later HTTP request is a new operation and may call and bill your provider again. Do not automatically retry an unkeyed SETTLEMENT_INCOMPLETE; first reconcile provider usage and observed machine state.
Crash ambiguity is conservative. Once the durable journal says started, Coasty never sends that operation identity to Anthropic/OpenAI again unless a completed session owner can prove it has an in-memory replay. A crash after started but before response checkpointing returns 503 SETTLEMENT_INCOMPLETE (outcome unknown) for that operation identity. The missing response and exact token counts cannot be reconstructed, so reconcile the provider usage dashboard and observed machine state before deliberately choosing a new Idempotency-Key.
Live BYOK Tasks, Workflow task steps, and schedule firings apply the same pre-egress principle immediately before each agent.predict CUA visual decision step. Coasty synchronously commits the exact pre-predict frame (the screenshot supplied to that decision), exact effective worker instruction (including injected environment context), and non-secret attribution under an attempt-scoped request id. One immutable model_input_NNNN frame is retained per CUA visual decision step, bounded to 1,000 steps and the existing 10 MiB encoded screenshot ceiling. Synchronous delegated employees inherit the same private callback but derive deterministic tenant/root/delegation-path-bound child attempt identities and keep their own local frame sequence. Parent, sibling, nested-child, and retry rows therefore cannot skip the audit lane, overwrite one another, or collide on local step numbers. Usage accounting separately aggregates token totals and per-call role data for worker, grounding, code-agent, compaction, and derived calls; that does not mean the model-input audit copies every internal role prompt, system template, or provider payload. These rows reuse the same tenant-scoped, service-only api_requests / api_screenshots account-lifetime retention, export, and deletion rules; no separate TTL is invented. With screenshot encryption enabled, stored model-input payloads are AES-256-GCM ciphertext bound to the tenant/request/frame-slot identity; with it disabled, their exact base64 is retained. If the preference cannot be resolved or opted-in encryption cannot be honored, the provider is not called for that step. Pixels never enter Task/Workflow events, webhooks, idempotency records, or application stdout. This is not every internal provider-call prompt, a raw internal system-template transcript, or a full provider payload.
Workflows
Workflows, at a glance
Compose many runs with a versioned JSON DSL
POST /v1/workflows- 01DefineJSON steps + inputs
- 02Versionimmutable run snapshot
- 03Executetasks and control flow
- 04Returnstructured output
IDs, references, and step types are checked.
A workflow composes many runs into one versioned program, with branching, loops, and guards expressed as a JSON DSL. Each task step is itself an agent run, so a workflow is the way to chain tasks, gate them on conditions, and pass results between them. Workflows are versioned: re-creating the same slug bumps the version, and a PUT does too.
Create one with POST /v1/workflows. The slug must match [a-z0-9_-]. The response is a Workflow carrying an id, a version, and the current dsl_version (2026-06-01).
import os, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
definition = {
"steps": [
{
"id": "fetch",
"type": "task",
"task": "Open order {{inputs.order_id}} and read the invoice total",
"save_as": "invoice",
},
{
"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": "no", "type": "fail", "message": "Invoice not marked paid"}],
},
],
}
# 1. Create the workflow. Re-using the same slug bumps its version.
wf = requests.post(
f"{BASE}/workflows",
headers=HEADERS,
json={
"name": "Invoice reconciliation",
"slug": "invoice-reconcile",
"inputs_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}},
"definition": definition,
},
timeout=30,
).json()
print(wf["id"], "v", wf["version"], wf["dsl_version"])
# 2. Start a run of the saved workflow.
run = requests.post(
f"{BASE}/workflows/{wf['id']}/runs",
headers=HEADERS,
json={"inputs": {"order_id": "ord_4821"}, "machine_id": "mch_test_0123456789abcdef", "budget_cents": 500},
timeout=30,
).json()
print(run["id"], run["status"])A concurrent update based on a stale workflow version fails with 409 CONFLICT. Fetch the workflow again, reapply the intended change to the latest state, and retry.
workflows:read and workflows:write scopes, granted to new keys by default. See the Workflow DSL for the full step and condition catalogue.Workflow DSL
Workflow DSL, at a glance
Steps, structured conditions, and variable refs
definition.steps[]- 01Inputsvalidated run values
- 02Stepstyped operations
- 03Refsstructured value paths
- 04Outputresolved JSON
The schema rejects malformed values.
The DSL (dsl_version 2026-06-01) is a JSON object with a steps array and an optional output. Each step has an id and a type. A task step runs the agent and binds its result ({ status, passed, result, run_id, steps, error }) under both its save_as name and its step id, so later steps can read it.
{
"dsl_version": "2026-06-01",
"definition": {
"steps": [
{
"id": "fetch",
"type": "task",
"task": "Open order {{inputs.order_id}} and read the invoice total",
"save_as": "invoice"
},
{
"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": "no",
"type": "fail",
"message": "Invoice not marked paid"
}
]
}
],
"output": {
"paid": "{{invoice.result}}"
}
}
}Conditions are structured rather than expression strings, which keeps them injection-safe. Each left, right, or value is either a literal or a {{path}} reference. Paths are dotted lookups into inputs.*, vars.*, and any step id or save_as name.
budget_cents (spend cap in USD cents; 0 means unlimited), max_iterations (loop cap), and deadline_seconds (wall-clock). A breach ends the run as failed or timed_out.A definition is validated before it is accepted. The limits below are enforced at create and ad-hoc time, so an invalid definition is rejected with 422 VALIDATION_ERROR rather than failing mid-run.
definition is snapshotted into that run, so editing or replacing the workflow (which bumps its version) never changes runs already in flight. Each run records the workflow_version it executed.Running workflows
Running workflows, at a glance
Saved runs, ad-hoc runs, and guards
POST /v1/workflows/{id}/runs- 01Startsaved or ad-hoc definition
- 02Advancestep state machine
- 03Guardbudget + iterations
- 04Inspectevents + terminal output
Version and inputs become durable.
Start a saved workflow with POST /v1/workflows/{id}/runs, or run a definition inline (without saving) with POST /v1/workflows/runs by adding a definition (and optional inputs_schema) to the same body. Both return a workflow.run. The body accepts inputs, a default managed-or-external machine_id for task steps, and the budget_cents, max_iterations, and deadline_seconds guards. Optional action_policy is inherited by every task step, retry, recovery, and nested delegation. An Idempotency-Key header is honoured here too, but it deduplicates workflow-run creation only; it does not make every nested machine action exactly once.
action_policy. It is atomically persisted and passed unchanged through task steps, retries, worker recovery, and nested CUA delegation. Budgets, assertions, deadlines, and approvals remain independent controls.import os, requests
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
# POST /v1/workflows/runs runs a definition inline, without saving a workflow.
run = requests.post(
f"{BASE}/workflows/runs",
headers=HEADERS,
json={
"machine_id": "mch_test_0123456789abcdef",
"inputs": {"url": "https://status.example.com"},
"max_iterations": 5,
"definition": {
"steps": [
{
"id": "open",
"type": "task",
"save_as": "page",
"task": "Open {{inputs.url}} and report whether all systems are operational",
},
{
"id": "gate",
"type": "assert",
"condition": {"op": "truthy", "value": "{{page.passed}}"},
},
],
},
},
timeout=30,
).json()
print(run["id"], run["status"]) # object == "workflow.run"GET /v1/workflows/runs/{id}instead of waiting indefinitely for an event or callback.{
"id": "wfr_5e6f7a8b",
"object": "workflow.run",
"status": "running",
"workflow_id": "wf_1a2b3c",
"workflow_version": 3,
"machine_id": "mch_test_0123456789abcdef",
"inputs": {
"order_id": "ord_4821"
},
"output": null,
"error": null,
"awaiting_human_reason": null,
"awaiting_step_id": null,
"iterations_used": 0,
"spent_cents": 0,
"budget_cents": 500,
"created_at": "2026-06-01T12:00:00Z",
"started_at": "2026-06-01T12:00:01Z",
"finished_at": null,
"request_id": "req_9c8b7a6d"
}Schedules
Schedules, at a glance
Create, inspect, update, pause, and delete scheduled tasks
POST /v1/schedules- 01Configuretask + timing + timezone
- 02Computenext fire time
- 03Claimdedupe due attempt
- 04Rundurable execution
Timing and execution authority are validated.
A schedule runs one Task against an owned managed or external machine on a preset cadence, custom cron expression, or one-shot run_at. Create it with POST /v1/schedules and schedules:write. Supply exactly one timing mode: a recurring frequency (plus cron when frequency is custom) or a one-shot ISO-8601 run_at. timezone is an IANA name; weekly day_of_week uses Monday=0 through Sunday=6.
{
"name": "Daily invoice sweep",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task_prompt": "Open billing and download every new invoice",
"frequency": "daily",
"time": "09:00",
"timezone": "America/New_York",
"max_consecutive_failures": 5,
"llm": {
"provider": "openai",
"model": "gpt-5.6-sol"
},
"action_policy": {
"blocked_keys": [
"escape"
],
"block_window_close": true,
"max_actions": 80
}
}action_policy as an immutable boundary inherited by every firing, retry, worker recovery, and nested delegation. PATCH intentionally cannot change it; create a replacement schedule when the boundary must change.PUT /v1/llm/keys/{provider}. A creation-time X-LLM-Api-Key is validation-only and must match that stored key. The schedule stores only provider/model-role selections and a fingerprint, never provider key material.Create, get, list, PATCH, pause, and resume return a sanitized schedule with id, timing, machine/task fields, enabled, next/last run times, counters, pause reason, metadata, and a credential-free llm preference when BYOK is configured. One-shot schedules pause after firing. Reaching max_consecutive_failures trips the circuit breaker and pauses future fires.
PATCH accepts name, task_prompt, timing fields,max_consecutive_failures, enabled, and metadata. It deliberately does not accept machine_id, run_at, or llm. A schedule's BYOK provider/model-role preference is immutable; delete and recreate the schedule to change it. Rotating the stored key is different: each fire resolves the current stored key, so a replacement takes effect on the next fire without recreating the schedule. The schedule's displayed fingerprint remains its creation-time reference; per-fire usage attribution records the fingerprint of the key actually used.
Run now & history
Run now & history, at a glance
Fire immediately and inspect durable execution history
POST /v1/schedules/{id}/run- 01Triggerrun now or due
- 02Attemptunique execution fence
- 03Executetask on machine
- 04Historyterminal record
Schedule state and concurrency are checked.
POST /v1/schedules/{id}/run queues one immediate fire. Its optional body is { task_prompt_override?, triggered_context? }; triggered context must be JSON and is capped at 1 MB. Send an Idempotency-Key when a network retry must not enqueue another run. The response is { schedule_id, run_id, status, message, request_id }.
Every execution path, run-now, due cron/run-at, inbound webhook, or schedule chain, uses the schedule's fixed non-secret LLM preference. At each live BYOK fire, Coasty resolves the current encrypted stored provider key. A missing, deleted, or unreadable key fails loudly with no managed fallback. Successful BYOK execution opens no Coasty billing session and records zero Coasty credits; the selected provider can bill its actual tokens.
A history record contains id, schedule_id, status, trigger, duration, credits_charged, error, and execution time. It deliberately does not contain llm, provider credentials, screenshots, prompts, or token totals. For managed non-Unlimited schedules, credits_charged is consumer subscription-credit quota, not Developer API wallet cents. It is zero for BYOK, test, Unlimited-bypass, and other non-billable runs, with no fixed USD conversion.
Triggers & webhooks
Triggers & webhooks, at a glance
HMAC-authenticated inbound fires and schedule chains
POST /v1/triggers/webhook/{id}- 01Receiveraw body + timestamp
- 02Verifyconstant-time HMAC
- 03Dedupepayload identity
- 04Fireschedule run admitted
The exact raw bytes are retained for signing.
Add a trigger with POST /v1/schedules/{id}/triggers and triggers:write. A webhook trigger returns its public URL and signing secret only on create (and an exact bounded idempotency replay); save it in a secrets manager. GET/list returns no secret. A chain trigger starts the target when another schedule completes, fails, or reaches either outcome, with a maximum chain depth of five.
Webhook ingress uses no Coasty API key. Compute HMAC-SHA256 over<unix_ts>.<raw_body> with the create-time secret and send exactly one of Coasty-Signature or X-Coasty-Signature as t=<unix_ts>,v1=<hex_digest>. The server verifies the raw bytes, timestamp window, body cap, per-webhook rate limit, and identical-body deduplication before durable dispatch. Read effective replay, dedup, body-size, rate, and wallet-gate values from GET /v1/models under pricing.schedules.
Provisioning
Provisioning, at a glance
Provision a cloud VM the agent can drive
POST /v1/machines- 01ChooseOS, region, TTL
- 02Provisioncreate isolated VM
- 03Attachsecure driver connects
- 04Readymachine ID usable
Ownership, wallet, and limits are checked.
A machine is the stable execution target used by Tasks and Workflows. It can be a managed Coasty cloud VM or an external machine whose driver you operate. For a managed VM, provision one, poll until it is running, then either hand it to a task run (the agent drives it to done) or drive it yourself with the action endpoints. Machines are optional for primitives: /predict, /sessions, and /ground run against your screen. You only need a Coasty machine or an enrolled external machine when you want the hosted Task/Workflow runtime to own the loop.
Provision with POST /v1/machines. Only display_name is required; everything else has a sensible default. The body rejects unknown fields, so a typo returns 422 VALIDATION_ERROR rather than being silently ignored.
curl -X POST https://coasty.ai/v1/machines \
-H "X-API-Key: $COASTY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"display_name": "agent-box", "os_type": "linux", "desktop_enabled": true, "ttl_minutes": 120}'Provisioning is asynchronous. The response returns immediately with the machine in creating status and a connection object whose secrets are redacted. The VM is not drivable yet — poll GET /v1/machines/{id} until status is running before you send actions or start a run.
{
"machine": {
"id": "9f2c1e7a-3b6d-4c81-9a0e-2d5f8b1c4e90",
"display_name": "agent-box",
"kind": "managed",
"capabilities": [
"screenshot",
"mouse",
"keyboard",
"scroll",
"terminal",
"files",
"browser"
],
"protocol_version": null,
"connection_status": null,
"last_seen_at": null,
"status": "creating",
"os_type": "linux",
"desktop_enabled": true,
"cpu_cores": 2,
"memory_gb": 4,
"storage_gb": 16,
"public_ip": null,
"auto_destroy_at": "2026-06-17T14:30:00Z",
"ttl_minutes": 120,
"is_test": false,
"created_at": "2026-06-17T12:30:00Z"
},
"connection": {
"public_ip": null,
"ssh_port": 22,
"ssh_username": "ubuntu",
"has_ssh_key": true,
"has_vnc_password": true
},
"request_id": "req_2f9c1a7b3e4d"
}The machine object — returned by provision, list, and get:
List your machines with GET /v1/machines (newest first, ?limit= 1–200, default 50) — it returns { data, has_more, request_id }. Fetch one with GET /v1/machines/{id}. Both read straight from the registry, so they keep working even when provisioning is busy.
machines:write scope, a wallet balance of at least 20 credits (including Unlimited consumer subscribers), and room under your plan's concurrent-machine cap. Building? An sk-coasty-test- key returns a fully-shaped mock VM (id mch_test_…, is_test: true) with zero billing — up to 5 at a time.Bring your machine
Bring your machine, at a glance
Stream screenshots and execute fenced actions on your own device
POST /v1/machines/external- 01Enrollowner key + capabilities
- 02Tokendriver-only credential
- 03Exchangescreenshots and commands
- 04Reportfenced results
Capabilities and dimensions are pinned.
External machines let the hosted Task and Workflow runtimes drive a desktop you operate. Your driver captures screenshots, uploads observations, long-polls typed commands, executes them locally, and acknowledges results. The resulting machine_id is used unchanged by POST /v1/runs, Workflow task steps, and schedules. No alternate run DSL or screenshot field is required.
1. Enroll with the owner API key. Call POST /v1/machines/external with machines:write. Protocol 1 requires screenshot; advertise only capabilities the driver really implements. Optional dimensions must be supplied as a width/height pair. Send a required, unique Idempotency-Key. The 201 response is Cache-Control: no-store and contains the machine-scoped device_token. An exact key/body replay can recover the same enrollment during its 24-hour recovery window; list and get never expose the token.
{
"display_name": "support-laptop",
"platform": "windows",
"protocol_version": "1",
"capabilities": [
"screenshot",
"mouse",
"keyboard",
"scroll"
],
"screen_width": 1920,
"screen_height": 1080,
"metadata": {
"environment": "production"
}
}Device authentication uses layered limits: before a token is trusted, the peer bucket allows 1,000 attempts/minute and 40,000/hour; after authentication, each token allows 300 requests/minute and 12,000/hour, while all device tokens on one account share 1,200 requests/minute and 48,000/hour. Screenshot observations have an additional per-token limit of 60/minute and 3,600/hour. On 429, honor the response retry metadata instead of spinning.
2. Establish liveness and submit the first frame. Send POST /v1/machines/{id}/heartbeat with Authorization: Bearer $COASTY_DEVICE_TOKEN, protocol 1, and the enrollment fencing_token. Then upload a monotonically increasing sequence to /observations. Screenshots may be raw base64 or a matching PNG/JPEG data URI, are limited to 10 MiB of base64 payload and decoded dimensions from 320×240 through 3840×2160, and must contain exactly one frame. Declared media type, optional dimensions, and optional SHA-256 must match the submitted decoded bytes.
Heartbeat returns the authoritative last_sequence (zero before any frame) and nullable last_frame_id. After a driver restart, long-poll once to recover the current fence, heartbeat with it, then submit sequence = last_sequence + 1; never guess or reset the counter to one.
The observation response assigns a canonical frame_id, returns verified dimensions, and echoes the current fencing generation. Its sha256 covers the server-normalized bytes and may differ from the optional request digest because metadata is stripped and the image is re-encoded. Live frames are encrypted, logically expire after 15 minutes, and are never copied into SSE, webhooks, ordinary application logs, or ordinary durable run history. This transport retention is separate from BYOK model-input auditing: if a live BYOK Task, Workflow, or schedule uses the frame for a visual decision, the fail-closed pre-provider audit retains a separate exact model-input copy under the account-lifetime screenshot rules described in Bring your own model.
3. Long-poll and execute safely. Call GET /v1/machines/{id}/commands?after=0&limit=10&wait_seconds=20. A timeout is a normal 200 with data: []. Each response carries next_cursor; persist it only after durably processing the batch, then reconnect with after=next_cursor. This produces at-least-once delivery without losing commands across restarts. The server allows exactly one queued or delivered command per external machine; concurrent dispatch returns 409 MACHINE_BUSY, preventing cross-replica races on one physical display.
Coordinates are pixels in the verified observation's exact decoded dimensions. Do not resize after capture without applying the same scale to every coordinate. Check the command deadline, fence, frame precondition, capability, and a local human-stop control immediately before input. Owner calls to the normal single-action endpoint on a connected external machine enter this same pull/result loop; they are not reported as successful until the driver acknowledges the command.
4. Acknowledge idempotently. POST { fencing_token, precondition_frame_id, success, result?, error?, observation? } to /commands/{command_id}/results. A successful result cannot carry an error; a failed result must. precondition_frame_id is required even when its value is null, and must exactly echo the command envelope. A mismatch returns 409 FRAME_PRECONDITION_MISMATCH. Embedding observation atomically supplies the post-action frame. Byte-equivalent replay returns replayed: true; a conflicting second result, stale fence, cancellation, or expiration returns 409 and must not cause local re-execution.
5. Run normal Tasks and Workflows. Once connection_status is connected, pass the external machine id as the ordinary machine_id. An offline/stale driver leaves a run waiting for observations and eventually fails with a stable machine-offline error; it never silently switches to a managed VM. Parallel Workflow task steps sharing one physical display must be serialized unless the driver explicitly provides isolated displays.
External enrollment, heartbeats, observations, command polling, and results are free and have no Coasty VM runtime charge. Managed Task/Workflow steps retain their published price. Live-key BYOK steps record provider tokens and charge zero Coasty credits. Managed-mode test-key steps remain deterministic sandbox and make no provider call; test-auth BYOK intent returns 422 LLM_PROVIDER_UNSUPPORTED before execution.GET /v1/machines/pricing reports all four transport fields as zero: enrollment, runtime per hour, direct actions, and frame transport. Those zeroes do not waive model inference performed by a Task or Workflow. Revoke the device token and cancel outstanding command leases with owner-authenticated DELETE /v1/machines/{id}. External machines have no Coasty-issued SSH, VNC, WebSocket, or browser-debug credentials, so /connection returns 409 INVALID_STATE. Because Coasty does not own the disk, /snapshot returns 400 UNSUPPORTED_MACHINE_KIND.
Lifecycle & TTL
Lifecycle & TTL, at a glance
Status, start/stop, managed destroy, external revoke, and runtime billing
PATCH /v1/machines/{id}- 01Runningactive runtime rate
- 02Stoppedreduced storage rate
- 03TTLautomatic destroy guard
- 04Terminatedno further billing
Start, stop, restart, or TTL update.
The power-state lifecycle applies to kind: managed. On an external machine, owner-authenticated start, stop, and restart are logical dispatch gates: they increment the fencing generation and cancel outstanding commands, but never power the caller-owned host on or off. External machines use connection_status, cannot be snapshotted, and are not billed for runtime. A managed machine moves through a small set of statuses. status is the field you poll: drive the machine only while it is running. The runtime rate that applies in each status is shown below; exact per-hour USD numbers are in the Pricing section.
Start, stop, restart are POST /v1/machines/{id}/start (and /stop, /restart). Managed-machine power changes are asynchronous: the call returns a transitional status (starting / stopping) and you poll until it settles. External logical gates commit immediately and return running or stopped. They are state-checked — starting a machine that is already running, or stopping one that is not running, returns 409 INVALID_STATE with current_state and allowed_from in the body, so you can react without guessing. start is allowed from stopped or error; stop only from running.
Terminate with DELETE /v1/machines/{id}. For a managed machine this permanently tears down the VM and its disk. For an external machine it revokes only the Coasty registration, device token, and outstanding command leases; the caller-owned host and storage remain untouched. A later GET returns 404 MACHINE_NOT_FOUND. Delete is idempotent, so an exact retry after an already-completed deletion is safe.
curl -X POST https://coasty.ai/v1/machines/$MACHINE_ID/stop -H "X-API-Key: $COASTY_API_KEY"
curl -X DELETE https://coasty.ai/v1/machines/$MACHINE_ID -H "X-API-Key: $COASTY_API_KEY"Auto-destroy / auto-revoke (TTL). A managed machine left running bills until you destroy it, so set ttl_minutes as a safety net. A background sweep (every ~60s) terminates managed infrastructure once its auto_destroy_at passes. On an external machine, expiry revokes only its Coasty registration/token/leases and never powers off or deletes the host or its storage. Adjust it any time with PATCH /v1/machines/{id}: ttl_minutes is measured from now (so it doubles as a lease extension), accepts 5–10080 (5 min to 7 days), and 0 clears auto-destroy entirely. Anything else is 400 INVALID_TTL. Provisioning without a TTL works but adds a Warning response header nudging you to set one.
curl -X PATCH https://coasty.ai/v1/machines/$MACHINE_ID \
-H "X-API-Key: $COASTY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ttl_minutes": 30}'Runtime billing. Machines bill the developer API wallet (separate from any subscription credits) by the minute, rounded down to whole credits in your favour. Published defaults are running Linux $0.05/hr, running Windows $0.09/hr, and a stopped or suspended machine the keep-alive rate of $0.01/hr; creating, error, and terminated are free. Transitional states (starting/stopping/restarting) bill at the running rate. The per-call control endpoints (actions, terminal, files, browser, screenshot, connection) are never billed. Managed-machine metering consists of runtime plus the separate one-time snapshot charge.
suspended) rather than destroying it — your disk and snapshots are preserved. Top up the wallet and POST /start to resume. You can watch live accrual for every metered machine at GET /v1/billing/active.Connect & control
Connect & control, at a glance
SSH/VNC, screenshots, snapshots, and the action surface
POST /v1/machines/{id}/actions- 01Inspectconnection or screenshot
- 02Commandtyped operation
- 03Fenceownership + policy
- 04Receiptstructured result
The ID resolves inside the caller tenant.
SSH/VNC credentials apply only to Coasty-managed machines; the direct-control and agent-run surfaces support both managed and caller-owned external machines. Once a machine is running, you can drive it through Coasty's control endpoints or hand it to an agent run; managed machines can also be reached directly over SSH/VNC. Normal machine responses redact secrets and only expose has_ssh_key / has_vnc_password booleans plus ports.
Connection secrets. GET /v1/machines/{id}/connection returns the full ssh_private_key_pem, vnc_password, public IP, and ports. It is gated by the opt-in connection:read scope (not granted by default — request it when you mint the key), and the response is sent Cache-Control: no-store. Treat that payload like a password: never log it. SSH usernames are ubuntu on Linux and Administrator on Windows; VNC details exist only on desktop_enabled machines.
curl https://coasty.ai/v1/machines/$MACHINE_ID/connection \
-H "X-API-Key: $COASTY_API_KEY"Screenshots & snapshots. GET /v1/machines/{id}/screenshot returns the current screen of a desktop machine (a still-booting VM returns 502 SCREENSHOT_FAILED — poll for running first). An external response also returns a canonical frame_id for those exact pixels; a fresh capture is a safe read-only resynchronization and advances that frame. POST /v1/machines/{id}/snapshot captures a restorable image (Linux), needs the snapshots:write scope, and is the one machine op with a flat fee ($0.01 per snapshot). A conclusive pre-creation failure is refunded, confirmed by X-Credits-Refunded. A timeout, an upstream 5xx, or malformed post-dispatch result may already have created the image and instead returns terminal 503 SNAPSHOT_OUTCOME_UNKNOWN without a blind refund or re-execution. Boot a future machine from a confirmed snapshot with restore_from_snapshot: true.
The control surface. Drive the VM directly with these endpoints. Each enforces a specific scope, and the high-risk ones — browser_execute (arbitrary JS) and anything under connection:read — are opt-in. /actions/batch runs up to 50 steps and stops on the first error by default (stop_on_error: false to continue); /terminal truncates output to 5000 chars.
Strict command contracts. /actions is a 50-command discriminated API, not a free-form command tunnel. The selected command chooses an exact parameter schema; unknown keys, incomplete alias pairs, mixed mutually exclusive shapes, non-JSON values, and parameter objects over 1,000,000 bytes are rejected before dispatch. Discover the exact command list from GET /v1/models under machine_action_commands, and generate validators from the OpenAPI ActionRequest.oneOf catalog.
POST /v1/predict), and send one action with that frame_id as precondition_frame_id. The driver rechecks the screen immediately before OS input and atomically returns the post-action screenshot, new frame_id, and observation_available. If pixels are unavailable after a committed mutation, capture again and re-plan; never repeat the mutation just to recover an image. Prefer single actions over external GUI batches because a future step cannot safely name a frame that does not exist yet. Missing mutation frames return 409 FRAME_PRECONDITION_REQUIRED; stale frames return 409 FRAME_PRECONDITION_MISMATCH or a typed unsuccessful device result.curl -X POST https://coasty.ai/v1/machines/$MACHINE_ID/terminal \
-H "X-API-Key: $COASTY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "ls -la /home/ubuntu"}'Idempotency & safety. The machine operations in the exact reserve-and-replay set — provision, snapshot, actions, action batches, browser, terminal, and file operations — accept an Idempotency-Key (≤128 chars). Lifecycle start/stop/restart, TTL updates, and termination do not. For supported operations, a duplicate key replays the original result without re-executing; a duplicate that arrives while the first is still running waits up to ~25s, then returns 409 IDEMPOTENCY_IN_FLIGHT. One nuance worth knowing: a command that never reached the VM (a dispatch failure) is not cached, so retrying the same key runs it fresh; a command the VM actually ran (even if it errored) is cached. Screenshot pixels are response-only and are never kept in the generic replay cache. A replay does not execute again: it retains frame_id, but returns screenshot: null and observation_available: false. Every machine lookup is ownership-scoped, so a wrong or someone-else's id returns 404 — never a leak. Full code list in Errors.
422 IDEMPOTENCY_KEY_REUSED. Rotating the owner API key starts a new replay namespace. External-machine enrollment is intentionally account-scoped so an exact enrollment replay can recover its device token after key rotation. BYOK-capable operations also bind the non-secret effective provider, model roles, and one-way key fingerprint; plaintext provider keys never enter the replay hash.Predict
Predict, at a glance
Low-level stateless screenshot to actions
POST /v1/predict- 01CapturePNG or JPEG screen
- 02Predictnext useful action
- 03Enforceserver action policy
- 04Applyclient executes batch
Pixels and the micro-goal are validated.
POST /v1/predict is the low-level, stateless prediction primitive. Each call is independent: you provide the full context, execute the returned actions, capture the next screenshot, and decide when to call again. Use it when your application must own that loop. For an autonomous goal, start a task run. When a manually driven loop needs server-side trajectory memory, reach for sessions instead.
The response is the standard prediction shape, covered in Response format.
Act → Observe → Verify
Use verification where state can branch or matter: after Save, navigation, modal transitions, irreversible operations, and before task completion. Execute the allowed action, wait for the UI to settle, capture a fresh screenshot, and compare that screenshot with one observable expected state. You do not need an expensive, verbose verification turn after every harmless deterministic keystroke such as a Tab inside a stable form.
Verify only whether the Group Note was saved.
Expected screen: the Group Note window is closed and the saved note is visible in the patient's Chart.
[done] when: both conditions are visible.
Otherwise return fail and describe the mismatch. Do not click or type.Keep verification prompts this narrow. Long verification prompts increase latency and model distraction by making the model reconsider the whole workflow. A verification turn is observational: if it proposes a click or key anyway, reject that action client-side and verify from the current screen again.
Sessions
Sessions, at a glance
Stateful prediction loops with memory
POST /v1/sessions/{id}/predict- 01Createrules once
- 02Advanceone micro-goal
- 03Verifyfresh observation
- 04Closedelete at boundary
Rules, version, and BYOK selection are fixed.
One session = one flow run
A session keeps the trajectory — the running history of screenshots and actions — on our side, so each step only needs the latest screenshot and instruction. This produces better multi-step behaviour on long tasks and keeps your request bodies small. Create a session once, step through the task, then delete it to release your concurrency quota.
Put short immutable rules in instructions when you create the session. That field appends to the tuned Coasty prompt; system_prompt replaces it, so use replacement only when you intentionally own the full agent prompt. Custom prompt fields are plan-gated. If your tier does not allow them, keep the built-in rules and send a short, self-contained micro-goal rather than trying to pass unsupported create fields.
Put invariant execution constraints in create-time action_policy, which is enforced by the server on every prediction in that session and is not plan-gated. Prompt rules still explain intent to the model; policy is the fail-closed boundary. The Escape example below uses both, plus a client-side check as defense in depth.
Recommended micro-goal prompt
Immutable rules (send once in session instructions):
- Never press Escape or close the Group Note window.
- Paste only with one Ctrl+V chord after focus is visible.
Micro-goal A (send now):
Goal: focus the Group Note body.
Expected screen: the caret or focus highlight is visible in the note body.
[done] when: note-body focus is visible.
Micro-goal B (send only after A is verified):
Goal: paste the clipboard once with one Ctrl+V chord.
Expected screen: the note body visibly contains the pasted text.
[done] when: the pasted text is visible in the note body.Every session-predict body still needs an instruction. Use one observable micro-goal per turn. While one micro-goal is unfinished, repeat only that short goal and its observable condition with the latest screenshot. After it is verified, send the subsequent micro-goal. Do not resend the whole workflow description or the immutable rules on every turn. Session-predict accepts only screenshot, instruction, and the two output flags; model, rules, tools, and action policy are fixed at session creation, and extra fields are rejected with 422 VALIDATION_ERROR.
import base64, email.utils, os, random, time, uuid, requests
from datetime import timezone
BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
MICRO_GOAL = "Open tomorrow at 3:00 PM. Expected screen: the day view shows 3:00 PM. [done] when that slot is visible."
VERIFY = "Verify only whether tomorrow's 3:00 PM slot is visible. Return done if visible; otherwise describe the mismatch. Do not click or type."
def screenshot() -> str:
# Replace this with a capture made NOW; never reuse a pre-action image.
with open("screen.png", "rb") as f:
return base64.b64encode(f.read()).decode()
def prohibited(action) -> bool:
params = action.get("params", {})
values = []
for field in ("key", "keys", "hold_keys", "modifiers"):
raw = params.get(field)
values.extend([raw] if isinstance(raw, str) else (raw or []))
aliases = {"esc": "escape", "control": "ctrl", "ctl": "ctrl", "command": "cmd", "super": "cmd", "win": "cmd", "windows": "cmd", "meta": "cmd", "option": "alt", "ctrl_l": "ctrl", "ctrl_r": "ctrl", "control_l": "ctrl", "control_r": "ctrl", "cmd_l": "cmd", "cmd_r": "cmd", "command_l": "cmd", "command_r": "cmd", "meta_l": "cmd", "meta_r": "cmd", "super_l": "cmd", "super_r": "cmd", "win_l": "cmd", "win_r": "cmd", "windows_l": "cmd", "windows_r": "cmd", "alt_l": "alt", "alt_r": "alt", "option_l": "alt", "option_r": "alt"}
keys = set()
for value in values:
for part in str(value).strip().lower().replace("-", "+").split("+"):
part = part.strip()
if part:
keys.add(aliases.get(part, part))
close_actions = {"close_window", "window_close", "browser_close", "browser_close_tab", "close_tab", "terminal_close"}
return (
"escape" in keys or action["action_type"] in close_actions
or {"alt", "f4"} <= keys
or (("ctrl" in keys or "cmd" in keys) and "w" in keys)
or {"cmd", "q"} <= keys
)
def policy_failure(result) -> bool:
return any(
a["action_type"] == "fail"
and a.get("params", {}).get("code") == "ACTION_POLICY_VIOLATION"
for a in result["actions"]
)
def parse_retry_after_seconds(raw):
if raw is None:
return None
try:
return max(0.0, float(raw))
except (TypeError, ValueError):
try:
value = email.utils.parsedate_to_datetime(str(raw))
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return max(0.0, value.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return None
def cleanup_retry_after_seconds(response):
try:
envelope = response.json()
if not isinstance(envelope, dict):
envelope = {}
error = envelope.get("error") or {}
if not isinstance(error, dict):
error = {}
except (ValueError, AttributeError, requests.RequestException):
error = {}
values = [
parse_retry_after_seconds(response.headers.get("Retry-After")),
parse_retry_after_seconds(error.get("retry_after")),
]
valid = [value for value in values if value is not None]
return max(valid) if valid else None
def close_session(session_id: str) -> None:
url = f"{BASE}/sessions/{session_id}"
deadline = time.monotonic() + 120
for attempt in range(3):
server_delay = None
try:
deleted = requests.delete(url, headers=HEADERS, timeout=15)
except requests.RequestException:
deleted = None
if deleted is not None:
if deleted.ok or deleted.status_code == 404:
return
if deleted.status_code not in (429, 502, 503, 504):
deleted.raise_for_status()
server_delay = cleanup_retry_after_seconds(deleted)
# A lost DELETE response is ambiguous. GET is authoritative.
try:
state = requests.get(url, headers=HEADERS, timeout=15)
if state.status_code == 404:
return
except requests.RequestException:
pass
if attempt < 2:
delay = max(server_delay or 0, random.uniform(0, min(4, 2 ** attempt)))
if delay > deadline - time.monotonic():
raise RuntimeError(f"cleanup retry exceeds budget; honor Retry-After and resume GET {url}")
time.sleep(delay)
raise RuntimeError(f"session cleanup unconfirmed; reconcile GET {url}")
# 1. Open a session — it remembers the trajectory across steps
session_payload = {
"screen_width": 1920,
"screen_height": 1080,
"action_policy": {
"blocked_keys": ["escape"],
"block_window_close": True,
"max_actions": 5,
},
}
create_key = f"session-create-{uuid.uuid4().hex}"
# If transport fails, retry only this same key + exact payload. Reconcile an
# ambiguous result at GET /v1/idempotency/{create_key} before using a new key.
created = requests.post(
f"{BASE}/sessions",
headers={**HEADERS, "Idempotency-Key": create_key},
json=session_payload,
timeout=60,
)
created.raise_for_status()
session = created.json()
session_id = session["session_id"]
# 2. Drive the task one step at a time
instruction = MICRO_GOAL
completed = False
rejections = 0
try:
for step in range(20): # safety cap
response = requests.post(
f"{BASE}/sessions/{session_id}/predict",
headers={**HEADERS, "Idempotency-Key": f"step-{session_id}-{step}-{uuid.uuid4().hex}"},
json={"screenshot": screenshot(), "instruction": instruction},
timeout=60,
)
response.raise_for_status()
res = response.json()
# Server policy replaces a prohibited batch with one fail action. The
# local scan is defense in depth. Execute none of that batch.
if policy_failure(res) or any(prohibited(a) for a in res["actions"]):
rejections += 1
if rejections >= 3:
raise RuntimeError("three prohibited proposals; human review required")
instruction = "CONTROL FEEDBACK: the previous prohibited action was rejected and NOT executed. The next screenshot is authoritative. Continue without Escape."
continue
rejections = 0
if res["status"] == "fail":
raise RuntimeError(res.get("reasoning", "agent failed"))
for action in res["actions"]:
if action["action_type"] not in ("done", "fail"):
perform(action) # your guarded action executor
time.sleep(0.5) # replace with a bounded DOM/app-state wait when available
if res["status"] == "done":
# done is a model claim: observe AFTER the actions, then verify.
verify_response = requests.post(
f"{BASE}/sessions/{session_id}/predict",
headers={**HEADERS, "Idempotency-Key": f"verify-{session_id}-{uuid.uuid4().hex}"},
json={"screenshot": screenshot(), "instruction": VERIFY},
timeout=60,
)
verify_response.raise_for_status()
verified = verify_response.json()
if verified["status"] != "done" or any(
a["action_type"] not in ("done", "fail") for a in verified["actions"]
):
raise RuntimeError("completion did not match the fresh screenshot")
completed = True
break
instruction = MICRO_GOAL
if not completed:
raise RuntimeError("step cap reached before verified completion")
finally:
# 3. Always release the session to free your concurrency quota
close_session(session_id)Session creation accepts max_trajectory_length from 1 through 20 (default 3). The current screenshot always occupies its own provider slot, so a prediction can include at most the newest 19 prior screenshots. The trajectory surcharge uses the exact number of prior screenshots actually sent after history compaction, not merely the configured ceiling.
Idempotency-Key, serialize the create body once, and keep both until the result is known. After a timeout or retryable edge failure, resend only that same key with the identical method, path, body, and BYOK identity. If the response is still ambiguous, reconcile GET /v1/idempotency/{key} before creating a new logical operation. Session delete has no generic replay record; after an ambiguous delete, GET /v1/sessions/{id} is authoritative: 404 means cleanup completed, while 200 means a bounded delete retry is still needed.finally block. Sessions count against your tier's concurrent-session limit. The 2-hour (7200-second) idle TTL is reset on each predict and reset; deleting the session releases the slot immediately. A returnedstatus: "done" does not close the session. Before a new patient, task, or run, delete and create a new session for the cleanest execution boundary. Reset is acceptable only for deliberate same-config reuse: it clears trajectory but preserves session configuration, BYOK binding, cumulative usage, and replay history. Use new Idempotency-Key values after reset.Grounding
Grounding, at a glance
Resolve a description to exact coordinates
POST /v1/ground- 01Screencurrent pixels
- 02Describeone target element
- 03Locateresolve x and y
- 04Checkvalidate before click
Use visible language, not guessed selectors.
Grounding answers a narrower question than predict: “where is this element?” Give it a screenshot and a description and it returns the exact x, y coordinate to target. It is faster and cheaper than a full prediction ($0.03 instead of the $0.05 Predict base price), which makes it ideal when you already know what to do and only need a pixel to click.
import os, requests
res = requests.post(
"https://coasty.ai/v1/ground",
headers={"X-API-Key": os.environ["COASTY_API_KEY"]},
json={
"screenshot": screenshot, # base64 PNG (see Predict)
"element": "the blue Submit button below the form",
},
timeout=60,
).json()
print(res["x"], res["y"]) # exact click coordinatesThe response is { x, y, usage, request_id }. Coordinates are in the same pixel space as the screenshot you sent.
Parse
Parse, at a glance
Turn pyautogui code into structured actions
POST /v1/parse- 01Codesupported pyautogui
- 02Parsestrict syntax tree
- 03Enforceaction policy
- 04Emitstructured actions
Only the documented subset is accepted.
Parse converts a block of pyautogui code into the same structured action objects the model returns. It is deterministic, runs no model, and is free. Use it to migrate existing automation scripts onto Coasty's executor, or to normalise hand-written steps into the canonical action schema. Add an optional action_policy beside code to validate those parsed actions before accepting them; a violation returns 422 ACTION_POLICY_VIOLATION.
import os, requests
res = requests.post(
"https://coasty.ai/v1/parse",
headers={"X-API-Key": os.environ["COASTY_API_KEY"]},
json={"code": "pyautogui.click(100, 200)\npyautogui.typewrite('hello')"},
timeout=30,
).json()
for action in res["actions"]:
print(action["action_type"], action["params"])Action types
Action types, at a glance
Every action the model can return
actions[] + action_policy- 01Generatetyped action batch
- 02Normalizekeys and coordinates
- 03Enforceallow, block, cap, bound
- 04Dispatchdriver receives safe batch
Actions use the published schema.
Every action the model can return uses an action_type from the table below, paired with a params object. Your executor switches on the type and applies the parameters. The terminal types — done and fail — set the response status and signal you to stop looping.
Server-enforced action policy
Prompt rules steer the model; action_policy enforces normalized structured output after the model responds and before an action is returned or dispatched. Enforcement is atomic: if one action in a proposed batch violates policy, none of that batch is admitted. Omit the field for the unrestricted compatibility default.
{
"action_policy": {
"blocked_keys": [
"escape"
],
"block_window_close": true,
"max_actions": 5
}
}Supply the policy on POST /v1/predict, session creation, /v1/parse, direct machine action/batch requests, Task Run creation, saved or ad-hoc Workflow Run creation, and Schedule creation. These surfaces preserve their create-time policy across turns, task steps, retries, recovery, and nested delegation. Constraints that require structured inspection reject raw/uninspectable code rather than guessing whether it is safe.
Action names are surface-specific. Inference and Parse policies use prediction action names such as type_text; direct machine action policies use machine command names such as type. Copy names from the action catalog for the endpoint you are calling.
status: "fail" with a fail action whose params include ACTION_POLICY_VIOLATION. Parse and direct machine actions return a 422 ACTION_POLICY_VIOLATION envelope. Every policy failure includes code, rule, and message. action_index and action_type appear only when the failure is attributable to one proposed action; batch-level rules such as max_actions omit them.ACTION_POLICY_VIOLATION through its error and event lifecycle. Creation succeeds because the rejected action is produced later during execution.ACTION_POLICY_VIOLATION before a generic terminal-failure branch. Execute nothing from that batch, capture a fresh screenshot, then either send short control feedback to the same session with a new Idempotency-Key or stop for human review. This is the recovery exception to ordinary status: failhandling.action_policy. A 2xx create response confirms acceptance, but your client must retain the exact submitted policy (or a hash) for audit and recovery. Keep the client-side guard active even when server policy is configured.Completion behavior
The [done] label is an optional prompt convention for making one completion condition easy to spot, for example [done] when: the saved note is visible. It is not a wire-level command, parser token, SSE marker, or guarantee. The API does not complete a step merely because that text appears in an instruction.
{
"status": "done",
"actions": [
{
"action_type": "done",
"params": {},
"description": "",
"raw_code": ""
}
],
"reasoning": "The saved note is visible in the current screenshot."
}response.status is the authoritative loop-control field. Stop the ordinary action loop when it is done or fail. A done action, when present, is a terminal signal with no keyboard or mouse effect; a valid done response is not required to contain that action. The model emits a completion claim when it believes either the observed screenshot already satisfies the condition or the proposed action batch will produce the requested result. That claim does not close a session and is not proof that your business operation persisted.
[done] tells the model what visible condition to look for. status: done tells your loop what the model claimed. A fresh screenshot plus your own application-state check decides whether the real task succeeded.Keyboard and clipboard semantics
All three operations are focus-sensitive. They go to the application and control that own OS focus when your executor runs them. A screenshot can show likely focus, but the prediction API cannot inspect the clipboard or prove focus. For POST /v1/machines/{id}/actions, map prediction type_text to machine command type;key_press and key_combo keep their names. Prediction done and fail are client terminal signals, not machine commands.
Use lowercase canonical key names on the wire: ctrl, alt, shift, cmd, escape, and enter. The server policy normalizes esc, return, control/ctl, command/super/win/windows, and option. meta is not a current server alias: emit cmd and normalize meta to cmd in your client-side guard for executor portability. Also normalize side-specific executor names such as ctrl_l/ctrl_r, command_l/command_r, and option_l/option_r before applying a client policy. Send canonical wire names whenever you create an API action.
Troubleshooting an Escape loop
Prompt prohibitions are advisory, not enforcement. A model or recovery strategy can still propose Escape to dismiss a suspected modal or clear focus. Set session create-time action_policy with blocked_keys: ["escape"] and block_window_close: true for fail-closed server enforcement. Also inspect every structured action in your executor, reject prohibited keys case-insensitively, and never evaluate raw_code; this client check protects older deployments and actions from another source. The tools allowlist can disable the wholekey_press class, but it cannot ban only Escape while retaining Tab and Enter.
// 1. Create the session with action_policy.blocked_keys=["escape"] and
// block_window_close=true. Still validate every returned action locally.
// 2. If key_press/key_combo contains "escape" or "esc", DO NOT EXECUTE it.
// 3. Capture a fresh screenshot, then send this to the SAME session:
{
"screenshot": freshScreenshotBase64,
"instruction": "CONTROL FEEDBACK: the previous key_press([escape]) was rejected and NOT executed. The current screenshot is authoritative. Continue without Escape. Use one key_combo([ctrl,v]) only after focus is visibly in the note body."
}
// 4. The body changed, so use a NEW Idempotency-Key. Reuse the old key only
// for an identical transport retry of the original request.If the same policy violation is proposed three times, stop the paid loop and surface the current screenshot for human review or reset the session. Repeatedly sending an unchanged screen without explicit rejection feedback can reinforce the same recovery plan.
Response format
Response format, at a glance
The shape of every prediction response
status + actions + usage + request_id- 01Statuscontinue, done, or fail
- 02Actionstyped parameters
- 03Usagecost and model metadata
- 04Branchapply, verify, or stop
The client checks object and request ID.
Predict and session-predict share the core action, status, reasoning, raw-code, and usage shape. Predict additionally echoes screen_width and screen_height; session-predict instead returns session_id and step. actions is the ordered list to execute; status tells you whether to keep going (continue), stop successfully (done), or stop because the task is impossible (fail). usage reports tokens and the dollar cost of the call (cost_cents).
Billed success responses also carry two headers you can read without parsing the body: X-Credits-Charged (what this call cost) and X-Credits-Remaining (your wallet balance after it). In the body, the same numbers appear as usage.credits_charged and usage.cost_cents. On an sk-coasty-test- key both are always 0. Every response produced by the Coasty application carries X-Request-Id for the current HTTP attempt. X-Coasty-Request-Id and the body request_id normally match it, but an exact replay can preserve the original operation id in one or both. Log every distinct value. A CDN/network failure can instead return non-JSON without Coasty headers.
On a failed billed request, X-Credits-Refunded is the authoritative confirmation that credits were returned, and X-Credits-Charged is then0. If settlement cannot be confirmed, the API returns503 BILLING_UNAVAILABLE without the refund header. Follow the error'sretry_with_same_idempotency_key field rather than retrying by status alone.
{
"request_id": "req_8f2c1e9a",
"status": "continue",
"reasoning": "The login form is visible. I'll click the email field, then type the address.",
"actions": [
{
"action_type": "click",
"params": {
"x": 512,
"y": 340,
"button": "left",
"clicks": 1
},
"description": "",
"raw_code": "pyautogui.click(512, 340, clicks=1, button='left')"
},
{
"action_type": "type_text",
"params": {
"text": "[email protected]"
},
"description": "",
"raw_code": "pyautogui.write('[email protected]')"
}
],
"raw_code": [
"pyautogui.click(512, 340, clicks=1, button='left')",
"pyautogui.write('[email protected]')"
],
"cua_version": "v5",
"screen_width": 1920,
"screen_height": 1080,
"usage": {
"input_tokens": 1523,
"output_tokens": 245,
"credits_charged": 6,
"cost_cents": 6,
"breakdown": [
{
"item": "base",
"credits": 5
},
{
"item": "hd_images",
"credits": 1,
"count": 1
}
]
}
}Errors
Errors, at a glance
Error envelope and HTTP status codes
error.code + retryable + request_id- 01Classifystatus + error code
- 02Inspectretry metadata
- 03Reconcilecheck authoritative state
- 04Recoversafe retry or surface
Canonical JSON or edge transport error.
Errors produced by the Coasty application return a non-2xx status and a JSON envelope under an error key. The code is stable and safe to branch on; message is human-readable and may change. Every canonical error also carries error.request_id, error.suggestion, and error.docs_url for self-service. The X-Request-Id header identifies the current attempt. X-Coasty-Request-Id can preserve the original operation id on an exact replay, as can the replayed body. A CDN or network intermediary can fail before this envelope exists and return HTML/non-JSON instead. A Link: <url>; rel="help" header mirrors docs_url. Always log the available request ids: they are the fastest way for us to trace a failed call.
Some codes attach machine-readable context to the body. A 402 (INSUFFICIENT_CREDITS) reports required and balance; a 403 reports required_scope and current_scopes; a 422 VALIDATION_ERROR lists the offending field path under error.details; and a 409 state conflict carries current_state with allowed_from or required_state.
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Your API wallet does not have enough funds to complete this request.",
"type": "billing_error",
"suggestion": "Add funds in the dashboard, or use an sk-coasty-test- key while building (test keys never bill).",
"docs_url": "https://coasty.ai/docs#errors",
"required": 5,
"balance": 2,
"request_id": "req_8f2c1e9a",
"retryable": false,
"retry_with_same_idempotency_key": false
}
}error.retryable, not the HTTP status alone. Some503 outcomes are deliberately terminal, includingSNAPSHOT_OUTCOME_UNKNOWN. A 500 model failure is refunded only when X-Credits-Refunded is present; after a confirmed refund, a deliberate new execution uses a new idempotency key.Official 503/504 retry policy
For a canonical JSON error, first read retryable,retry_with_same_idempotency_key, retry_after, and code. Parse Retry-After as either delta seconds or an IMF-fixdate HTTP-date. Wait at least the largest valid server delay. If no delay is present, use full jitter: a random delay from zero tomin(30s, 1s × 2^retryIndex).
The recommended automatic client budget is four total attempts (the initial send plus three retries) and a two-minute elapsed budget after the first failure, whichever ends first. This is a client safety default, not a server guarantee. Never violate a largerRetry-After; if it does not fit the foreground budget, stop and requeue the operation for later.
For state reconciliation, use GET /v1/runs/{id},GET /v1/workflows/runs/{id},GET /v1/machines/{id}, orGET /v1/sessions/{id}. For a generic keyed operation whose response was lost, GET /v1/idempotency/{key} can return the stored result or report that it is still processing. A missing record does not prove that an unkeyed or unsupported mutation never ran. Lifecycle actions such as run resume/cancel, workflow update/delete/cancel/resume, machine start/stop/restart/update/delete, and session reset/delete require a resource-state check before any resend.
request_id and Idempotency-Key are different. The client chooses and reuses the idempotency key for one operation; each HTTP attempt gets a current X-Request-Id. Log it, plus any distinct X-Coasty-Request-Id header or body request_id retained from the original operation by an exact replay. If an edge response has none, log UTC time, method, path, andCF-Ray when present. Never copy a request id into the Idempotency-Key header.
Troubleshooting
Five mistakes account for almost every first-week support ticket. Each maps to one status and one fix:
Pricing
Pricing, at a glance
What each endpoint costs in USD
GET /v1/models + /machines/pricing- 01Discovereffective rate card
- 02Estimatebase + surcharges
- 03Reservefunds before work
- 04Settlecharge or refund header
The deployed rates become authoritative.
Requests are billed in US dollars from your prepaid API wallet. The charge is taken before the model runs. On a conclusive server-side failure, the API submits a refund; treat it as complete only when X-Credits-Refunded is present. Ambiguous provider mutations may retain the debit until reconciliation. Internally each request unit is $0.01 (the granularity behind every price below), but everything you pay and see is dollars. The table shows published base prices and defaults; test keys (sk-coasty-test-) always bill $0.00. The effective deployed CUA and task-step rate card is returned in the pricing object from GET /v1/models, so deployment probes can detect an operator override before sending billable work. Effective machine runtime prices come from GET /v1/machines/pricing.
Surcharges
Predict and session-predict can incur all four fixed surcharges below. Ground can incur only the current-image HD fee, and session create has none. Each is an exact USD amount:
Machines
Managed-machine runtime is metered per minute and rounded down: $0.05/hr for a running Linux machine, $0.09/hr for a running Windows machine, and $0.01/hr while stopped or suspended. The starting, stopping, and restarting transitions bill at the running rate; the creating, error, and terminated states bill nothing, and TTL auto-destroy is free. Snapshots are a one-time $0.01 each, and every per-call operation (actions, batch, browser, terminal, files, screenshot, connection) is free. Provisioning requires a $0.20 wallet minimum, which is a gate, not a charge. If the wallet empties mid-flight the machine is automatically stopped, never destroyed, and resumes after you top up. The live rate card is always at GET /v1/machines/pricing.
External-machine enrollment and its heartbeat, observation, command-poll, and result transport are free and do not incur a VM runtime rate. Managed Task and Workflow inference keeps normal per-step pricing. Live-key BYOK records provider tokens and charges zero Coasty credits; test-key Task and Workflow execution remains deterministic sandbox and makes no provider call.
Schedule billing
Schedules have no per-fire fee. Managed schedules use the published API-wallet gate; BYOK schedules bypass create, run-now, and webhook wallet gates. For managed non-Unlimited accounts, execution is billed differently from everything else on this page: the published default charges scheduled agent runtime to your subscription credit balance at 10 consumer subscription credits per minute, with 20 credits required to start and a 6-hour timeout, not to this USD API wallet. Consumer subscription credits are quota units and do not inherit the Developer API wallet's fixed one-cent conversion. Unlimited bypasses the managed consumer-credit meter but retains managed API-wallet eligibility. BYOK opens no Coasty billing session and has no API-wallet eligibility gate. It reports credits_charged: 0; concurrency, persistent-machine, schedule, token, and abuse safeguards still apply. Live-key schedule execution can call and bill the selected provider. Managed-mode test-key schedule execution stays deterministic sandbox and makes no provider call; BYOK headers or provider metadata return 422 LLM_PROVIDER_UNSUPPORTED before execution without reading a stored provider key. Operator-effective gates, rates, timeouts, replay/dedup windows, body cap, and default webhook rate limit are exposed by GET /v1/models under pricing.schedules; use them in budget and signing guards. In schedule run-history records, credits_charged means consumer subscription-credit quota units, not API-wallet cents; it is zero for test, Unlimited-bypass, BYOK, and other non-billable runs and has no fixed USD conversion. Non-Unlimited managed schedules should keep both balances funded.
Cookbook
Cookbook, at a glance
Open-source example repos to clone and ship
clone → configure → run → adapt- 01Cloneofficial example
- 02Configurescoped environment
- 03Runknown-good flow
- 04Adaptyour product boundary
Dependencies and sample assets are local.
Curated open-source repositories for building on the Coasty API. Clone a repo, mint a key, and ship.
Computer Use Cookbook
Runnable, copy-paste examples for every part of the Coasty API: predict, sessions, task runs, workflows, and driving machines. Start here.
Open Cowork
The open-source Coasty project for computer-use agents that work alongside you. Build on it, fork it, or contribute.