# Coasty Computer Use API > The single, complete reference for the Coasty Computer Use (CUA) API. Coasty > turns a screenshot plus an instruction into structured GUI actions, drives > autonomous task runs on a machine, and composes those runs into versioned > workflows. This document covers every endpoint, request field, response shape, > error, rate limit, scope, and price. - Base URL: `https://coasty.ai/v1` - Auth header: `X-API-Key: ` (or `Authorization: Bearer `) - Human docs: https://coasty.ai/docs - Manage keys: https://coasty.ai/developers/keys - Site-wide LLM doc: https://coasty.ai/llms-full.txt --- ## 1. Overview Coasty exposes three layers, smallest to largest: 1. **Core inference** (`/v1/predict`, `/v1/sessions`, `/v1/ground`, `/v1/ocr`, `/v1/parse`). You supply screenshots and instructions; Coasty returns the actions to take. You execute the actions on your own machine and loop. 2. **Task Runs** (`/v1/runs`). You give the agent a task plus a `machine_id` and Coasty drives the whole loop server-side (autonomous, pass/fail verified, optional human takeover, per-step billing, streaming events, webhooks). 3. **Workflows** (`/v1/workflows`). A versioned JSON DSL that composes many runs with branching, loops, parallelism, asserts, retries, and human approvals. How a single inference turn works: send a base64 screenshot and a natural language instruction; the model returns an ordered list of `actions` (clicks, typing, key presses, scrolls, etc.) and a `status` of `continue`, `done`, or `fail`. With `/v1/predict` you manage trajectory yourself; with `/v1/sessions` the server remembers the trajectory across steps. ### Base URL All endpoints live under: ``` https://coasty.ai/v1 ``` ### Authentication Send your API key in either header (both are accepted on every `/v1` endpoint): ``` X-API-Key: sk-coasty-live-<48 hex> Authorization: Bearer sk-coasty-live-<48 hex> ``` Read the key from the `COASTY_API_KEY` environment variable rather than hard coding it. A missing or malformed key returns `401 INVALID_API_KEY` (which also sends a `WWW-Authenticate: Bearer` challenge header). ### Request ids and response headers Every response (success or error) carries an `X-Coasty-Request-Id` header, and every error body repeats it as `error.request_id`. Quote that id verbatim when you contact support; it ties together the whole request end-to-end. Billed responses also return two headers so you can track spend without a second call: - `X-Credits-Charged`: what this request cost (internal units; `0` on test keys). - `X-Credits-Remaining`: your wallet balance after the charge (USD cents). Other useful headers: `X-Coasty-Key-Kind` (`live` / `test` / `legacy`), `X-Coasty-Test-Mode: true` (test keys only), and `X-Coasty-Idempotent-Replay: true` when a response was served from the idempotency cache. ### Key management - Create, list, and revoke keys at https://coasty.ai/developers/keys, or via the API: `POST /v1/keys`, `GET /v1/keys`, `DELETE /v1/keys/{key_id}`. - The raw key is shown exactly **once** at creation; store it securely. - Per-account cap: 20 active keys. - Each key carries a set of scopes (see Reference). New keys are granted a conservative default set that already includes `runs:*` and `workflows:*`. ### Test vs live keys | Prefix | Kind | Bills your wallet? | Rate limits | | --- | --- | --- | --- | | `sk-coasty-live-<48 hex>` | live | Yes | Your subscription tier | | `sk-coasty-test-<48 hex>` | test (sandbox) | No (free) | Same as your tier | | `cua_sk_<48 hex>` | legacy | Yes | Your tier (accepted through 2026-11-01) | Test keys (`sk-coasty-test-*`) run the same validation and logic as live keys but **never debit your wallet**. They still consume rate-limit budget, so a runaway test loop cannot starve production traffic. Responses from test keys carry `X-Coasty-Test-Mode: true` and `X-Credits-Charged: 0`. The `X-Coasty-Key-Kind` response header reports which family authenticated (`live`, `test`, or `legacy`). ### Billing model (USD) Your developer wallet is a **prepaid USD balance** (denominated in cents). Internally costs are computed at a granularity of $0.01 per unit; everywhere in this document costs are shown in dollars. Charges are taken before the model call and automatically refunded if the call fails. See the Pricing table in the Reference section for exact per-endpoint dollar costs. --- ## 2. Quickstart ### Step 1: get a key Create a key at https://coasty.ai/developers/keys and export it: ```bash export COASTY_API_KEY="sk-coasty-live-..." ``` ### Step 2: your first prediction ```bash # screen.png is a screenshot of the screen you want to control SCREENSHOT=$(base64 < screen.png | tr -d '\n') curl -s https://coasty.ai/v1/predict \ -H "X-API-Key: $COASTY_API_KEY" \ -H "Content-Type: application/json" \ -d @- < 100 chars. | | `instruction` | string | yes | - | Natural language task. Must be non-empty. | | `cua_version` | string | no | `v3` | `v1` / `v3` / `v4`. | | `system_prompt` | string\|null | no | null | REPLACES the base prompt. | | `instructions` | string\|null | no | null | APPENDED to the base prompt. | | `screen_width` | int | no | 1920 | 320-3840. | | `screen_height` | int | no | 1080 | 240-2160. | | `trajectory` | array | no | [] | Prior steps for context: `[{screenshot, actions, reasoning}]`. | | `max_actions` | int | no | 5 | 1-10. Capped to your tier max. | | `tools` | string[]\|null | no | null | Allowed action types (null = all). | | `include_reasoning` | bool | no | true | Include the agent's reasoning. | | `include_raw_code` | bool | no | true | Include raw pyautogui code. | **Response** (`PredictResponse`) ```json { "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 }, "description": "Click the email field" }, { "action_type": "type_text", "params": { "text": "you@example.com" }, "description": "Type the email address" } ], "raw_code": ["pyautogui.click(512, 340)", "pyautogui.typewrite('you@example.com')"], "usage": { "input_tokens": 1523, "output_tokens": 245, "credits_charged": 5, "cost_cents": 45 } } ``` `status` is one of `continue`, `done`, `fail`. Each action object is `{ action_type, params, description, raw_code }`. `usage.cost_cents` is the USD cost in cents. **curl** ```bash SCREENSHOT=$(base64 < screen.png | tr -d '\n') curl -s https://coasty.ai/v1/predict \ -H "X-API-Key: $COASTY_API_KEY" \ -H "Content-Type: application/json" \ -d @- <` makes a retried create safe. Reusing a key with a different body returns `422 IDEMPOTENCY_KEY_REUSED`. **The Run object** (`RunResponse`, returned by create / get / list) | Field | Type | Notes | | --- | --- | --- | | `id` | string | Run id. | | `object` | string | Always `"agent.run"`. | | `status` | string | `queued` / `running` / `awaiting_human` / `succeeded` / `failed` / `cancelled` / `timed_out`. | | `machine_id` | string | The machine the agent is driving. | | `task` | string | The goal you submitted. | | `cua_version` | string | `v3` (default) or `v4`. | | `instructions` | string\|null | Extra guidance appended to the base prompt. | | `max_steps` | int | Hard cap on agent steps. | | `on_awaiting_human` | string | `pause` / `fail` / `cancel`. | | `steps_completed` | int | Steps run so far. | | `credits_charged` | int | Internal cost units (1 unit = $0.01). See `cost_cents` for dollars. | | `cost_cents` | int | Dollar cost so far, in cents. | | `result` | object\|null | `{ passed, status, summary, verdict? }` once finished. | | `error` | object\|null | `{ code, message }` when failed. | | `awaiting_human_reason` | string\|null | Why the run paused. | | `metadata` | object\|null | The metadata you attached. | | `webhook_url` | string\|null | Where lifecycle events are POSTed. | | `webhook_secret` | string\|null | Per-run HMAC signing secret. Returned ONCE on create, null on get/list. | | `created_at` | string\|null | ISO-8601. | | `started_at` | string\|null | When the run left the queue. | | `awaiting_human_since` | string\|null | When it last paused for a human. | | `finished_at` | string\|null | When it reached a terminal state. | | `request_id` | string\|null | Id of the create request. | **Example response** (a freshly created run): ```json { "id": "run_7a1b2c3d", "object": "agent.run", "status": "queued", "machine_id": "m_9f2c", "task": "Open the billing page and download the latest invoice as PDF", "cua_version": "v3", "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" } ``` **curl** ```bash BASE=https://coasty.ai/v1 AUTH="X-API-Key: $COASTY_API_KEY" # 1. Start a run. It returns status "queued" and a one-time webhook_secret. RUN_ID=$(curl -s "$BASE/runs" -H "$AUTH" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4821" \ -d '{ "machine_id": "m_9f2c", "task": "Open the billing page and download the latest invoice as PDF", "cua_version": "v3", "max_steps": 40, "on_awaiting_human": "pause" }' | python -c "import sys,json;print(json.load(sys.stdin)['id'])") # 2. Poll the run until it reaches a terminal state. while :; do RUN=$(curl -s "$BASE/runs/$RUN_ID" -H "$AUTH") STATUS=$(echo "$RUN" | python -c "import sys,json;print(json.load(sys.stdin)['status'])") echo "status=$STATUS" case "$STATUS" in succeeded|failed|cancelled|timed_out) break ;; esac sleep 2 done ``` **Python** ```python import os, time, requests BASE = "https://coasty.ai/v1" HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]} TERMINAL = {"succeeded", "failed", "cancelled", "timed_out"} run = requests.post( f"{BASE}/runs", headers={**HEADERS, "Idempotency-Key": "order-4821"}, json={ "machine_id": "m_9f2c", "task": "Open the billing page and download the latest invoice as PDF", "cua_version": "v3", # "v4" needs professional tier or above "max_steps": 40, "on_awaiting_human": "pause", }, timeout=30, ).json() run_id = run["id"] webhook_secret = run.get("webhook_secret") # shown once; store it now while run["status"] not in TERMINAL: time.sleep(2) run = requests.get(f"{BASE}/runs/{run_id}", headers=HEADERS, timeout=30).json() print(run["status"], run["steps_completed"], "steps") print(run["result"]) ``` ### GET /v1/runs List your runs. Query params: `status` (filter), `limit` (default 20). Returns `{ object: "list", data: [Run...], has_more, request_id }`. ### GET /v1/runs/{id} Get one run by id. Returns a `RunResponse`. ### POST /v1/runs/{id}/cancel Cancel an active run. Returns the run with `status: "cancelled"`. ### POST /v1/runs/{id}/resume Hand control back to the agent after a human takeover. Only valid while `status == "awaiting_human"`. Body: `{ "note": "" }`. After resume the run returns to `running`. ```python import os, requests BASE = "https://coasty.ai/v1" HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]} run_id = "run_7a1b" run = requests.get(f"{BASE}/runs/{run_id}", headers=HEADERS, timeout=30).json() 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" ``` ### GET /v1/runs/{id}/events (SSE) Server-Sent Events stream of the run timeline. Reconnect-safe: pass `Last-Event-ID: ` (or `?after=`) to replay everything after that sequence. Events are durable in Postgres, so a dropped connection never loses or double-emits an event (the `seq` is the cursor). Each event has `{ seq, type, data, created_at }` and is emitted as: ``` id: 42 event: status data: {"status":"running"} ``` **Run event types** | Type | Meaning | | --- | --- | | `status` | The run moved to a new status. | | `text` | A chunk of the agent's narration. | | `reasoning` | A chunk of the model's reasoning, if exposed. | | `tool_call` | The agent invoked a tool (click, keypress, navigation). | | `tool_result` | The result of the most recent tool call. | | `awaiting_human` | The run paused and is waiting for a human. | | `resumed` | Control was handed back after a takeover. | | `step` | A full agent step completed; carries `steps_completed`. | | `billing` | Incremental billing update (`credits_charged`, `cost_cents`). | | `error` | A non-fatal or fatal error occurred. | | `done` | Terminal event. The stream closes after this. | ```bash # -N disables buffering so events arrive as they happen. # Pass Last-Event-ID (the last seq you saw) to replay after a drop. curl -N "https://coasty.ai/v1/runs/$RUN_ID/events" \ -H "X-API-Key: $COASTY_API_KEY" \ -H "Last-Event-ID: 42" ``` ### Run state machine ``` queued -> running -> (awaiting_human <-> running) -> succeeded | failed | cancelled | timed_out ``` Terminal states (`succeeded`, `failed`, `cancelled`, `timed_out`) are immutable. `awaiting_human` is only reached when `on_awaiting_human == "pause"`; with `fail` or `cancel` the run goes straight to the corresponding terminal state. ### Webhooks Pass a `webhook_url` (https only) when you create a run. Coasty POSTs a JSON payload on lifecycle transitions. The create response returns `webhook_secret` exactly once: store it, because every callback is signed with it. **Webhook events** | Event | Meaning | | --- | --- | | `run.awaiting_human` | The run paused and needs a human to take over. | | `run.succeeded` | The run finished and verification passed. | | `run.failed` | The run ended in failure (verification failed or an error). | | `run.cancelled` | The run was cancelled via the cancel endpoint. | | `run.timed_out` | The run breached its deadline before finishing. | **Signature.** Each callback carries a header: ``` Coasty-Signature: t=,v1= ``` The signed payload is `"." + raw_request_body`. Compute `HMAC-SHA256(webhook_secret, signed_payload)` as hex and compare to `v1` with a constant-time compare. Reject if it does not match or `t` is too old. ```python 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. webhook_secret is returned exactly once. run = requests.post( f"{BASE}/runs", headers=HEADERS, json={ "machine_id": "m_9f2c", "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) ``` --- ## 5. Workflows A workflow is a versioned JSON DSL composing many runs with branching, loops, parallelism, asserts, retries, and human approvals. Scopes: `workflows:read` and `workflows:write` (granted to new keys by default). DSL version: `2026-06-01`. The `definition` is validated structurally on create and on ad-hoc start. When a run begins, the definition is **snapshotted** into the run, so editing a workflow never changes an in-flight run (version pinning). Updating a saved workflow bumps its `version`. ### Workflow endpoints | Method + path | Scope | Description | | --- | --- | --- | | `POST /v1/workflows` | `workflows:write` | Create a workflow. | | `GET /v1/workflows` | `workflows:read` | List workflows (`limit`, default 20). | | `GET /v1/workflows/{id}` | `workflows:read` | Get a workflow. | | `PUT /v1/workflows/{id}` | `workflows:write` | Update (bumps version). | | `DELETE /v1/workflows/{id}` | `workflows:write` | Archive a workflow. | | `POST /v1/workflows/{id}/runs` | `workflows:write` | Start a run of a saved workflow. | | `POST /v1/workflows/runs` | `workflows:write` | Start an ad-hoc run (inline `definition`). | | `GET /v1/workflows/runs` | `workflows:read` | List workflow runs (`workflow_id`, `limit`). | | `GET /v1/workflows/runs/{id}` | `workflows:read` | Get a workflow run. | | `GET /v1/workflows/runs/{id}/events` | `workflows:read` | SSE stream (Last-Event-ID replay). | | `POST /v1/workflows/runs/{id}/cancel` | `workflows:write` | Cancel a workflow run. | | `POST /v1/workflows/runs/{id}/resume` | `workflows:write` | Approve/reject a paused step. | Note: the static `/runs` subtree is declared before the dynamic `/{workflow_id}` routes, so `runs` is never captured as a workflow id. ### POST /v1/workflows (create) **Request body** | Field | Type | Req | Notes | | --- | --- | --- | --- | | `name` | string | yes | 1-128 chars. | | `slug` | string | yes | Stable per-account handle, matches `^[a-z0-9][a-z0-9_-]{0,62}$`. | | `definition` | object | yes | The workflow DSL (validated server-side). | | `inputs_schema` | object\|null | no | Typed input declarations: `{name: {type, required?, default?}}`. | | `description` | string\|null | no | Up to 2000 chars. | | `metadata` | object\|null | no | Opaque. | **Response** (`WorkflowResponse`): `{ id, object: "workflow", name, slug, version, dsl_version, definition, inputs_schema, description, status, metadata, created_at, updated_at, request_id }`. `PUT /v1/workflows/{id}` accepts optional `name`, `definition`, `inputs_schema`, `description`, `status` (`active` | `archived`), and `metadata`. ### Workflow DSL spec The `definition` holds a `steps` array (and optional top-level `output`). Each step is `{ id, type, ... }` where `id` matches `^[A-Za-z0-9_-]{1,64}$`. **Step types (9)** | Type | Shape | Description | | --- | --- | --- | | `task` | `{ id, type, task, machine_id?, cua_version?, instructions?, system_prompt?, max_steps?, save_as?, on_awaiting_human? }` | Run an agent task. Supports `{{var}}` templating. Binds its result under `save_as` and under the step id. | | `assert` | `{ id, type, condition, message? }` | Fail the workflow unless the structured condition holds. | | `if` | `{ id, type, condition, then: [...], else?: [...] }` | Branch on a structured condition. | | `loop` | `{ id, type, (count: int \| while: condition), body: [...], max_iterations? }` | Repeat a body a fixed number of times or while a condition holds. | | `parallel` | `{ id, type, branches: [[...], [...]] }` | Run independent branches concurrently. | | `human_approval` | `{ id, type, message?, timeout_seconds? }` | Pause for a human to approve or reject before continuing. | | `retry` | `{ id, type, body: [...], max_attempts: int }` | Retry a body up to `max_attempts` times on failure. | | `succeed` | `{ id, type, output?: {} }` | Finish the workflow successfully with an optional output. | | `fail` | `{ id, type, message? }` | Finish the workflow as failed with an optional message. | **Structured conditions (13 ops, injection-safe, no free-text eval)** | Op | Shape | Meaning | | --- | --- | --- | | `eq` / `ne` | `{ op, left, right }` | Equal / not equal. | | `lt` / `gt` / `lte` / `gte` | `{ op, left, right }` | Ordered numeric comparison. | | `contains` | `{ op, left, right }` | `left` contains `right` (substring or membership). | | `truthy` / `falsy` / `exists` | `{ op, value }` | Test a single value for truthiness, falsiness, or presence. | | `and` / `or` | `{ op, conditions: [...] }` | Combine several conditions. | | `not` | `{ op, condition }` | Negate a condition. | **Variable references** `{{path}}` resolve dotted paths into `{inputs, vars, }`: - `{{inputs.x}}` reads a bound input value. - `{{vars.y}}` reads a workflow variable. - `{{stepId.field}}` reads a prior task's result. A `task` binds: `{ status, passed, result, run_id, steps, error }`. For a step saved as `invoice` you can reference `{{invoice.passed}}` or `{{invoice.result}}`. **Hard guards** (set when starting a run, enforced during execution) - `budget_cents`: total spend cap across all task steps (0 or null = unlimited). - `max_iterations`: cap on total loop iterations consumed. - `deadline_seconds`: wall-clock budget for the whole workflow run. **Validation limits** (enforced at create / ad-hoc time) | Limit | Rule | | --- | --- | | Max steps | At most 200 steps total (counting every nested step). | | Max nesting depth | Steps nest at most 8 levels deep (if/loop/parallel/retry bodies). | | Parallel branches | A `parallel` step takes at most 16 branches; they run concurrently. | | Retry attempts | `retry.max_attempts` is an integer from 1 to 20. | | Parallel contents | `human_approval`, `succeed`, and `fail` are not allowed inside a parallel branch. | | save_as name | `save_as` must not be `"inputs"` or `"vars"` (reserved namespaces). | **Complete example DSL** (task -> assert -> if/branch): ```json { "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}}" } } ``` ### Starting a workflow run `POST /v1/workflows/{id}/runs` (saved) or `POST /v1/workflows/runs` (ad-hoc with an inline `definition`). **Request body** (`StartWorkflowRunRequest`) | Field | Type | Req | Notes | | --- | --- | --- | --- | | `inputs` | object\|null | no | Bound input values, available as `{{inputs.*}}`. | | `machine_id` | string\|null | no | Default machine for task steps that omit their own. | | `budget_cents` | int\|null | no | Spend cap, 0-10000000 (0/null = unlimited). | | `max_iterations` | int\|null | no | 1-100000. | | `deadline_seconds` | int\|null | no | 1-86400. | | `webhook_url` | string\|null | no | Lifecycle callbacks. | | `metadata` | object\|null | no | Opaque. | | `definition` | object\|null | no | For ad-hoc runs without a saved workflow. | | `inputs_schema` | object\|null | no | For ad-hoc runs. | Supports the `Idempotency-Key` header (same semantics as runs). **The Workflow Run object** (`WorkflowRunResponse`) | Field | Type | Notes | | --- | --- | --- | | `id` | string | Workflow-run id. | | `object` | string | Always `"workflow.run"`. | | `status` | string | `queued` / `running` / `awaiting_human` / `succeeded` / `failed` / `cancelled` / `timed_out`. | | `workflow_id` | string\|null | The workflow this run belongs to (null for inline runs). | | `workflow_version` | int\|null | Version of the definition that ran. | | `machine_id` | string\|null | Default machine for task steps. | | `inputs` | object | The inputs you passed in. | | `output` | object\|null | Produced by a `succeed` step. | | `error` | object\|null | `{ code, message }` when failed. | | `awaiting_human_reason` | string\|null | Why the run paused. | | `awaiting_step_id` | string\|null | The step id awaiting approval. | | `iterations_used` | int | Loop iterations consumed. | | `spent_cents` | int | Total spend so far (USD cents). | | `budget_cents` | int | Spend cap (0 = unlimited). | | `webhook_url` | string\|null | Lifecycle callbacks. | | `webhook_secret` | string\|null | Returned once on create. | | `metadata` | object\|null | Opaque. | | `created_at` / `started_at` / `finished_at` | string\|null | Timestamps. | | `request_id` | string\|null | Id of the create request. | `resume` body (`POST /v1/workflows/runs/{id}/resume`): `{ "approved": true, "note": "" }`. `approved: false` rejects (fails) the pending `human_approval` step. **Example workflow run response:** ```json { "id": "wfr_5e6f7a8b", "object": "workflow.run", "status": "running", "workflow_id": "wf_1a2b3c", "workflow_version": 3, "machine_id": "m_9f2c", "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" } ``` **curl: create a workflow, then start a run** ```bash BASE=https://coasty.ai/v1 AUTH="X-API-Key: $COASTY_API_KEY" # 1. Create a workflow: a task step, an assert, then an if/branch. WF_ID=$(curl -s "$BASE/workflows" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoice reconciliation", "slug": "invoice-reconcile", "inputs_schema": {"type": "object", "properties": {"order_id": {"type": "string"}}}, "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"}]} ] } }' | python -c "import sys,json;print(json.load(sys.stdin)['id'])") # 2. Start a run of the saved workflow. curl -s "$BASE/workflows/$WF_ID/runs" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"inputs": {"order_id": "ord_4821"}, "machine_id": "m_9f2c", "budget_cents": 500}' ``` **Python** ```python import os, requests BASE = "https://coasty.ai/v1" HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]} definition = { "steps": [ {"id": "fetch", "type": "task", "save_as": "invoice", "task": "Open order {{inputs.order_id}} and read the invoice total"}, {"id": "check", "type": "assert", "condition": {"op": "truthy", "value": "{{invoice.passed}}"}, "message": "Agent failed to read the invoice"}, {"id": "branch", "type": "if", "condition": {"op": "contains", "left": "{{invoice.result}}", "right": "PAID"}, "then": [{"id": "ok", "type": "succeed", "output": {"state": "paid"}}], "else": [{"id": "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": "m_9f2c", "budget_cents": 500}, timeout=30, ).json() print(run["id"], run["status"]) ``` Workflow run events stream the same way as run events (`GET /v1/workflows/runs/{id}/events`, Last-Event-ID replay). --- ## 6. Reference ### Action types Every action type the model can return in `actions`: | Type | Params | Description | | --- | --- | --- | | `click` | `{ x, y }` | Single left click at the pixel coordinate. | | `type_text` | `{ text }` | Type a literal string at the current focus. | | `key_press` | `{ key }` | Press one key, e.g. `"enter"`, `"tab"`, `"escape"`. | | `key_combo` | `{ keys: [..] }` | Press a chord, e.g. `["ctrl", "c"]` or `["cmd", "v"]`. | | `scroll` | `{ x, y, direction, amount }` | Scroll up/down/left/right at a position. | | `drag` | `{ from_x, from_y, to_x, to_y }` | Press, move, and release between two points. | | `move` | `{ x, y }` | Move the cursor without clicking. | | `wait` | `{ ms }` | Pause before the next step (e.g. for a page load). | | `done` | `{}` | The task is complete. `status` becomes `"done"`. | | `fail` | `{ reason? }` | The task is impossible. `status` becomes `"fail"`. | ### Error envelope Every error, on every endpoint, returns the same JSON shape with the HTTP status set accordingly. The body is always wrapped in an `error` object: ```json { "error": { "code": "INSUFFICIENT_CREDITS", "message": "Operation needs 20 credits; you have 5.", "type": "billing_error", "request_id": "req_8f2c1e9a", "suggestion": "Top up at https://coasty.ai/credits, or switch to a sandbox key 'sk-coasty-test-...' for free testing.", "docs_url": "https://coasty.ai/api-docs#errors", "required": 20, "balance": 5 } } ``` Field reference (every error carries the first four; the rest are conditional): - `code`: machine-readable, stable across versions. Branch your logic on this, never on `message`. - `message`: human-readable, may change between versions. Do not parse it. - `type`: coarse telemetry category (`auth_error`, `billing_error`, `validation_error`, `not_found_error`, `state_error`, `rate_limit_error`, `server_error`). - `request_id`: also returned as the `X-Coasty-Request-Id` header. Quote it to support. - `suggestion`: a concrete next step (auto-filled per code). LLM agents can act on this to self-recover. - `docs_url`: deep link to the matching docs anchor (also sent as `Link: ; rel="help"`). - `support`: `founders@coasty.ai`, attached only on 5xx and a few ambiguous 4xx. - Context extras: code-specific fields such as `required_scope`, `current_scopes`, `required`, `balance`, `retry_after`, `valid_options`, `examples`, `details`, `current_state`, `allowed_from`. Use these to auto-correct. ### Full error catalog | Status | Code | Cause | Fix | | --- | --- | --- | --- | | 401 | `INVALID_API_KEY` | Key missing, malformed, revoked, or `Bearer ` pasted into `X-API-Key`. Also sends `WWW-Authenticate`. | Send a raw `sk-coasty-live-`/`sk-coasty-test-` key in `X-API-Key`, or `Authorization: Bearer `. | | 403 | `INSUFFICIENT_SCOPE` | Key is valid but lacks the scope this route needs. Body has `required_scope` + `current_scopes`. | Re-mint the key with the missing scope, or call an endpoint your scopes allow. | | 402 | `INSUFFICIENT_CREDITS` | Prepaid USD wallet can't cover this request. Body has `required` + `balance`. | Top up at https://coasty.ai/credits, or use a `sk-coasty-test-` key (free). | | 402 | `WALLET_EXHAUSTED` | Wallet ran dry mid-run; the run stopped at the step in `message`. Completed steps were already billed. | Top up, then start a new run. | | 422 | `VALIDATION_ERROR` | A request field failed validation. `error.details` (loc) names the field path + expected type. | Fix the named field and retry. | | 422 | `INVALID_SCREENSHOT` | `screenshot` is not decodable base64 (often a `data:...;base64,` prefix or embedded newlines). | Strip the `data:` prefix and whitespace; send raw base64. | | 413 | `PAYLOAD_TOO_LARGE` | Body exceeds the cap (CUA screenshot endpoints accept up to 10 MB of base64). | Downscale or JPEG-compress the screenshot, or split the work. | | 400 | `INVALID_LIMIT` | A `limit` query param is outside `1..200`. Body has `actual`, `min`, `max`. | Pass `1 <= limit <= 200` (default 50), or omit the param. | | 400 | `INVALID_STATUS_FILTER` | A `status` filter value is not a recognized state. Body lists `valid_options`. | Use a value from `valid_options` or omit `?status=`. | | 404 | `NOT_FOUND` / `MACHINE_NOT_FOUND` / `RUN_NOT_FOUND` / `WORKFLOW_NOT_FOUND` / `SESSION_NOT_FOUND` | Id does not exist in this key's namespace. Ids are mode-isolated: a test key cannot see live resources and vice-versa. | Verify the id with the matching `GET` listing endpoint, using a key of the same kind. | | 409 | `NOT_AWAITING_HUMAN` | You tried to resume a run that is not in `awaiting_human`. | Re-GET the run; resume only while `status == "awaiting_human"`. | | 409 | `RESUME_CONFLICT` | Another resume/cancel/timeout won the race. | Re-GET the run to read its current status, then retry. | | 409 | `IDEMPOTENCY_KEY_REUSED` | Same `Idempotency-Key` sent with a different body. | Resend the original body to get the cached result, or use a new key. | | 409 | `INVALID_STATE` | A lifecycle action is illegal in the resource's current state. Body has `current_state` + `allowed_from`. | Check the state first (actions need `running`; provisioning is async), then retry. | | 429 | `RATE_LIMIT_EXCEEDED` | Per-key or per-user rate limit hit. `scope` is `per_key` or `per_user`; honor `Retry-After`. | Back off. The `per_user` cap is per-account across ALL keys, so minting more keys will NOT raise it; upgrade your plan instead. | | 429 | `TOO_MANY_RUNS` | Too many concurrent runs in flight. | Wait for one to finish or cancel one; honor `Retry-After`. | | 400 | `FEATURE_NOT_AVAILABLE` | The feature is gated to a higher tier (e.g. `v4` on free/starter, custom prompts on free). | Upgrade your plan or use an available alternative. | | 500 | `INTERNAL_ERROR` | An unexpected server-side failure. | Retry; if it persists, file a ticket with the `request_id`. | | 500 | `PREDICTION_FAILED` / `GROUNDING_FAILED` / `OCR_FAILED` | The model call failed. The charge is automatically refunded. | Retry; for grounding/OCR, send a clearer or higher-resolution screenshot. | | 504 | `UPSTREAM_TIMEOUT` | An upstream provisioning service timed out. | Add an `Idempotency-Key` and retry; if the original succeeded, the retry is a no-op. | | 503 | `UPSTREAM_UNAVAILABLE` | An upstream service is briefly unavailable. | Retry with backoff; check https://status.coasty.ai. | Rate-limit responses also include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers plus a `Retry-After` header (and a `retry_after` body field). Always honor `Retry-After` before retrying. ### Rate limits per tier API tiers are derived from your subscription. Both a per-key and a per-user (across all your keys) limit apply. | Tier | Requests/min | Requests/hour | Concurrent sessions | Max trajectory | Max actions | CUA versions | | --- | --- | --- | --- | --- | --- | --- | | free | 3 | 30 | 1 | 3 | 3 | v3 | | starter | 10 | 200 | 3 | 5 | 5 | v3 | | professional | 20 | 500 | 10 | 8 | 5 | v1, v3, v4 | | enterprise | 30 | 1000 | 100 | 20 | 10 | v1, v3, v4 | Per-user hard cap across all keys: 40 requests/min, 1500 requests/hour. Custom prompts (`system_prompt` + `instructions`) are gated by `max_system_prompt_chars` per tier: free 0 (unavailable), starter 2000, professional 4000, enterprise 16000. ### Scopes Scopes are deny-by-default; each route asserts the scope it requires. Naming is `:` (`read` / `write` / `exec`). | Scope | Grants | | --- | --- | | `predict` | `POST /v1/predict`. | | `session` | All `/v1/sessions` endpoints. | | `ground` | `POST /v1/ground`. | | `ocr` | `POST /v1/ocr`. | | `parse` | `POST /v1/parse`. | | `keys` | List/revoke your own keys via the API. | | `usage` | Read the usage summary. | | `runs:read` | List, get, and stream events of your runs. | | `runs:write` | Start, cancel, and resume (human takeover) runs. | | `workflows:read` | List/get workflows and workflow runs. | | `workflows:write` | Create/update/delete workflows and start workflow runs. | | `machines:read` | List, get, status, screenshot of machines. | | `machines:write` | Provision, start, stop, terminate machines. | | `actions:exec` | `POST /actions`, `/actions/batch`, browser ops. | | `terminal:exec` | `POST /terminal` (arbitrary shell). | | `files:read` / `files:write` | File read/list/exists; write/edit/append/delete. | | `browser:execute` | `browser_execute` (arbitrary JS). | | `snapshots:write` | Create + delete snapshots. | | `connection:read` | SSH key + VNC password (high-risk). | | `schedules:read` / `schedules:write` | List/get; create/update/delete/run-now schedules. | | `triggers:write` | Add/remove webhook + email + chain triggers. | Default scopes on a new key: `predict`, `session`, `ground`, `ocr`, `parse`, `machines:read`, `actions:exec`, `files:read`, `runs:read`, `runs:write`, `workflows:read`, `workflows:write`. Elevated scopes (`terminal:exec`, `files:write`, `browser:execute`, `connection:read`, `snapshots:write`) are requested explicitly at key creation. ### Pricing (USD) Costs are computed internally at a granularity of $0.01 per unit and shown here in dollars. Charges are taken before the model call and refunded on failure. | Endpoint | Cost | Note | | --- | --- | --- | | `POST /v1/predict` | $0.05 | Stateless prediction. | | `POST /v1/sessions` | $0.10 | One-time session creation. | | `POST /v1/sessions/{id}/predict` | $0.04 | Each step inside a session. | | `POST /v1/ground` | $0.03 | Coordinate grounding. | | `POST /v1/ocr` | $0.03 | Text extraction. | | `POST /v1/parse` | Free | Deterministic, no model call. | | `POST /v1/runs` | $0.05/step | Per agent step on v3/v4 (v1 is $0.08/step), billed from your USD wallet. | | `POST /v1/workflows/runs` | $0.05/step | Each task step is a run; total capped by `budget_cents`. | Surcharges may apply on inference endpoints: roughly +$0.02 per extra trajectory screenshot, +$0.01 per HD screenshot (wider than 1280x720), +$0.03 per request on the `v1` engine, and +$0.01 for a large custom prompt (over 500 chars). The wallet is a prepaid USD balance; top up in the developer dashboard. ### MCP server Connect Coasty to an MCP-capable client (for example, Claude or an IDE agent) with the official server: ```bash npx -y @coasty/mcp ``` Set `COASTY_API_KEY` in the environment; the MCP server exposes the same endpoints documented above as tools. --- ## 7. Machines API A machine is a cloud VM you own. Provision one, drive it with low-level actions (click, type, terminal, browser, files), snapshot it, and tear it down. A machine id is the `machine_id` you pass to runs, workflows, and schedules. A machine id is either a UUID (live machines) or `mch_test_<8-32 hex>` (sandbox machines from a test key). Ids are mode-isolated: a test key never sees live machines and a live key never sees test machines. **Sandbox shortcut.** A test key (`sk-coasty-test-`) returns a mock VM **instantly** with no AWS provisioning, no wallet billing, and an `mch_test_*` id. Use it to build and test the full action surface for free. Live provisioning needs the `machines:write` scope and a minimum wallet balance (a 20-credit pre-flight check). ### Machine endpoints + scopes | Method + path | Scope | Description | | --- | --- | --- | | `POST /v1/machines` | `machines:write` | Provision a VM. Honors `Idempotency-Key`. | | `GET /v1/machines` | `machines:read` | List your machines (`limit`, 1-200, default 50). | | `GET /v1/machines/{id}` | `machines:read` | Get one machine. | | `DELETE /v1/machines/{id}` | `machines:write` | Terminate a machine. | | `POST /v1/machines/{id}/start` | `machines:write` | Start a stopped machine. | | `POST /v1/machines/{id}/stop` | `machines:write` | Stop a running machine. | | `POST /v1/machines/{id}/snapshot` | `snapshots:write` | Snapshot the disk. Honors `Idempotency-Key`. | | `GET /v1/machines/{id}/screenshot` | `machines:read` | Capture the screen as base64. | | `GET /v1/machines/{id}/connection` | `connection:read` | SSH key + VNC password + ports (HIGH-RISK). | | `POST /v1/machines/{id}/actions` | varies by command | Run one action (click, type, scroll, terminal, file, browser). | | `POST /v1/machines/{id}/actions/batch` | varies by command | Run up to 50 actions in order. | | `POST /v1/machines/{id}/browser/{op}` | `actions:exec` (`browser:execute` for raw JS) | Browser convenience wrapper. | | `POST /v1/machines/{id}/terminal` | `terminal:exec` | Run a shell command (PowerShell on Windows, bash on Unix). | | `POST /v1/machines/{id}/files/{op}` | `files:read` or `files:write` | File operations. | Per-command scopes on `/actions`: `terminal_*` needs `terminal:exec`; `file_read`/`file_exists`/`directory_list`/`file_download`/`file_list_downloads` need `files:read`; `file_write`/`file_edit`/`file_append`/`file_delete`/`directory_delete` need `files:write`; `browser_execute` (arbitrary JS) needs `browser:execute`; everything else needs `actions:exec`. ### POST /v1/machines (provision) **Request body** | Field | Type | Req | Default | Notes | | --- | --- | --- | --- | --- | | `display_name` | string | yes | - | 1-64 chars. Shown in dashboards. | | `os_type` | string | no | `linux` | `linux` or `windows`. | | `desktop_enabled` | bool | no | false | Install XFCE + VNC for a GUI desktop. | | `provider` | string | no | `auto` | `aws` / `azure` / `auto`. | | `cpu_cores` | int\|null | no | null | 1-16, capped to your tier. | | `memory_gb` | int\|null | no | null | 1-64, capped to your tier. | | `storage_gb` | int\|null | no | null | 8-500. | | `restore_from_snapshot` | bool\|null | no | false | Restore your latest snapshot (Linux only). | | `metadata` | object\|null | no | null | Free-form string tags (max 16 entries). | Pass `Idempotency-Key: ` so a retried provision does not create a second VM (deduped for 24h). **curl** ```bash BASE=https://coasty.ai/v1 AUTH="X-API-Key: $COASTY_API_KEY" curl -s "$BASE/machines" -H "$AUTH" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: provision-bot-001" \ -d '{"display_name":"invoice-bot","os_type":"linux","desktop_enabled":true}' ``` **Python** ```python import os, requests BASE = "https://coasty.ai/v1" HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]} m = requests.post( f"{BASE}/machines", headers={**HEADERS, "Idempotency-Key": "provision-bot-001"}, json={"display_name": "invoice-bot", "os_type": "linux", "desktop_enabled": True}, timeout=60, ).json() machine_id = m["machine"]["id"] print(machine_id, m["machine"]["status"]) ``` **Response** ```json { "machine": { "id": "mch_test_a1b2c3d4", "display_name": "invoice-bot", "status": "running", "os_type": "linux", "provider": "aws", "desktop_enabled": true, "cpu_cores": 2, "memory_gb": 4.0, "storage_gb": 20, "public_ip": "203.0.113.7", "is_test": true, "created_at": "2026-06-01T12:00:00Z", "metadata": {} }, "connection": { "public_ip": "203.0.113.7", "ssh_port": 22, "ssh_username": "ubuntu", "vnc_port": 5900, "websocket_port": 8080, "has_ssh_key": true, "has_vnc_password": true }, "request_id": "req_8f2c1e9a" } ``` The provision response NEVER includes the SSH key or VNC password. Fetch those from `GET /v1/machines/{id}/connection` (gated by `connection:read`); it returns `ssh_private_key_pem`, `vnc_password`, `websocket_url`, and `devtools_url`. Treat that response as a secret (it is sent with `Cache-Control: no-store`). ### Lifecycle: start / stop / delete / snapshot `POST /v1/machines/{id}/start`, `/stop`, and `DELETE /v1/machines/{id}` return `{ machine_id, status, message, request_id }`. `POST /v1/machines/{id}/snapshot` returns `{ machine_id, snapshot_id, name, created_at, credits_charged, request_id }`. ### GET /v1/machines/{id}/screenshot ```bash curl -s "$BASE/machines/$MACHINE_ID/screenshot" -H "$AUTH" ``` ```json { "machine_id": "mch_test_a1b2c3d4", "image_b64": "", "mime_type": "image/jpeg", "width": 1280, "height": 720, "captured_at": "2026-06-01T12:00:05Z", "request_id": "req_8f2c1e9a" } ``` The `image_b64` is pure base64 with any `data:image/...;base64,` prefix already stripped, so you can feed it straight back into `/v1/predict`. ### POST /v1/machines/{id}/actions **Request body** | Field | Type | Req | Default | Notes | | --- | --- | --- | --- | --- | | `command` | string | yes | - | A name from the action allowlist (e.g. `click`, `type`, `scroll`, `screenshot`). | | `parameters` | object | no | {} | Command-specific params, e.g. `{ "x": 512, "y": 340 }` for `click`. | | `timeout_ms` | int\|null | no | null | 1000-120000. Defaults to the command's tuned timeout. | ```bash curl -s "$BASE/machines/$MACHINE_ID/actions" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"command":"click","parameters":{"x":512,"y":340}}' ``` ```python res = requests.post( f"{BASE}/machines/{machine_id}/actions", headers=HEADERS, json={"command": "click", "parameters": {"x": 512, "y": 340}}, timeout=60, ).json() print(res["success"], res["duration_ms"], "ms") ``` ```json { "machine_id": "mch_test_a1b2c3d4", "command": "click", "success": true, "result": { "success": true, "x": 512, "y": 340 }, "error": null, "duration_ms": 84, "screenshot": null, "request_id": "req_8f2c1e9a" } ``` ### POST /v1/machines/{id}/actions/batch Run an ordered list of actions in one round-trip. Body: `{ "steps": [ ActionRequest... ], "stop_on_error": true }` (max 50 steps; `stop_on_error` aborts on the first failure, shell `&&`-style). Returns `{ machine_id, results, completed_count, failed_count, aborted, request_id }`. ```bash curl -s "$BASE/machines/$MACHINE_ID/actions/batch" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "steps": [ {"command": "click", "parameters": {"x": 512, "y": 340}}, {"command": "type", "parameters": {"text": "you@example.com"}}, {"command": "key_press", "parameters": {"key": "enter"}} ], "stop_on_error": true }' ``` ### POST /v1/machines/{id}/browser/{op} A convenience wrapper over browser commands. `op` is one of: `open`, `navigate`, `click`, `type`, `dom`, `clickables`, `state`, `info`, `scroll`, `close`, `screenshot`, `wait`, `list-tabs`, `open-tab`, `close-tab`, `switch-tab`. Body: `{ "parameters": {...}, "timeout_ms": null }`. Arbitrary JS (`browser_execute`) is intentionally NOT a browser op; send it through `/actions` with the `browser:execute` scope. ```bash curl -s "$BASE/machines/$MACHINE_ID/browser/navigate" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"parameters":{"url":"https://example.com"}}' ``` ### POST /v1/machines/{id}/terminal Run a shell command. Requires `terminal:exec`. Output is truncated VM-side to 5000 chars; the hard timeout cap is 120s. **Request body** | Field | Type | Req | Default | Notes | | --- | --- | --- | --- | --- | | `command` | string | yes | - | 1-8192 chars. PowerShell on Windows, bash on Unix. | | `timeout_ms` | int | no | 30000 | 1000-120000. | | `session_id` | string\|null | no | null | Reuse a persistent terminal session. | | `cwd` | string\|null | no | null | Initial working directory. | ```bash curl -s "$BASE/machines/$MACHINE_ID/terminal" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"command":"ls -la /tmp","timeout_ms":15000}' ``` ### POST /v1/machines/{id}/files/{op} File operations. `op` is one of: `read`, `exists`, `list`, `list-directory`, `download`, `list-downloads` (need `files:read`) or `write`, `edit`, `append`, `delete`, `delete-directory` (need `files:write`). Body: `{ "parameters": {...} }` where the params depend on the op (e.g. `{ "path": "..." }` for read, `{ "path": ..., "content": ... }` for write). ```bash # Read a file curl -s "$BASE/machines/$MACHINE_ID/files/read" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"parameters":{"path":"/home/ubuntu/report.txt"}}' # Write a file (needs files:write) curl -s "$BASE/machines/$MACHINE_ID/files/write" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{"parameters":{"path":"/home/ubuntu/out.txt","content":"hello"}}' ``` --- ## 8. Schedules & Triggers A schedule fires an agent task against a machine on a cron/preset cadence, or in response to an inbound trigger (an HMAC-signed webhook, inbound email, or when another schedule completes). Scopes: `schedules:read` (list/get/runs) and `schedules:write` (create/update/delete/pause/resume/run-now); adding or removing triggers needs `triggers:write`. A schedule id is a UUID (live) or `sch_test_<8-32 hex>` (sandbox). Test keys get mock schedules that never bill, capped at 10. A trigger id matches `trg_<8-32 hex>`. ### Schedule endpoints | Method + path | Scope | Description | | --- | --- | --- | | `POST /v1/schedules` | `schedules:write` | Create a schedule. Honors `Idempotency-Key`. | | `GET /v1/schedules` | `schedules:read` | List schedules (`limit`, 1-200, default 50). | | `GET /v1/schedules/{id}` | `schedules:read` | Get one schedule. | | `PATCH /v1/schedules/{id}` | `schedules:write` | Update fields (at least one required). | | `DELETE /v1/schedules/{id}` | `schedules:write` | Delete a schedule. | | `POST /v1/schedules/{id}/run` | `schedules:write` | Fire once now (optional overrides). | | `POST /v1/schedules/{id}/pause` | `schedules:write` | Pause (stop firing). | | `POST /v1/schedules/{id}/resume` | `schedules:write` | Resume a paused schedule. | | `GET /v1/schedules/{id}/runs` | `schedules:read` | List run history (`cursor`, `status`, `limit`). | | `GET /v1/schedules/{id}/runs/{run_id}` | `schedules:read` | Get one schedule run. | | `GET /v1/schedules/{id}/triggers` | `schedules:read` | List a schedule's triggers. | | `POST /v1/schedules/{id}/triggers` | `triggers:write` | Add a webhook/email/chain trigger. | | `DELETE /v1/schedules/{id}/triggers/{tid}` | `triggers:write` | Remove a trigger. | | `POST /v1/triggers/webhook/{id}` | NONE (HMAC-signed) | Public fire endpoint hit by external systems. | | `POST /v1/triggers/email-mailbox` | `triggers:write` | Provision an inbound email address. | ### POST /v1/schedules (create) Provide exactly one of `frequency` (a recurring preset) or `run_at` (a one-shot UTC timestamp). With `frequency: "custom"` you must also supply a raw `cron`. **Request body** | Field | Type | Req | Default | Notes | | --- | --- | --- | --- | --- | | `name` | string | yes | - | 1-128 chars. | | `machine_id` | string | yes | - | Target VM, owned by your key's user. | | `task_prompt` | string | yes | - | 1-8000 chars. The agent's instructions each fire. | | `frequency` | string\|null | no | null | `every_15_minutes`, `every_30_minutes`, `hourly`, `every_6_hours`, `every_12_hours`, `daily`, `weekly`, `monthly`, `custom`. | | `cron` | string\|null | no | null | Raw 5-or-6-field cron. Required when `frequency = "custom"`. | | `time` | string\|null | no | null | `HH:MM` for daily/weekly/monthly presets. | | `timezone` | string | no | `UTC` | IANA timezone, e.g. `America/New_York`. | | `day_of_week` | int\|null | no | null | 0=Mon..6=Sun (weekly). | | `day_of_month` | int\|null | no | null | 1-28 (monthly). | | `run_at` | string\|null | no | null | ISO-8601 UTC for a one-shot schedule. Mutually exclusive with `frequency`. | | `max_consecutive_failures` | int | no | 5 | 1-50. Circuit breaker: auto-pause after N failures. | | `metadata` | object\|null | no | null | String tags (max 16). | ```bash BASE=https://coasty.ai/v1 AUTH="X-API-Key: $COASTY_API_KEY" curl -s "$BASE/schedules" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "name": "Daily invoice sweep", "machine_id": "mch_test_a1b2c3d4", "task_prompt": "Open the billing page and download every new invoice as PDF", "frequency": "daily", "time": "09:00", "timezone": "America/New_York" }' ``` A schedule (`ScheduleResponse`) returns `{ id, name, machine_id, task_prompt, enabled, frequency, cron, timezone, next_run_at, last_run_at, run_count, consecutive_failures, paused_reason, is_test, created_at, metadata }`. `PATCH /v1/schedules/{id}` accepts any subset of `name`, `task_prompt`, `frequency`, `cron`, `timezone`, `time`, `day_of_week`, `day_of_month`, `max_consecutive_failures`, `enabled`, `metadata` (an empty body is a 400 `EMPTY_UPDATE`). ### Run now / pause / resume `POST /v1/schedules/{id}/run` fires immediately; body (all optional): `{ "task_prompt_override": "...", "triggered_context": { ... } }`. It returns `{ schedule_id, run_id, status, message, request_id }`. `/pause` and `/resume` flip the schedule's `enabled` flag. ### Run history `GET /v1/schedules/{id}/runs` lists past fires: `{ data: [ { id, schedule_id, status, trigger, duration_seconds, credits_charged, error, executed_at } ], next_cursor, has_more, request_id }`. Filter with `?status=` (one of `completed`, `failed`, `skipped`, `cancelled`, `running`, `insufficient_credits`). `GET /v1/schedules/{id}/runs/{run_id}` returns a single run. ### Triggers `POST /v1/schedules/{id}/triggers` adds a trigger. `kind` is one of: - `webhook`: returns a public `webhook_url` plus a one-time `webhook_secret`. Optional `rate_limit_per_minute` (1-600, default 60). - `email`: provisions an inbound mailbox; optional `email_label`. - `chain`: fires this schedule when `source_schedule_id` completes. Optional `event` (`on_complete` / `on_failure` / `on_any`, default `on_complete`) and `pass_output` (default true). Chain depth is capped at 5. The webhook `webhook_secret` is shown ONCE at creation (the response carries `Cache-Control: no-store`); only its hash is stored, so save it now. ### POST /v1/triggers/webhook/{id} (unauthenticated, HMAC-signed) External systems (Stripe, Linear, a CRM) fire a schedule by POSTing to the webhook URL. There is NO API key: the request is authenticated by an HMAC-SHA256 signature instead. Send the signature in the `Coasty-Signature` header as `t=,v1=`, where the signed payload is `"." + raw_request_body`. The replay window is 5 minutes: a signature whose `t` is older than 5 minutes is rejected even if the HMAC is valid. Identical `(webhook_id, body)` fires within 60s are deduplicated. ```python import hashlib, hmac, json, os, time, requests BASE = "https://coasty.ai/v1" HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]} SCHEDULE_ID = "sch_test_a1b2c3d4" # 1. Add a webhook trigger. webhook_url + webhook_secret are returned ONCE. trig = requests.post( f"{BASE}/schedules/{SCHEDULE_ID}/triggers", headers=HEADERS, json={"kind": "webhook"}, timeout=30, ).json() webhook_url = trig["webhook_url"] # https://coasty.ai/v1/triggers/webhook/whk_... webhook_secret = trig["webhook_secret"] # persist this securely; shown only here # 2. Sign and fire the webhook the way an external system would. body = json.dumps({"event": "invoice.created", "id": "inv_4821"}).encode() ts = str(int(time.time())) signed = ts.encode() + b"." + body sig = hmac.new(webhook_secret.encode(), signed, hashlib.sha256).hexdigest() res = requests.post( webhook_url, headers={ "Content-Type": "application/json", "Coasty-Signature": f"t={ts},v1={sig}", }, data=body, timeout=30, ).json() print(res["received"], res.get("run_id")) # {"received": true, "run_id": "...", ...} ``` The fire response is `{ received, schedule_id, run_id, deduplicated, message, request_id }`. A bad/missing/expired signature returns `401 INVALID_SIGNATURE`; a paused schedule returns `SCHEDULE_INACTIVE`; exceeding the per-webhook rate limit returns `429 RATE_LIMITED` (honor `Retry-After`). ### POST /v1/triggers/email-mailbox Provisions a fresh inbound address on the `agents.coasty.ai` domain (gated by `triggers:write`). Returns `{ email_address, label, is_test, note, request_id }`. Pair it with a chain or email trigger to fire schedules on inbound mail. --- ## Links - Human docs: https://coasty.ai/docs - Manage API keys: https://coasty.ai/developers/keys - Site-wide LLM doc: https://coasty.ai/llms-full.txt