Send a screenshot. Get structured mouse and keyboard actions back. One REST endpoint — for automation, browser testing, and AI agents that interact with any GUI.
{ "action_type": "click", "params": { "x": 98, "y": 136, } }
Pure REST. No SDK lock-in, no extra servers, no browser drivers.
1import requests, base6423img = base64.b64encode(open("screen.png", "rb").read()).decode()45r = requests.post(6 "https://coasty.ai/v1/predict",7 headers={"X-API-Key": "sk-coasty-live-..."},8 json={9 "screenshot": img,10 "instruction": "Click the search bar and type 'hello'",11 },12)1314for a in r.json()["actions"]:15 print(a["action_type"], a["params"])
Provision a sandbox or production VM, then drive it with actions, terminal commands, browser automation, or file ops. One auth header, one API.
1import requests23# Provision a sandbox VM (sk-coasty-test-* — instant, no billing).4r = requests.post(5 "https://coasty.ai/v1/machines",6 headers={7 "X-API-Key": "sk-coasty-test-...",8 "Idempotency-Key": "demo-001",9 },10 json={11 "display_name": "automation-bot",12 "os_type": "linux",13 "desktop_enabled": True,14 },15)16machine = r.json()["machine"]1718# Drive it: click at (512, 340)19requests.post(20 f"https://coasty.ai/v1/machines/{machine['id']}/actions",21 headers={"X-API-Key": "sk-coasty-test-..."},22 json={"command": "click", "parameters": {"x": 512, "y": 340}},23)
POST /v1/machinesGET /v1/machinesGET /v1/machines/pricingPATCH /v1/machines/{id}DELETE /v1/machines/{id}POST /v1/machines/{id}/startPOST /v1/machines/{id}/stopPOST /v1/machines/{id}/restartPOST /v1/machines/{id}/snapshotGET /v1/machines/{id}/screenshotGET /v1/machines/{id}/connectionPOST /v1/machines/{id}/actionsPOST /v1/machines/{id}/actions/batchPOST /v1/machines/{id}/browser/{op}POST /v1/machines/{id}/terminalPOST /v1/machines/{id}/files/{op}Run an agent on a cron, fire it from any webhook with HMAC, or chain schedules together. Webhook fires are free. Published managed defaults: $0.20 wallet gate; non-Unlimited execution at 10 subscription cr/min (min 20, 6 h cap). Unlimited bypasses the credit meter. Live BYOK schedules bypass both Coasty meters and the selected provider bills actual tokens. Store the provider key first: a creation header is validation-only, every fire resolves the current stored key, rotation takes effect on the next fire without recreating the schedule, and PATCH cannot change the schedule's LLM preference (delete and recreate it). Run history omits LLM attribution and credentials. Read GET /v1/models pricing.schedules for effective pricing and webhook policy. Sandbox managed mode is free; test-auth BYOK is rejected before execution.
1import requests, hmac, hashlib, time23# 1. Create a daily 9 AM ET schedule4sched = requests.post(5 "https://coasty.ai/v1/schedules",6 headers={"X-API-Key": "sk-coasty-test-..."},7 json={8 "name": "morning briefing",9 "machine_id": "550e8400-e29b-41d4-a716-446655440000",10 "task_prompt": "Summarize unread Gmail and post to Slack.",11 "frequency": "daily",12 "time": "09:00",13 "timezone": "America/New_York",14 },15).json()1617# 2. Add a webhook trigger — store the secret immediately18trigger = requests.post(19 f"https://coasty.ai/v1/schedules/{sched['id']}/triggers",20 headers={"X-API-Key": "sk-coasty-test-..."},21 json={"kind": "webhook"},22).json()23secret = trigger["webhook_secret"] # whsec_<64 hex> — store this2425# 3. Sign + fire the webhook from any external system26ts = int(time.time())27body = b'{"event":"order.placed"}'28sig = hmac.new(secret.encode(), f"{ts}.".encode() + body,29 hashlib.sha256).hexdigest()30requests.post(31 trigger["webhook_url"],32 headers={"Coasty-Signature": f"t={ts},v1={sig}"},33 data=body,34)
POST /v1/schedulesGET /v1/schedulesPATCH /v1/schedules/{id}DELETE /v1/schedules/{id}POST /v1/schedules/{id}/runPOST /v1/schedules/{id}/pausePOST /v1/schedules/{id}/resumeGET /v1/schedules/{id}/runsGET /v1/schedules/{id}/runs/{run_id}POST /v1/schedules/{id}/triggersDELETE /v1/schedules/{id}/triggers/{tid}POST /v1/triggers/webhook/{wh} ← unauth · HMACpredict, ground and sessions are screen-agnostic — feed them screenshots from your own desktop, a Playwright page, a phone emulator, or a VNC frame, and execute the returned actions with pyautogui, page.mouse, or adb. No VM required. If you want Coasty Tasks and Workflows to own that loop, enroll the same screen as an external machine below. Each screenshot is capped at 10,485,760 base64 characters, and every ordinary JSON request (all trajectory frames included) must stay within 15 MiB.
1# Automate YOUR screen — no VM needed. pip install requests mss pyautogui pillow2import base64, contextlib, email.utils, io, json, os, random, time, uuid, requests, mss, pyautogui3from datetime import timezone4from PIL import Image56API = "https://coasty.ai/v1"7KEY = os.environ["COASTY_API_KEY"] # use a live key for real managed inference8HDRS = {"X-API-Key": KEY, "Content-Type": "application/json"}9pyautogui.FAILSAFE = True # slam the mouse into a corner to abort instantly1011REAL_W, REAL_H = pyautogui.size() # your actual desktop resolution12SEND_W, SEND_H = 1280, 720 # what we tell the model (SD = 1 credit cheaper)13SX, SY = REAL_W / SEND_W, REAL_H / SEND_H # scale model coords -> real pixels1415def screenshot_b64():16 with mss.mss() as sct:17 shot = sct.grab(sct.monitors[1]) # primary monitor18 img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")19 img = img.resize((SEND_W, SEND_H)) # MUST match screen_width/height20 buf = io.BytesIO(); img.save(buf, format="PNG")21 return base64.b64encode(buf.getvalue()).decode()2223WINDOW_CLOSE_ACTIONS = {24 "close_window", "window_close", "browser_close", "browser_close_tab",25 "close_tab", "terminal_close",26}27KEY_ALIASES = {28 "esc": "escape", "return": "enter", "control": "ctrl", "ctl": "ctrl",29 "command": "cmd", "super": "cmd", "win": "cmd", "windows": "cmd", "meta": "cmd",30 "option": "alt",31 "ctrl_l": "ctrl", "ctrl_r": "ctrl", "control_l": "ctrl", "control_r": "ctrl",32 "cmd_l": "cmd", "cmd_r": "cmd", "command_l": "cmd", "command_r": "cmd",33 "meta_l": "cmd", "meta_r": "cmd", "super_l": "cmd", "super_r": "cmd",34 "win_l": "cmd", "win_r": "cmd", "windows_l": "cmd", "windows_r": "cmd",35 "alt_l": "alt", "alt_r": "alt", "option_l": "alt", "option_r": "alt",36}37PYAUTOGUI_KEY_ALIASES = {38 "cmd": "command", "meta": "command", "return": "enter",39 "esc": "escape", "option": "alt",40 "ctrl_l": "ctrl", "ctrl_r": "ctrl", "control_l": "ctrl", "control_r": "ctrl",41 "cmd_l": "command", "cmd_r": "command", "command_l": "command", "command_r": "command",42 "meta_l": "command", "meta_r": "command", "super_l": "command", "super_r": "command",43 "win_l": "command", "win_r": "command", "windows_l": "command", "windows_r": "command",44 "alt_l": "alt", "alt_r": "alt", "option_l": "alt", "option_r": "alt",45}4647def pyautogui_key(value):48 key = str(value).strip().lower()49 return PYAUTOGUI_KEY_ALIASES.get(key, key)5051def type_text_literal(value):52 # PyAutoGUI 0.9.x cannot faithfully type arbitrary Unicode. Never silently53 # drop patient names/notes: use an OS-native Unicode implementation in a54 # production desktop driver, or fail visibly as this portable sample does.55 if not value.isascii():56 raise RuntimeError("type_text contains non-ASCII text; configure an OS-native Unicode typer")57 pyautogui.write(value, interval=0.02)5859@contextlib.contextmanager60def held_keys(values):61 keys = [pyautogui_key(value) for value in (values or [])]62 for key in keys:63 pyautogui.keyDown(key)64 try:65 yield66 finally:67 for key in reversed(keys):68 pyautogui.keyUp(key)6970def normalized_keys(a):71 p = a.get("params", {})72 values = []73 for field in ("key", "keys", "hold_keys", "modifiers"):74 raw = p.get(field)75 values.extend([raw] if isinstance(raw, str) else (raw or []))76 keys = set()77 for value in values:78 for part in str(value).strip().lower().replace("-", "+").split("+"):79 part = part.strip()80 if part:81 keys.add(KEY_ALIASES.get(part, part))82 return keys8384def prohibited(a):85 keys = normalized_keys(a)86 return (87 "escape" in keys88 or a["action_type"] in WINDOW_CLOSE_ACTIONS89 or {"alt", "f4"} <= keys90 or (("ctrl" in keys or "cmd" in keys) and "w" in keys)91 or {"cmd", "q"} <= keys92 )9394def execute(a):95 t, p = a["action_type"], a["params"]96 if t in ("done", "fail"):97 return # terminal signals have no OS effect98 if t == "click":99 with held_keys(p.get("hold_keys")):100 pyautogui.click(p["x"] * SX, p["y"] * SY,101 clicks=p.get("clicks", 1), button=p.get("button", "left"))102 elif t == "move": pyautogui.moveTo(p["x"] * SX, p["y"] * SY)103 elif t == "type_text": type_text_literal(p["text"])104 elif t == "key_press": pyautogui.press([pyautogui_key(k) for k in p["keys"]])105 elif t == "key_combo": pyautogui.hotkey(*[pyautogui_key(k) for k in p["keys"]])106 elif t == "scroll":107 if "x" in p: pyautogui.moveTo(p["x"] * SX, p["y"] * SY)108 if p.get("direction", "vertical") == "horizontal":109 pyautogui.hscroll(p["clicks"]) # +right / -left110 else:111 pyautogui.scroll(p["clicks"]) # +up / -down112 elif t == "drag":113 pyautogui.moveTo(p["x1"] * SX, p["y1"] * SY)114 with held_keys(p.get("hold_keys")):115 pyautogui.dragTo(p["x2"] * SX, p["y2"] * SY, duration=0.4,116 button=p.get("button", "left"))117 elif t == "wait": time.sleep(p["seconds"])118 else: raise RuntimeError(f"unsupported action: {t}")119120def parse_retry_after_seconds(raw):121 if raw is None:122 return None123 try:124 return max(0.0, float(raw)) # delta-seconds125 except (TypeError, ValueError):126 try: # IMF-fixdate HTTP-date127 dt = email.utils.parsedate_to_datetime(str(raw))128 if dt.tzinfo is None:129 dt = dt.replace(tzinfo=timezone.utc)130 return max(0.0, dt.timestamp() - time.time())131 except (TypeError, ValueError, OverflowError):132 return None133134def retry_after_seconds(res, error):135 # Header and canonical body are independent signals. Honor the largest136 # valid delay so a smaller or malformed intermediary header cannot shorten137 # the server's requested wait.138 candidates = (139 parse_retry_after_seconds(res.headers.get("Retry-After")),140 parse_retry_after_seconds(error.get("retry_after")),141 )142 valid = [value for value in candidates if value is not None]143 return max(valid) if valid else None144145def reconcile_idempotency(request_key, max_wait_seconds=600, initial_delay_seconds=0):146 """Collect a lost reserve-and-replay result; never invent a fresh operation."""147 url = f"{API}/idempotency/{request_key}"148 deadline = time.monotonic() + max_wait_seconds149 if initial_delay_seconds > max_wait_seconds:150 raise RuntimeError(151 f"server Retry-After exceeds foreground reconciliation budget; "152 f"resume GET {url} after {initial_delay_seconds:.1f}s"153 )154 if initial_delay_seconds > 0:155 time.sleep(initial_delay_seconds)156 attempt = 0157 while True:158 delay = None159 try:160 res = requests.get(url, headers=HDRS, timeout=15)161 except requests.RequestException:162 res = None163 if res is not None:164 try:165 envelope = res.json()166 if not isinstance(envelope, dict):167 envelope = {}168 error = envelope.get("error") or {}169 if not isinstance(error, dict):170 error = {}171 except (ValueError, AttributeError, requests.RequestException):172 envelope, error = {}, {}173 print({174 "reconcile_status": res.status_code,175 "x_request_id": res.headers.get("X-Request-Id"),176 "x_coasty_request_id": res.headers.get("X-Coasty-Request-Id"),177 "body_request_id": error.get("request_id") or envelope.get("request_id"),178 "cf_ray": res.headers.get("CF-Ray"),179 "idempotency_key": request_key,180 })181 if res.ok and envelope.get("status") == "completed":182 original_status = envelope.get("original_status")183 result = envelope.get("result")184 if isinstance(original_status, int) and 200 <= original_status < 300 and isinstance(result, dict):185 return result186 raise RuntimeError(187 f"original operation completed with HTTP {original_status}; "188 f"inspect reconciled result for key={request_key}: {str(result)[:500]}"189 )190 if res.ok and envelope.get("status") != "processing":191 raise RuntimeError(f"malformed idempotency lookup for key={request_key}")192 if res.status_code == 404:193 raise RuntimeError(194 f"idempotency key={request_key} is unknown or expired; outcome remains "195 "ambiguous. Do not start a replacement operation until resource state is checked."196 )197 if not res.ok and res.status_code not in (429, 502, 503, 504):198 res.raise_for_status()199 delay = retry_after_seconds(res, error)200201 remaining = deadline - time.monotonic()202 jitter = random.uniform(0, min(30, 2 ** min(attempt, 5)))203 delay = max(delay or 0, jitter)204 if remaining <= 0 or delay > remaining:205 raise RuntimeError(206 f"idempotency result still pending for key={request_key}; "207 f"resume GET {url} before any new operation"208 )209 time.sleep(delay)210 attempt += 1211212def post_with_retry(url, request_key, payload):213 # These documented POST routes are reserve-and-replay capable: keep the214 # same key and byte-equivalent body for transport/edge retries. Typed215 # failures must also explicitly permit same-key replay.216 body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)217 first_failure_at = None218 for attempt in range(4): # initial send + at most 3 retries219 delay = None220 remaining = (221 120.0 if first_failure_at is None222 else 120.0 - (time.monotonic() - first_failure_at)223 )224 if remaining <= 0:225 return reconcile_idempotency(request_key)226 try:227 res = requests.post(228 url,229 headers={**HDRS, "Idempotency-Key": request_key},230 data=body, # byte-identical across same-key retries231 timeout=max(0.001, min(120.0, remaining)),232 )233 except requests.RequestException:234 print(f"transport failure attempt={attempt + 1} idempotency_key={request_key}")235 else:236 if res.ok:237 try:238 result = res.json()239 if isinstance(result, dict):240 return result241 except (ValueError, requests.RequestException):242 pass243 # A 2xx with a truncated/malformed body may have committed. The244 # keyed lookup recovers the stored response without a new POST.245 return reconcile_idempotency(request_key)246 try:247 envelope = res.json()248 if not isinstance(envelope, dict):249 envelope = {}250 error = envelope.get("error", {})251 if not isinstance(error, dict):252 error = {}253 except (ValueError, AttributeError, requests.RequestException):254 envelope = {}255 error = {}256 ids = {257 "x_request_id": res.headers.get("X-Request-Id"),258 "x_coasty_request_id": res.headers.get("X-Coasty-Request-Id"),259 "body_request_id": error.get("request_id") or envelope.get("request_id"),260 "cf_ray": res.headers.get("CF-Ray"),261 "idempotency_key": request_key,262 }263 print(f"retry context attempt={attempt + 1}: {ids}")264 if error:265 if error.get("retryable") is not True:266 res.raise_for_status()267 if error.get("retry_with_same_idempotency_key") is not True:268 raise RuntimeError(269 "Retryable response did not permit same-key replay; inspect state "270 "before starting a new logical operation."271 )272 if error.get("code") == "IDEMPOTENCY_IN_FLIGHT":273 return reconcile_idempotency(274 request_key,275 initial_delay_seconds=retry_after_seconds(res, error) or 0,276 )277 elif res.status_code not in (429, 502, 503, 504):278 res.raise_for_status()279 delay = retry_after_seconds(res, error)280281 first_failure_at = first_failure_at or time.monotonic()282 delay = delay if delay is not None else random.uniform(0, min(30, 2 ** attempt))283 elapsed = time.monotonic() - first_failure_at284 if attempt == 3 or elapsed + delay > 120:285 # Do not poll before a server-directed Retry-After. Hand the delay286 # to reconciliation; if it exceeds that budget, the helper tells287 # the caller exactly when and where to resume GET polling.288 return reconcile_idempotency(289 request_key,290 initial_delay_seconds=delay,291 )292 time.sleep(delay)293294def predict_with_retry(request_key, payload):295 return post_with_retry(f"{API}/sessions/{sid}/predict", request_key, payload)296297def delete_session_with_reconciliation(session_id):298 url = f"{API}/sessions/{session_id}"299 deadline = time.monotonic() + 120300 for attempt in range(3):301 server_delay = None302 try:303 deleted = requests.delete(url, headers=HDRS, timeout=15)304 except requests.RequestException:305 deleted = None306 if deleted is not None:307 if deleted.ok or deleted.status_code == 404:308 return309 if deleted.status_code not in (429, 502, 503, 504):310 deleted.raise_for_status()311 try:312 envelope = deleted.json()313 if not isinstance(envelope, dict):314 envelope = {}315 error = envelope.get("error") or {}316 if not isinstance(error, dict):317 error = {}318 except (ValueError, AttributeError, requests.RequestException):319 error = {}320 server_delay = retry_after_seconds(deleted, error)321 print({322 "cleanup_status": deleted.status_code,323 "x_request_id": deleted.headers.get("X-Request-Id"),324 "x_coasty_request_id": deleted.headers.get("X-Coasty-Request-Id"),325 "cf_ray": deleted.headers.get("CF-Ray"),326 })327 # A lost DELETE response is ambiguous. GET is authoritative: 404 means328 # cleanup completed; 200 means the slot still exists and DELETE may retry.329 try:330 state = requests.get(url, headers=HDRS, timeout=15)331 if state.status_code == 404:332 return333 except requests.RequestException:334 state = None335 if attempt < 2:336 delay = max(337 server_delay or 0,338 random.uniform(0, min(4, 2 ** attempt)),339 )340 if delay > deadline - time.monotonic():341 raise RuntimeError(342 f"session cleanup retry exceeds budget; honor Retry-After and resume GET {url}"343 )344 time.sleep(delay)345 raise RuntimeError(f"session cleanup unconfirmed; reconcile GET {url}")346347# A live Coasty key gives real managed inference. A test key without explicit348# BYOK provider headers returns deterministic sandbox output rather than a model.349session_payload = {350 "cua_version": "v5",351 "screen_width": SEND_W, "screen_height": SEND_H,352 "action_policy": {353 "allowed_actions": [354 "click", "move", "type_text", "key_press", "key_combo",355 "scroll", "drag", "wait",356 ],357 "blocked_keys": ["escape"],358 "block_window_close": True,359 "max_actions": 5,360 },361}362create_key = f"session-create-{uuid.uuid4().hex}"363sess = post_with_retry(f"{API}/sessions", create_key, session_payload)364sid = sess["session_id"]365366micro_goal = "Goal: compute 42 * 17. Expected screen: calculator shows 714. [done] when 714 is visible."367verify_goal = "Verify only whether the calculator visibly shows 714. Return done if visible; otherwise describe the mismatch. Do not click or type."368instruction = micro_goal369completed = False370rejections = 0371try:372 for step in range(25):373 request_key = f"step-{sid}-{step}-{uuid.uuid4().hex[:8]}"374 payload = {"screenshot": screenshot_b64(), "instruction": instruction}375 r = predict_with_retry(request_key, payload)376 print(f"step {r['step']}: {r['reasoning'][:80]}")377378 policy_failure = next((379 a for a in r["actions"]380 if a["action_type"] == "fail"381 and a.get("params", {}).get("code") == "ACTION_POLICY_VIOLATION"382 ), None)383 rejected = next((a for a in r["actions"] if prohibited(a)), None)384 if policy_failure or rejected:385 # Server policy rejects its whole batch. The client check is defense386 # in depth. Execute NOTHING, observe again, and explain the rejection387 # to this same session with a new key.388 rejections += 1389 if rejections >= 3:390 raise RuntimeError(391 "three prohibited-action proposals; human review required"392 )393 instruction = (394 "CONTROL FEEDBACK: the previous prohibited action was rejected and "395 "NOT executed. The next screenshot is authoritative. Continue without "396 "Escape or closing any window."397 )398 time.sleep(0.5)399 continue400 rejections = 0401 if r["status"] == "fail":402 raise RuntimeError(f"agent failed: {r['reasoning']}")403404 for action_index, a in enumerate(r["actions"]):405 # A production driver durably journals (request_key, action_index)406 # as PREPARED before input and COMPLETED after it. On restart, a407 # leftover PREPARED action is ambiguous: capture a fresh screenshot408 # and re-plan; never blindly execute that action again.409 execute(a)410 time.sleep(0.5) # let the UI settle411412 if r["status"] == "done":413 # done is a model claim. Verify from pixels captured AFTER the last414 # action; also assert application/DOM state here when available.415 verify_payload = {"screenshot": screenshot_b64(), "instruction": verify_goal}416 verified = predict_with_retry(f"verify-{sid}-{uuid.uuid4().hex}", verify_payload)417 mutating = [418 a for a in verified["actions"]419 if a["action_type"] not in ("done", "fail")420 ]421 if mutating or verified["status"] != "done":422 raise RuntimeError(f"completion mismatch: {verified['reasoning']}")423 completed = True424 print("finished and freshly verified")425 break426 instruction = micro_goal427 if not completed:428 raise RuntimeError("step cap reached before verified completion")429finally:430 delete_session_with_reconciliation(sid) # stop the session clock431
your desktop · mss + pyautoguia browser · Playwrighta phone · adb screencap + inputVNC / RDP · framebuffer + injected inputa Coasty VM · /v1/machines runs the loop for youEnroll a caller-operated screenshot/action driver, then use its machine ID anywhere a managed VM is accepted: Tasks, Workflow task steps, schedules, and direct Machine actions. Coasty plans the work; your driver performs only fenced, typed commands.
Read the driver protocolPOST /v1/machines/externalUse the owner key plus Idempotency-Key; store the one-machine device token immediately.
observations → commandsUpload monotonic PNG/JPEG frames, heartbeat, and long-poll allowlisted commands after a durable cursor.
commands/{id}/resultsJournal before OS input, then atomically return the fenced result with its post-action screenshot.
No selectors. No DOM parsing. No brittle XPath. Just vision.
Send screenshot
Base64 PNG/JPEG + plain-language intent
AI reasons visually
Vision model identifies the target UI element
Execute actions
Typed primitives: click, type, scroll, press…
Works on any UI — web, desktop, mobile, VNC. No DOM access, no selectors, no agents.
Multi-step trajectories. The model remembers what it tried, what worked, and what's next.
v5 (default) is latency-first for long tasks; v1/v3/v4 stay available on every tier — pick per request with cua_version.
Browser tabs, desktop apps, mobile emulators, VNC feeds — anything you can capture visually.
click, type_text, key_press, key_combo, scroll, drag, move, wait, done, fail.
Allow or block actions and keys, prevent window close, cap action counts, and constrain coordinates after model output. Omitted by default.
Plain REST + JSON. Python, Node, Go, Ruby, PHP, Java, C#, or cURL from your terminal.
Billed to your API wallet — 1 credit = $0.01, separate from subscription credits. Each operation documents its own charge, refund, and ambiguous-outcome rules. Management endpoints, external-machine transport, and sandbox keys are free.
POST /predict5 cr · BYOK 0POST /sessions10 cr · BYOK 0POST /sessions/{id}/predict4 cr · BYOK 0POST /ground3 cr · BYOK 0Run / workflow agent step (v3, v4, v5)5 cr · BYOK 0Run / workflow agent step (v1)8 cr · BYOK 0Machine running — Linux5 cr/hr defaultMachine running — Windows9 cr/hr defaultMachine stopped / suspended1 cr/hr defaultPOST /machines/{id}/snapshot1 cr defaultExternal machine enrollment · actions · framesFreePOST /parseFreeMachine actions · terminal · browser · filesFreeWorkflow control-flow stepsFreeSchedules create · run · webhook fireFreeScheduled execution (published default)10/min · BYOK 0GET /models, /usage, /sessionsFreeSurcharges
Managed predict and session predict can incur trajectory, HD, v1-engine, and long-prompt surcharges; managed ground can incur only its current-image HD surcharge; managed session create has none. HD is strictly larger than 1280×720. Gates, not fees: machine provisioning and managed schedule create, run-now, and webhook fires each require a $0.20 API-wallet balance by default. Managed non-Unlimited scheduled execution defaults to subscription credits at 10 cr/min (20-credit start minimum, 6 h timeout); Unlimited bypasses that meter and uses its token throttle. BYOK inference across predict, ground, sessions, runs, workflow tasks, and schedules debits zero Coasty platform credits; BYOK schedules bypass both wallet gates and the consumer-credit runtime meter. The selected provider account bills actual tokens when Coasty sends a provider request. Under test auth, direct BYOK on predict, ground, and session create requires an explicit X-LLM-Api-Key; session create fixes it for inherited predicts without inference, and stored live keys are never read. Managed-mode Tasks, Workflows, and schedules stay deterministic sandbox; BYOK intent on those async endpoints returns 422 LLM_PROVIDER_UNSUPPORTED before execution. Operator-effective pricing and webhook policy are reported at GET /v1/models under pricing.schedules; use them for budget and signing guards. Machines auto-stop (never destroyed) if the wallet empties. Test keys (sk-coasty-test-*) bill 0 everywhere. Keep real provider secrets out of CI. Live machine rates: GET /v1/machines/pricing.
The CUA API gives your code the ability to see and interact with any screen. Send a screenshot and a natural language instruction — receive structured mouse clicks, keyboard inputs, and scroll commands with exact coordinates.
Send your key as an X-API-Key header or Authorization: Bearer. Sign up to create API keys. Direct metered API calls debit your prepaid developer API wallet (1 credit = 1¢ = $0.01) — separate from your Coasty app subscription, even on Unlimited. Scheduled runtime is the explicit exception and uses subscription credits. Metered calls reserve funds before execution; conclusive recoverable failures submit a refund, while documented outcome-unknown operations are not blindly refunded.
X-API-Key: sk-coasty-live-your_key_here
# or, equivalently:
Authorization: Bearer sk-coasty-live-your_key_here"Bearer " prefix into an X-API-Key value.X-Credits-Charged + X-Credits-Remaining; the body usage has credits_charged + cost_cents (both 0 on test keys).sk-coasty-test-) never bill Coasty and use mock VMs; sandbox-supported operations keep their production wire shapes, while live-only surfaces such as stored BYOK-key mutation remain unavailable.X-LLM-Api-Key and can call your provider only on predict, ground, and inherited session predict; test auth never reads a stored live key. Managed-mode Tasks, Workflows, and schedules remain deterministic sandbox, while BYOK intent on them returns 422 LLM_PROVIDER_UNSUPPORTED before execution. Keep provider secrets out of CI.Idempotency-Key to deduplicate the top-level request/resource; it is not a transaction around later OS actions.Choose your language. The predict endpoint is the core of the API — everything else builds on it.
pip install requestsimport requests, base64
API_KEY = "sk-coasty-live-..."
img = base64.b64encode(open("screen.png", "rb").read()).decode()
r = requests.post(
"https://coasty.ai/v1/predict",
headers={"X-API-Key": API_KEY},
json={
"screenshot": img,
"instruction": "Click the search bar and type 'hello'",
},
)
for action in r.json()["actions"]:
print(action["action_type"], action["params"])# Guarded session skeleton. See /docs#sessions for retry + verification details.
import os, time, uuid, requests
API_KEY = os.environ["COASTY_API_KEY"]
# Create one session per flow run; action_policy is server-enforced.
# Keep this key + exact body for any retry.
create_key = f"session-create-{uuid.uuid4().hex}"
session_body = {
"cua_version": "v5", "screen_width": 1920, "screen_height": 1080,
"action_policy": {"blocked_keys": ["escape"], "block_window_close": True, "max_actions": 5},
}
s = requests.post(
"https://coasty.ai/v1/sessions",
headers={"X-API-Key": API_KEY, "Idempotency-Key": create_key},
json=session_body,
).json()
session_id = s["session_id"]
micro_goal = "Complete the current form. Expected screen: success is visible. [done] when success is visible."
instruction = micro_goal
rejections = 0
completed = False
# Send only the current micro-goal; never blindly trust or execute terminal output.
try:
for step in range(20):
r = requests.post(
f"https://coasty.ai/v1/sessions/{session_id}/predict",
headers={"X-API-Key": API_KEY, "Idempotency-Key": f"step-{step}-{uuid.uuid4().hex}"},
json={"screenshot": capture_screenshot(), "instruction": instruction},
).json()
if any(a["action_type"] == "fail" and a.get("params", {}).get("code") == "ACTION_POLICY_VIOLATION" for a in r["actions"]):
rejections += 1
if rejections >= 3:
raise RuntimeError("three prohibited proposals; human review required")
# Execute nothing. The next loop captures fresh pixels and sends the
# rejection to the same session under a new Idempotency-Key.
instruction = "CONTROL FEEDBACK: the previous prohibited action was rejected and NOT executed. The next screenshot is authoritative. Continue without Escape or closing a window."
continue
rejections = 0
if r["status"] == "fail":
raise RuntimeError(r.get("reasoning", "agent failed"))
inspect_batch_then_execute_allowed(r["actions"]) # pre-scan all, then act
time.sleep(0.5) # replace with a bounded DOM/app-state wait when available
if r["status"] == "done":
assert_completion_state(capture_screenshot()) # pixels captured AFTER actions
completed = True
break
instruction = micro_goal
if not completed:
raise RuntimeError("step cap reached before verified completion")
finally:
delete_session_with_reconciliation(session_id) # bounded DELETE + authoritative GETEvery prediction returns structured actions with exact coordinates, a status signal, and token usage.
{
"request_id": "req_abc123",
"actions": [
{
"action_type": "click",
"params": { "x": 512, "y": 340, "button": "left", "clicks": 1 }
},
{
"action_type": "type_text",
"params": { "text": "hello world" }
}
],
"reasoning": "I see a search bar at (512, 340)...",
"status": "continue",
"usage": {
"input_tokens": 1523,
"output_tokens": 245,
"credits_charged": 5,
"cost_cents": 5
}
}Billed responses also carry X-Credits-Charged + X-Credits-Remaining headers. On a sk-coasty-test- key, credits_charged and cost_cents are both 0.
clickMouse click at (x, y)type_textType a stringkey_pressPress a key (enter, tab...)key_comboCombo (ctrl+c, cmd+v...)scrollScroll at a positiondragDrag between two pointsmoveMove cursorwaitPause executiondoneTask completedfailTask impossibleAdd action_policy to enforce controls after model output, before anything is returned or dispatched. A violation rejects the complete proposed batch.
"action_policy": {
"allowed_actions": ["click", "type_text", "key_combo"],
"blocked_actions": ["close_window"],
"blocked_keys": ["escape"],
"block_window_close": true,
"max_actions": 40,
"coordinate_bounds": { "min_x": 0, "min_y": 100, "max_x": 1280, "max_y": 720 }
}Only screenshot and instruction are required.
screenshotstringrequiredinstructionstringrequiredcua_version"v5" (default) | "v4" | "v3" | "v1" (+3 cr)screen_widthintscreen_heightintmax_actionsint (1-10)trajectoryarraysystem_promptstringtoolsstring[]action_policyobjectStateless prediction, sessions, and grounding utilities. All require the X-API-Key header.
/v1/predict5 cr/v1/sessions10 cr/v1/sessions/{id}/predict4 cr/v1/sessions/{id}/resetFree/v1/sessions/{id}Free/v1/ground3 cr/v1/parseFree/v1/modelsFree/v1/usageFree/v1/sessionsFree+2 cr ($0.02)+1 cr ($0.01)+3 cr ($0.03)+1 cr ($0.01)predict, ground and sessions are screen-agnostic: a screenshot goes in, coordinates and actions come out. The pixels can come from anywhere — your own desktop, a browser, a phone emulator, a VNC frame. Coasty VMs are one execution target, not the only one.
Coordinates & scaling — read this first
Coordinates come back in the SAME space as the screenshot you sent. If you downscale (e.g. a 2560x1440 desktop resized to 1280x720 to save a credit), multiply returned x/y by your scale factor before clicking — and pass the DOWNSCALED size as screen_width/height. Sending full resolution with the real width/height also works (coordinates map 1:1) and costs +1 credit above 1280x720. Mismatched screenshot vs screen_width/height is the number-one cause of "it clicks the wrong place".
Safety on a real desktop
You are giving a model control of a real mouse and keyboard. Keep pyautogui.FAILSAFE on (mouse to a corner aborts) and run with a step cap. An Idempotency-Key deduplicates the prediction and billing when the same request body is retried; it does NOT make local mouse, keyboard, browser, or OS inputs exactly once. Before each input, durably journal the request key plus action index as PREPARED, then mark it COMPLETED. After a crash, never repeat an ambiguous PREPARED action: capture a fresh screenshot, verify state, and re-plan. Use the 'Cautious (non-destructive)' preset when the screen can reach anything irreversible. Send an action_policy for server-side fail-closed limits, but also validate every structured action in your executor. Prompt prohibitions are advisory, not enforcement. Treat screenshots and on-screen text as untrusted model input; policy cannot decide whether an otherwise valid click is semantically safe. If a prohibited key such as Escape is proposed, do not execute it; capture a fresh screenshot and send explicit rejection feedback to the same session with a new Idempotency-Key. For clipboard paste, preload the clipboard and execute one key_combo chord [ctrl, v]; type_text types literal supplied text and never reads the clipboard.
Screenshot → predict → reject unsafe batches → execute allowed inputs → observe fresh pixels → verify. Sessions keep history and Idempotency-Key deduplicates inference/billing retries; your driver must journal local inputs and reconcile ambiguous actions.
# Automate YOUR screen — no VM needed. pip install requests mss pyautogui pillow
import base64, contextlib, email.utils, io, json, os, random, time, uuid, requests, mss, pyautogui
from datetime import timezone
from PIL import Image
API = "https://coasty.ai/v1"
KEY = os.environ["COASTY_API_KEY"] # use a live key for real managed inference
HDRS = {"X-API-Key": KEY, "Content-Type": "application/json"}
pyautogui.FAILSAFE = True # slam the mouse into a corner to abort instantly
REAL_W, REAL_H = pyautogui.size() # your actual desktop resolution
SEND_W, SEND_H = 1280, 720 # what we tell the model (SD = 1 credit cheaper)
SX, SY = REAL_W / SEND_W, REAL_H / SEND_H # scale model coords -> real pixels
def screenshot_b64():
with mss.mss() as sct:
shot = sct.grab(sct.monitors[1]) # primary monitor
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
img = img.resize((SEND_W, SEND_H)) # MUST match screen_width/height
buf = io.BytesIO(); img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode()
WINDOW_CLOSE_ACTIONS = {
"close_window", "window_close", "browser_close", "browser_close_tab",
"close_tab", "terminal_close",
}
KEY_ALIASES = {
"esc": "escape", "return": "enter", "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",
}
PYAUTOGUI_KEY_ALIASES = {
"cmd": "command", "meta": "command", "return": "enter",
"esc": "escape", "option": "alt",
"ctrl_l": "ctrl", "ctrl_r": "ctrl", "control_l": "ctrl", "control_r": "ctrl",
"cmd_l": "command", "cmd_r": "command", "command_l": "command", "command_r": "command",
"meta_l": "command", "meta_r": "command", "super_l": "command", "super_r": "command",
"win_l": "command", "win_r": "command", "windows_l": "command", "windows_r": "command",
"alt_l": "alt", "alt_r": "alt", "option_l": "alt", "option_r": "alt",
}
def pyautogui_key(value):
key = str(value).strip().lower()
return PYAUTOGUI_KEY_ALIASES.get(key, key)
def type_text_literal(value):
# PyAutoGUI 0.9.x cannot faithfully type arbitrary Unicode. Never silently
# drop patient names/notes: use an OS-native Unicode implementation in a
# production desktop driver, or fail visibly as this portable sample does.
if not value.isascii():
raise RuntimeError("type_text contains non-ASCII text; configure an OS-native Unicode typer")
pyautogui.write(value, interval=0.02)
@contextlib.contextmanager
def held_keys(values):
keys = [pyautogui_key(value) for value in (values or [])]
for key in keys:
pyautogui.keyDown(key)
try:
yield
finally:
for key in reversed(keys):
pyautogui.keyUp(key)
def normalized_keys(a):
p = a.get("params", {})
values = []
for field in ("key", "keys", "hold_keys", "modifiers"):
raw = p.get(field)
values.extend([raw] if isinstance(raw, str) else (raw or []))
keys = set()
for value in values:
for part in str(value).strip().lower().replace("-", "+").split("+"):
part = part.strip()
if part:
keys.add(KEY_ALIASES.get(part, part))
return keys
def prohibited(a):
keys = normalized_keys(a)
return (
"escape" in keys
or a["action_type"] in WINDOW_CLOSE_ACTIONS
or {"alt", "f4"} <= keys
or (("ctrl" in keys or "cmd" in keys) and "w" in keys)
or {"cmd", "q"} <= keys
)
def execute(a):
t, p = a["action_type"], a["params"]
if t in ("done", "fail"):
return # terminal signals have no OS effect
if t == "click":
with held_keys(p.get("hold_keys")):
pyautogui.click(p["x"] * SX, p["y"] * SY,
clicks=p.get("clicks", 1), button=p.get("button", "left"))
elif t == "move": pyautogui.moveTo(p["x"] * SX, p["y"] * SY)
elif t == "type_text": type_text_literal(p["text"])
elif t == "key_press": pyautogui.press([pyautogui_key(k) for k in p["keys"]])
elif t == "key_combo": pyautogui.hotkey(*[pyautogui_key(k) for k in p["keys"]])
elif t == "scroll":
if "x" in p: pyautogui.moveTo(p["x"] * SX, p["y"] * SY)
if p.get("direction", "vertical") == "horizontal":
pyautogui.hscroll(p["clicks"]) # +right / -left
else:
pyautogui.scroll(p["clicks"]) # +up / -down
elif t == "drag":
pyautogui.moveTo(p["x1"] * SX, p["y1"] * SY)
with held_keys(p.get("hold_keys")):
pyautogui.dragTo(p["x2"] * SX, p["y2"] * SY, duration=0.4,
button=p.get("button", "left"))
elif t == "wait": time.sleep(p["seconds"])
else: raise RuntimeError(f"unsupported action: {t}")
def parse_retry_after_seconds(raw):
if raw is None:
return None
try:
return max(0.0, float(raw)) # delta-seconds
except (TypeError, ValueError):
try: # IMF-fixdate HTTP-date
dt = email.utils.parsedate_to_datetime(str(raw))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return max(0.0, dt.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return None
def retry_after_seconds(res, error):
# Header and canonical body are independent signals. Honor the largest
# valid delay so a smaller or malformed intermediary header cannot shorten
# the server's requested wait.
candidates = (
parse_retry_after_seconds(res.headers.get("Retry-After")),
parse_retry_after_seconds(error.get("retry_after")),
)
valid = [value for value in candidates if value is not None]
return max(valid) if valid else None
def reconcile_idempotency(request_key, max_wait_seconds=600, initial_delay_seconds=0):
"""Collect a lost reserve-and-replay result; never invent a fresh operation."""
url = f"{API}/idempotency/{request_key}"
deadline = time.monotonic() + max_wait_seconds
if initial_delay_seconds > max_wait_seconds:
raise RuntimeError(
f"server Retry-After exceeds foreground reconciliation budget; "
f"resume GET {url} after {initial_delay_seconds:.1f}s"
)
if initial_delay_seconds > 0:
time.sleep(initial_delay_seconds)
attempt = 0
while True:
delay = None
try:
res = requests.get(url, headers=HDRS, timeout=15)
except requests.RequestException:
res = None
if res is not None:
try:
envelope = res.json()
if not isinstance(envelope, dict):
envelope = {}
error = envelope.get("error") or {}
if not isinstance(error, dict):
error = {}
except (ValueError, AttributeError, requests.RequestException):
envelope, error = {}, {}
print({
"reconcile_status": res.status_code,
"x_request_id": res.headers.get("X-Request-Id"),
"x_coasty_request_id": res.headers.get("X-Coasty-Request-Id"),
"body_request_id": error.get("request_id") or envelope.get("request_id"),
"cf_ray": res.headers.get("CF-Ray"),
"idempotency_key": request_key,
})
if res.ok and envelope.get("status") == "completed":
original_status = envelope.get("original_status")
result = envelope.get("result")
if isinstance(original_status, int) and 200 <= original_status < 300 and isinstance(result, dict):
return result
raise RuntimeError(
f"original operation completed with HTTP {original_status}; "
f"inspect reconciled result for key={request_key}: {str(result)[:500]}"
)
if res.ok and envelope.get("status") != "processing":
raise RuntimeError(f"malformed idempotency lookup for key={request_key}")
if res.status_code == 404:
raise RuntimeError(
f"idempotency key={request_key} is unknown or expired; outcome remains "
"ambiguous. Do not start a replacement operation until resource state is checked."
)
if not res.ok and res.status_code not in (429, 502, 503, 504):
res.raise_for_status()
delay = retry_after_seconds(res, error)
remaining = deadline - time.monotonic()
jitter = random.uniform(0, min(30, 2 ** min(attempt, 5)))
delay = max(delay or 0, jitter)
if remaining <= 0 or delay > remaining:
raise RuntimeError(
f"idempotency result still pending for key={request_key}; "
f"resume GET {url} before any new operation"
)
time.sleep(delay)
attempt += 1
def post_with_retry(url, request_key, payload):
# These documented POST routes are reserve-and-replay capable: keep the
# same key and byte-equivalent body for transport/edge retries. Typed
# failures must also explicitly permit same-key replay.
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
first_failure_at = None
for attempt in range(4): # initial send + at most 3 retries
delay = None
remaining = (
120.0 if first_failure_at is None
else 120.0 - (time.monotonic() - first_failure_at)
)
if remaining <= 0:
return reconcile_idempotency(request_key)
try:
res = requests.post(
url,
headers={**HDRS, "Idempotency-Key": request_key},
data=body, # byte-identical across same-key retries
timeout=max(0.001, min(120.0, remaining)),
)
except requests.RequestException:
print(f"transport failure attempt={attempt + 1} idempotency_key={request_key}")
else:
if res.ok:
try:
result = res.json()
if isinstance(result, dict):
return result
except (ValueError, requests.RequestException):
pass
# A 2xx with a truncated/malformed body may have committed. The
# keyed lookup recovers the stored response without a new POST.
return reconcile_idempotency(request_key)
try:
envelope = res.json()
if not isinstance(envelope, dict):
envelope = {}
error = envelope.get("error", {})
if not isinstance(error, dict):
error = {}
except (ValueError, AttributeError, requests.RequestException):
envelope = {}
error = {}
ids = {
"x_request_id": res.headers.get("X-Request-Id"),
"x_coasty_request_id": res.headers.get("X-Coasty-Request-Id"),
"body_request_id": error.get("request_id") or envelope.get("request_id"),
"cf_ray": res.headers.get("CF-Ray"),
"idempotency_key": request_key,
}
print(f"retry context attempt={attempt + 1}: {ids}")
if error:
if error.get("retryable") is not True:
res.raise_for_status()
if error.get("retry_with_same_idempotency_key") is not True:
raise RuntimeError(
"Retryable response did not permit same-key replay; inspect state "
"before starting a new logical operation."
)
if error.get("code") == "IDEMPOTENCY_IN_FLIGHT":
return reconcile_idempotency(
request_key,
initial_delay_seconds=retry_after_seconds(res, error) or 0,
)
elif res.status_code not in (429, 502, 503, 504):
res.raise_for_status()
delay = retry_after_seconds(res, error)
first_failure_at = first_failure_at or time.monotonic()
delay = delay if delay is not None else random.uniform(0, min(30, 2 ** attempt))
elapsed = time.monotonic() - first_failure_at
if attempt == 3 or elapsed + delay > 120:
# Do not poll before a server-directed Retry-After. Hand the delay
# to reconciliation; if it exceeds that budget, the helper tells
# the caller exactly when and where to resume GET polling.
return reconcile_idempotency(
request_key,
initial_delay_seconds=delay,
)
time.sleep(delay)
def predict_with_retry(request_key, payload):
return post_with_retry(f"{API}/sessions/{sid}/predict", request_key, payload)
def delete_session_with_reconciliation(session_id):
url = f"{API}/sessions/{session_id}"
deadline = time.monotonic() + 120
for attempt in range(3):
server_delay = None
try:
deleted = requests.delete(url, headers=HDRS, 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()
try:
envelope = deleted.json()
if not isinstance(envelope, dict):
envelope = {}
error = envelope.get("error") or {}
if not isinstance(error, dict):
error = {}
except (ValueError, AttributeError, requests.RequestException):
error = {}
server_delay = retry_after_seconds(deleted, error)
print({
"cleanup_status": deleted.status_code,
"x_request_id": deleted.headers.get("X-Request-Id"),
"x_coasty_request_id": deleted.headers.get("X-Coasty-Request-Id"),
"cf_ray": deleted.headers.get("CF-Ray"),
})
# A lost DELETE response is ambiguous. GET is authoritative: 404 means
# cleanup completed; 200 means the slot still exists and DELETE may retry.
try:
state = requests.get(url, headers=HDRS, timeout=15)
if state.status_code == 404:
return
except requests.RequestException:
state = None
if attempt < 2:
delay = max(
server_delay or 0,
random.uniform(0, min(4, 2 ** attempt)),
)
if delay > deadline - time.monotonic():
raise RuntimeError(
f"session cleanup retry exceeds budget; honor Retry-After and resume GET {url}"
)
time.sleep(delay)
raise RuntimeError(f"session cleanup unconfirmed; reconcile GET {url}")
# A live Coasty key gives real managed inference. A test key without explicit
# BYOK provider headers returns deterministic sandbox output rather than a model.
session_payload = {
"cua_version": "v5",
"screen_width": SEND_W, "screen_height": SEND_H,
"action_policy": {
"allowed_actions": [
"click", "move", "type_text", "key_press", "key_combo",
"scroll", "drag", "wait",
],
"blocked_keys": ["escape"],
"block_window_close": True,
"max_actions": 5,
},
}
create_key = f"session-create-{uuid.uuid4().hex}"
sess = post_with_retry(f"{API}/sessions", create_key, session_payload)
sid = sess["session_id"]
micro_goal = "Goal: compute 42 * 17. Expected screen: calculator shows 714. [done] when 714 is visible."
verify_goal = "Verify only whether the calculator visibly shows 714. Return done if visible; otherwise describe the mismatch. Do not click or type."
instruction = micro_goal
completed = False
rejections = 0
try:
for step in range(25):
request_key = f"step-{sid}-{step}-{uuid.uuid4().hex[:8]}"
payload = {"screenshot": screenshot_b64(), "instruction": instruction}
r = predict_with_retry(request_key, payload)
print(f"step {r['step']}: {r['reasoning'][:80]}")
policy_failure = next((
a for a in r["actions"]
if a["action_type"] == "fail"
and a.get("params", {}).get("code") == "ACTION_POLICY_VIOLATION"
), None)
rejected = next((a for a in r["actions"] if prohibited(a)), None)
if policy_failure or rejected:
# Server policy rejects its whole batch. The client check is defense
# in depth. Execute NOTHING, observe again, and explain the rejection
# to this same session with a new key.
rejections += 1
if rejections >= 3:
raise RuntimeError(
"three prohibited-action proposals; human review required"
)
instruction = (
"CONTROL FEEDBACK: the previous prohibited action was rejected and "
"NOT executed. The next screenshot is authoritative. Continue without "
"Escape or closing any window."
)
time.sleep(0.5)
continue
rejections = 0
if r["status"] == "fail":
raise RuntimeError(f"agent failed: {r['reasoning']}")
for action_index, a in enumerate(r["actions"]):
# A production driver durably journals (request_key, action_index)
# as PREPARED before input and COMPLETED after it. On restart, a
# leftover PREPARED action is ambiguous: capture a fresh screenshot
# and re-plan; never blindly execute that action again.
execute(a)
time.sleep(0.5) # let the UI settle
if r["status"] == "done":
# done is a model claim. Verify from pixels captured AFTER the last
# action; also assert application/DOM state here when available.
verify_payload = {"screenshot": screenshot_b64(), "instruction": verify_goal}
verified = predict_with_retry(f"verify-{sid}-{uuid.uuid4().hex}", verify_payload)
mutating = [
a for a in verified["actions"]
if a["action_type"] not in ("done", "fail")
]
if mutating or verified["status"] != "done":
raise RuntimeError(f"completion mismatch: {verified['reasoning']}")
completed = True
print("finished and freshly verified")
break
instruction = micro_goal
if not completed:
raise RuntimeError("step cap reached before verified completion")
finally:
delete_session_with_reconciliation(sid) # stop the session clock
clickx, y, button?=left, clicks?=1, hold_keys?with held_keys(...): pyautogui.click(x, y, clicks=..., button=...)movex, ypyautogui.moveTo(x, y)type_textliteral text (current focus; no clipboard)ASCII-only sample; reject non-ASCII instead of dropping itkey_presskeys (1-100 sequential press/release taps)pyautogui.press([pyautogui_key(k) for k in p["keys"]])key_combokeys (2-8 held as one chord; [ctrl,v] pastes)pyautogui.hotkey(*[pyautogui_key(k) for k in p["keys"]])scrollclicks (+up / −down), direction?=vertical, x?, y?move to optional anchor; scroll(clicks) or hscroll(clicks)dragx1, y1, x2, y2, button?=left, hold_keys?hold modifiers; moveTo(x1,y1); dragTo(x2,y2,button); release modifierswaitsecondstime.sleep(p["seconds"])done—stop ordinary loop; freshly verify before accepting completionfail—agent is blocked — stop and inspect `reasoning`Best-practice steering for the `instructions` field — appended to the base agent prompt (unlike system_prompt, which replaces it). Pick the preset that matches your job, copy it, and pass it on session create or any predict call. Custom prompts require Starter or higher.
Default pick — careful clicking on real desktops where mis-clicks have consequences.
Be precise. Before clicking, confirm the target element is actually visible in the CURRENT screenshot — never click from memory of a previous screen. Click the visual center of elements, not their edges. If the element you need is not visible, scroll toward where it should be instead of guessing coordinates. If two elements look similar, prefer the one whose text matches the task exactly. After typing into a field, verify focus landed in the right field before continuing.requests.post(f"{API}/sessions", headers=HDRS, json={
"cua_version": "v5",
"screen_width": 1280, "screen_height": 720,
"instructions": PRESET, # the text above — applies to every step in the session
})instructions is additive steering on top of the tuned base prompt — start here.system_prompt fully replaces the base prompt: more power, more ways to break grounding — reach for it only when a preset plus task phrasing can't express what you need. Both draw from the same per-tier character budget (Starter 2,000 / Pro 4,000 / Enterprise 16,000; +1 credit / $0.01 per call when the prompt is strictly over 500 chars — exactly 500 is free).
Provision a sandbox or production VM, then drive it with actions, terminal commands, browser automation, or file operations. Sandbox keys (sk-coasty-test-*) return mock VMs with no billing.
machines:readlist, get, screenshotmachines:writeprovision, start, stop, terminateactions:execclick, type, scroll, browser_*terminal:execshell command executionfiles:readread, exists, listfiles:writewrite, edit, append, deletebrowser:executearbitrary JS in browsersnapshots:writecreate AMI snapshotsconnection:readfetch SSH key + VNC password20 cr ($0.20) min5 cr/hr ($0.05)9 cr/hr ($0.09)running rate1 cr/hr ($0.01)FreeFree1 cr ($0.01)never destroyedFreesk-coasty-test-* key during development — you get instant mock VMs (id mch_test_…), synthetic action results, and zero billing. The wire format matches production exactly, so you can swap to a live key and ship.Create a VM, list your fleet, and control start/stop/restart/snapshot/terminate. Set ttl_minutes for auto-destroy (extend or clear any time via PATCH). Runtime bills your API wallet per minute at a small surplus over cloud cost. Sandbox keys mock everything in-memory; live keys provision real machines.
import requests
# Provision a fresh Linux desktop VM. Sandbox keys (sk-coasty-test-*)
# return a mock machine instantly with no billing.
r = requests.post(
"https://coasty.ai/v1/machines",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "provision-bot-001", # safe to retry
},
json={
"display_name": "automation-bot",
"os_type": "linux",
"desktop_enabled": True,
},
)
machine = r.json()["machine"]
print(machine["id"], machine["status"])/v1/machines/v1/machines/{id}/v1/machines/pricing/v1/machines/{id}/v1/machines/{id}/start/v1/machines/{id}/stop/v1/machines/{id}/restart/v1/machines/{id}/snapshot/v1/machines/{id}Dispatch a single action, or chain up to 50 in one batch. Commands are validated against an explicit allowlist — typos return 422, never reach the VM.
import requests
machine_id = "..." # from provision response
r = requests.post(
f"https://coasty.ai/v1/machines/{machine_id}/actions",
headers={"X-API-Key": "sk-coasty-live-..."},
json={
"command": "click",
"parameters": {"x": 512, "y": 340},
},
)
result = r.json()
print(result["success"], result["duration_ms"], "ms")clickactions:exectypeactions:execkey_pressactions:execkey_comboactions:execscrollactions:execdragactions:execscreenshotactions:execterminal_executeterminal:execfile_readfiles:readfile_writefiles:writebrowser_navigateactions:execbrowser_clickactions:execbrowser_executebrowser:executePOST /v1/machines/{id}/actions/batch
Content-Type: application/json
X-API-Key: sk-coasty-live-...
{
"steps": [
{ "command": "browser_navigate",
"parameters": { "url": "https://example.com/login" } },
{ "command": "browser_type",
"parameters": { "selector": "#email", "text": "[email protected]" } },
{ "command": "browser_type",
"parameters": { "selector": "#password", "text": "***" } },
{ "command": "browser_click",
"parameters": { "selector": "button[type=submit]" } }
],
"stop_on_error": true
}
Returns:
{
"results": [...], // one per step
"completed_count": 4,
"failed_count": 0,
"aborted": false,
"request_id": "req_..."
}Typed convenience endpoints over /actions. Same dispatch path, ergonomic URL shapes, identical scope rules.
import requests
# Run a shell command (PowerShell on Windows, bash on Linux).
# Output is truncated VM-side to 5000 chars.
r = requests.post(
f"https://coasty.ai/v1/machines/{machine_id}/terminal",
headers={"X-API-Key": "sk-coasty-live-..."},
json={
"command": "uname -a && uptime",
"timeout_ms": 10_000,
},
)
print(r.json()["result"]["output"])/browser/{op}opennavigateclicktypedomclickablesstateinfoscrollclosescreenshotwaitlist-tabsopen-tabclose-tabswitch-tabBody: { parameters: {…}, timeout_ms? }. browser_execute NOT here — use /actions with browser:execute.
/files/{op}readexistslistlist-directorydownloadlist-downloadswriteeditappenddeletedelete-directory/terminal{ command, timeout_ms?, session_id?, cwd? }PowerShell on Windows, bash on Unix. Output capped at 5000 chars VM-side. Pass session_id to reuse a persistent shell across calls.Requires terminal:exec scope.Full reference. All require X-API-Key (or Authorization: Bearer) except /health.
/v1/machines20 cr default gate/v1/machinesFree/v1/machines/{id}Free/v1/machines/pricingFree/v1/machines/{id}Free/v1/machines/{id}Free/v1/machines/{id}/startFree/v1/machines/{id}/stopFree/v1/machines/{id}/restartFree/v1/machines/{id}/snapshot1 cr default/v1/machines/{id}/actionsFree/v1/machines/{id}/actions/batchFree/v1/machines/{id}/browser/{op}Free/v1/machines/{id}/terminalFree/v1/machines/{id}/files/{op}Free/v1/machines/{id}/screenshotFree/v1/machines/{id}/connectionFree/v1/machines/healthFreeHand Coasty a machine and a task; it drives the agent loop for you, streams lifecycle, and pauses for a human when needed. Workflows compose many runs with a versioned JSON DSL. Sandbox keys (sk-coasty-test-*) run against mock VMs with no billing.
runs:readlist, get, stream run eventsruns:writestart, cancel, resume (human takeover)workflows:readlist/get workflows + workflow runsworkflows:writecreate, update, delete, start runsX-Credits-Charged + X-Credits-Remaining headers, and the body usage object exposes credits_charged + cost_cents (both 0 on test keys). Each agent step costs 5 cr ($0.05) on v3/v4/v5 and 8 cr ($0.08) on v1, charged from your API wallet after the step completes (idempotent per step; a failed charge stops the run with WALLET_EXHAUSTED). Starting a run requires wallet balance ≥ one step's cost. Each workflow task step is itself a run with identical per-step billing; control-flow steps (assert · if · loop · parallel · retry · human_approval · succeed · fail) are Free. Total spend is capped by budget_cents (default 0 = no budget guard) and max_iterations (≤ 1000). Test-mode runs bill 0.POST /v1/runs starts a durable run and returns status 'queued'. Create and exact bounded replay responses include webhook_secret; GET/list do not. Idempotency-Key deduplicates run creation, not every later OS action. Resume only works while awaiting_human.
import requests
API_KEY = "sk-coasty-live-..."
# Start a task run. Create/exact-replay responses include webhook_secret
# when webhook_url is present; GET/list do not. Idempotency-Key deduplicates this
# top-level run creation; it does not make the run's later OS actions exactly once.
r = requests.post(
"https://coasty.ai/v1/runs",
headers={
"X-API-Key": API_KEY,
"Idempotency-Key": "invoice-run-4821",
},
json={
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Open the invoice in the browser and read the total.",
"cua_version": "v5", # any of v1/v3/v4/v5, all tiers; omit to use the v5 default
"max_steps": 40,
"on_awaiting_human": "pause", # pause | fail | cancel
"webhook_url": "https://your.app/hooks/coasty",
},
)
run = r.json()
print(run["id"], run["status"]) # ... queued
webhook_secret = run.get("webhook_secret") # create/replay response only — store it nowmachine_iduuidrequiredtaskstringrequiredcua_version"v5" (default) | "v4" | "v3" | "v1" (8 cr/step)max_stepsint (1-1000)on_awaiting_human"pause"|"fail"|"cancel"webhook_urlstring (https)Idempotency-Key (≤ 128 chars) makes a retried POST return the original run. It does not make the run's later GUI, terminal, browser, or file effects exactly once. webhook_secret is returned by create and an exact bounded Idempotency-Key replay, so store it; it is null on get/list.{
"id": "...",
"status": "queued",
"machine_id": "550e8400-...",
"task": "Open the invoice ...",
"cua_version": "v3",
"max_steps": 40,
"on_awaiting_human": "pause",
"step_count": 0,
"awaiting_human_reason": null,
"result": null,
"error": null,
"created_at": "2026-06-08T17:00:00Z",
"usage": { "credits_charged": 0, "cost_cents": 0 },
"webhook_secret": "whsec_create_response_only"
}queued → running → (awaiting_human ⇄ running) → succeeded | failed | cancelled | timed_outawaiting_human is only reached when on_awaiting_human == "pause". Terminal states are immutable. POST /v1/runs/{id}/resume is valid only while awaiting a human (otherwise 409 NOT_AWAITING_HUMAN). POST /v1/runs/{id}/cancel works at any non-terminal state.
import requests
# Cancel a run at any non-terminal state.
requests.post(f"https://coasty.ai/v1/runs/{run_id}/cancel",
headers={"X-API-Key": API_KEY})
# Resume ONLY works when status == "awaiting_human" (human takeover). Resuming
# any other state returns 409 NOT_AWAITING_HUMAN.
requests.post(
f"https://coasty.ai/v1/runs/{run_id}/resume",
headers={"X-API-Key": API_KEY},
json={"note": "Approved by ops; the total is correct."},
)GET /v1/runs?status=running&limit=20
X-API-Key: sk-coasty-live-...
# Query params:
# status = queued|running|awaiting_human|succeeded
# |failed|cancelled|timed_out (else 400 INVALID_STATUS_FILTER)
# limit = 1..200 (else 400 INVALID_LIMIT)
#
# GET /v1/runs/{id} fetches one run (404 RUN_NOT_FOUND if not yours).Follow successfully persisted lifecycle frames over SSE or receive HMAC-signed callbacks; always reconcile the Run resource for authoritative state.
import requests
# Stream lifecycle as Server-Sent Events. Reconnect with Last-Event-ID to
# replay every successfully persisted frame after the last seq you saw.
# Intermediate appends are best-effort; GET the run for authoritative state.
with requests.get(
f"https://coasty.ai/v1/runs/{run_id}/events",
headers={"X-API-Key": API_KEY, "Last-Event-ID": "42"},
stream=True,
) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip()) # status / step / awaiting_human / terminalGET /v1/runs/{id}/eventsServer-Sent Events. With curl, pass -N to disable buffering so frames arrive live.
Each persisted frame has an id: (monotonic seq). Reconnect with Last-Event-ID: <seq> (or ?after=<seq>) for ordered at-least-once replay after that cursor. Intermediate appends are best-effort and can be absent; terminal Agent Run status/done frames are durable. Read the Run resource for authoritative state.
Event names: status · step · awaiting_human · billing · terminal.
Pass webhook_url on create (HTTPS only). Terminal Agent Run callbacks are durably queued for at most three durably recorded delivery attempts; a crash after send but before acknowledgement can add duplicate physical sends, so dedupe the stable delivery id. run.awaiting_human is best-effort and can be missed.
Verify with the per-run webhook_secret (create/exact-replay response only; absent from GET/list):
Coasty-Signature: t=<ts>,v1=<hex>
v1 = HMAC-SHA256(secret, "<t>.<body>")Events: run.awaiting_human · run.succeeded · run.failed · run.cancelled · run.timed_out.
A versioned JSON DSL composing many runs with branching, loops, parallelism, asserts, retries, and human approvals. {{var}} references pull from earlier steps' results.
import requests
# A workflow is a versioned JSON DSL composing many runs with branching,
# loops, parallelism, asserts, retries, and human approvals. {{var}} pulls
# from earlier steps' results (bound via save_as / step id).
r = requests.post(
"https://coasty.ai/v1/workflows",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "ar-collections-v1",
},
json={
"name": "AR collections",
"definition": {
"steps": [
{"id": "invoice", "type": "task", "save_as": "invoice",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Open the invoice and read its status + total."},
{"id": "check", "type": "assert",
"condition": {"op": "truthy", "value": "{{invoice.passed}}"},
"message": "Agent failed to read the invoice"},
{"id": "branch", "type": "if",
"condition": {"op": "contains",
"left": "{{invoice.result}}", "right": "PAID"},
"then": [{"id": "ok", "type": "succeed",
"output": {"state": "paid"}}],
"else": [{"id": "ask", "type": "human_approval",
"message": "Invoice unpaid — send reminder?"},
{"id": "fin", "type": "succeed",
"output": {"state": "reminded"}}]},
]
},
},
)
workflow = r.json()
print(workflow["id"], workflow["version"])taskRun an agent task. Binds result via save_as + step id.assertFail the workflow unless a condition holds.ifBranch on a condition: then / else.loopRepeat a body (count, or while a condition).parallelRun independent branches concurrently.human_approvalPause for a human to approve / reject.retryRetry a body on failure.succeedFinish successfully with optional output.failFinish as failed with a message.task steps bill — 5 cr ($0.05) on v3/v4/v5, 8 cr ($0.08) on v1. All other step types are Free.eqneltgtltegtecontainstruthyfalsyexistsandornotStructured + injection-safe (no free-text eval). and/or take conditions: [...]; not takes a single condition.
2008 levels16human_approval, succeed, and fail are not allowed inside a parallel branch.import requests
# Start a run of a SAVED workflow ...
r = requests.post(
f"https://coasty.ai/v1/workflows/{workflow_id}/runs",
headers={"X-API-Key": "sk-coasty-live-...", "Idempotency-Key": "wf-run-9"},
json={"inputs": {"invoice_url": "https://billing.example.com/inv/42"}},
)
wf_run = r.json()
print(wf_run["id"], wf_run["status"]) # ... queued
# ... or run an AD-HOC inline definition without saving it first:
requests.post(
"https://coasty.ai/v1/workflows/runs",
headers={"X-API-Key": "sk-coasty-live-..."},
json={"definition": {"steps": [
{"id": "t", "type": "task",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task": "Take a screenshot and describe the screen."}]}},
)Full reference for runs + workflows. All require X-API-Key (or Authorization: Bearer). Idempotency-Key is supported on create-run, create-workflow, and start ad-hoc/saved workflow-run; cancel, resume, approve, update, and delete do not reserve or replay keys.
/v1/runs5–8 cr/step/v1/runsFree/v1/runs/{id}Free/v1/runs/{id}/eventsFree/v1/runs/{id}/cancelFree/v1/runs/{id}/resumeFree/v1/workflowsFree/v1/workflowsFree/v1/workflows/{id}Free/v1/workflows/{id}Free/v1/workflows/{id}Free/v1/workflows/{id}/runs5–8 cr/step/v1/workflows/runs5–8 cr/step/v1/workflows/runsFree/v1/workflows/runs/{id}Free/v1/workflows/runs/{id}/eventsFree/v1/workflows/runs/{id}/cancelFree/v1/workflows/runs/{id}/resumeFreeCron-fired agent runs, one-shot run_at jobs, plus two trigger kinds (webhook, chain). Schedules created via API show up in your /schedules dashboard automatically.
schedules:readlist, get, runs, triggersschedules:writecreate, update, delete, pause, run-nowtriggers:writeadd/remove webhook, chain triggers20 cr ($0.20) default20 cr ($0.20) default10 cr/min default0 Coasty creditsFreeFreeFreeFreeGET /v1/models → pricing.schedules. A schedule run's credits_charged records consumer subscription-credit quota units, not API-wallet cents; it is 0 for test, Unlimited-bypass, and other non-billable runs and has no fixed USD conversion.Create a cron or one-shot schedule. Pause, resume, run-now, list runs, soft-delete. Idempotency-Key is supported only on create, run-now, and add-trigger — not pause, resume, update, or delete.
import requests
# Daily 9:00 AM ET email summary, fired by the Coasty scheduler.
# Published defaults per fire: >= 20 cr ($0.20) in your API wallet to dispatch (gate only),
# then 10 subscription credits/min. Read GET /v1/models pricing.schedules for effective values.
r = requests.post(
"https://coasty.ai/v1/schedules",
headers={
"X-API-Key": "sk-coasty-live-...",
"Idempotency-Key": "morning-briefing-001",
},
json={
"name": "morning briefing",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task_prompt": "Summarize unread Gmail and post the top 5 to Slack.",
"frequency": "daily",
"time": "09:00",
"timezone": "America/New_York",
},
)
schedule = r.json()
print(schedule["id"], schedule["next_run_at"])every_15_minutes*/15 * * * *every_30_minutes*/30 * * * *hourly0 * * * *every_6_hours0 */6 * * *every_12_hours0 */12 * * *daily0 9 * * * (override with `time`)weekly0 9 * * 1 (override `time`, `day_of_week`)monthly0 9 1 * * (override `time`, `day_of_month`)customsupply your own `cron` fieldPOST /v1/schedules
Content-Type: application/json
X-API-Key: sk-coasty-live-...
{
"name": "launch announcement",
"machine_id": "550e8400-e29b-41d4-a716-446655440000",
"task_prompt": "Post the launch tweet from the draft.",
"run_at": "2099-01-01T17:00:00Z"
}
# Notes:
# * `run_at` and `frequency` are mutually exclusive.
# * Must be in the future (within last 60s tolerated).
# * After firing once, the schedule auto-pauses with paused_reason='one_shot_complete'.max_consecutive_failures (default 5) failed runs. Resume via POST /v1/schedules/{id}/resume. Insufficient credits at fire-time auto-pauses with reason insufficient_credits.Two trigger kinds: webhook and schedule chain. Creation responses include the webhook secret; persist it because GET/list never return it.
# Add a webhook trigger — returns the signing secret ONCE.
r = requests.post(
f"https://coasty.ai/v1/schedules/{schedule_id}/triggers",
headers={"X-API-Key": "sk-coasty-live-..."},
json={"kind": "webhook", "rate_limit_per_minute": 60},
)
trigger = r.json()
webhook_url = trigger["webhook_url"] # https://coasty.ai/v1/triggers/webhook/whk_...
webhook_secret = trigger["webhook_secret"] # whsec_<64 hex> — STORE THIS
# Save webhook_secret in your secrets manager. Coasty encrypts it at rest.
# Exact bounded create replays return it; GET/list never do.{ kind: "webhook" }webhook_url + webhook_secret (whsec_64hex) on create or an exact bounded replay. The secret is encrypted at rest and never appears in GET/list. Sign every fire with HMAC-SHA256(secret, "{ts}.body") and send Coasty-Signature: t={ts},v1={sig}. Published defaults: 5-minute replay window and 60-second identical-body deduplication. Read effective values from GET /v1/models → pricing.schedules.{ kind: "chain" }source_schedule_id completes. Events: on_complete · on_failure · on_any.Max chain depth: 5./v1/schedules/{id}/triggers/v1/schedules/{id}/triggers/v1/schedules/{id}/triggers/{trigger_id}POST /v1/triggers/webhook/{webhook_id} — UNAUTHENTICATED but HMAC-verified. Hit by Stripe, Linear, n8n, anything that can sign a request.
# Customer-side webhook signing — produces a Coasty-Signature header
# the public /v1/triggers/webhook/{id} endpoint accepts.
import hmac, hashlib, time
def sign_coasty_webhook(secret: str, body: bytes) -> dict:
ts = int(time.time())
signed_payload = f"{ts}.".encode("utf-8") + body
sig = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return {"Coasty-Signature": f"t={ts},v1={sig}"}
# Fire the webhook from your own app:
import requests
body = b'{"event":"order.placed","order_id":"123"}'
headers = {**sign_coasty_webhook(webhook_secret, body), "Content-Type": "application/json"}
requests.post(webhook_url, data=body, headers=headers)Coasty-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>
# t = current unix timestamp (seconds)
# v1 = lowercase hex HMAC-SHA256(webhook_secret, "<t>.<body>")
# (period as separator; raw body bytes; no newline)
# published defaults below; discover effective values at GET /v1/models -> pricing.schedules
# replay = signatures > 5 min stale are rejected
# dedup = identical (webhook_id, body) within 60 s returns deduplicated=true
# body cap = 1,000,000 bytes (413 if exceeded)HTTP/1.1 200 OK
Content-Type: application/json
X-Coasty-Request-Id: req_...
X-Coasty-Webhook-Deduplicated: false
{
"received": true,
"schedule_id": "550e8400-...",
"run_id": "550e8400-...",
"deduplicated": false,
"message": "Schedule fire dispatched.",
"request_id": "req_..."
}webhook_secret like a password — it grants the ability to fire your schedule. Coasty stores it server-side and uses it to verify every inbound signature. If leaked: delete the trigger and re-create to rotate.Full reference. Public webhook fire endpoint is the only one without auth (it uses HMAC-signed Coasty-Signature instead).
/v1/schedules20 cr default/v1/schedulesFree/v1/schedules/{id}Free/v1/schedules/{id}Free/v1/schedules/{id}Free/v1/schedules/{id}/pauseFree/v1/schedules/{id}/resumeFree/v1/schedules/{id}/run20 cr default/v1/schedules/{id}/runsFree/v1/schedules/{id}/runs/{run_id}Free/v1/schedules/{id}/triggersFree/v1/schedules/{id}/triggersFree/v1/schedules/{id}/triggers/{trigger_id}Free/v1/triggers/webhook/{webhook_id}FreeDrive Coasty from any MCP-capable client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code Copilot. One install, every Coasty tool available.
MCP (Model Context Protocol) is the open standard, designed by Anthropic and adopted across the agent ecosystem, that lets LLM hosts plug into external tools and data. Coasty's MCP server is a thin wrapper over the /v1 API — same scopes, same billing. It runs locally via npx; your API key never touches a Coasty MCP relay.
npm i -g @coasty/mcpOr just point your MCP host at npx -y @coasty/mcp — zero install needed. Set COASTY_API_KEY in the host config and you're running. Sandbox keys (sk-coasty-test-*) work for free.
Pick your client. Configs are checked into the @coasty/mcp test suite — copying any of these blocks verbatim produces a working install.
// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
// Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}
// Restart Claude Desktop. Coasty tools appear under the 🛠 icon.claude mcp add coasty \
--env COASTY_API_KEY=sk-coasty-test-... \
-- npx -y @coasty/mcp
# Verify
claude mcp list
# coasty ✓ connected (26 tools, 2 prompts){
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}
// Cursor → Settings → MCP shows a green dot when reachable.{
"mcpServers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}// VS Code uses "servers" (NOT "mcpServers"). Tools only appear in
// Agent mode — type # in the chat to autocomplete tool names.
{
"servers": {
"coasty": {
"command": "npx",
"args": ["-y", "@coasty/mcp"],
"env": { "COASTY_API_KEY": "sk-coasty-test-..." }
}
}
}sk-coasty-test-* key while you're iterating — everything works (provision, schedule, run) but no real machine or credit billing. Swap in sk-coasty-live-* when you're ready to ship.26 tools: 24 authenticated operation tools across Predict, Machines, Schedules, and Account, plus 2 public discovery tools. All carry MCP annotations (readOnly / destructive / idempotent) so well-behaved hosts confirm before destructive operations.
coasty_predictScreenshot + goal → list of actionscoasty_groundElement description → (x, y) coordscoasty_parsepyautogui code → structured actions (free)coasty_list_machinesRead-only — your VMscoasty_get_machineRead-only — one VMcoasty_take_machine_screenshotRead-only — current desktop imagecoasty_provision_machineCreate new VM (idempotent w/ key)coasty_terminate_machineDestructive — irreversiblecoasty_start_machineResume a stopped VMcoasty_stop_machinePause running VM (preserves state)coasty_execute_machine_actionDispatch click / type / scroll / browser_* / file_* / etc.coasty_run_terminal_commandShell exec on VM (terminal:exec scope)coasty_list_schedulesRead-onlycoasty_get_scheduleRead-onlycoasty_list_schedule_runsCursor-paginated historycoasty_create_scheduleCron, run-once, or custom — appears in dashboardcoasty_update_schedulePATCH (any field)coasty_delete_scheduleDestructive — soft-deletecoasty_run_schedule_nowManual fire (idempotent w/ key)coasty_pause_scheduleDisable future firescoasty_resume_scheduleRe-enablecoasty_add_triggerWebhook / chain (HMAC secret in create/exact-replay response)coasty_remove_triggerDestructivecoasty_get_creditsRead-only — balance + tier + period usagecoasty_get_pricingPublic, versioned pricing snapshotcoasty_get_capabilitiesPublic API + MCP capability cataloguestart_automation_session — pre-fill a chat that picks a VM, screenshots, predicts, and executes toward a goal.debug_failed_run — investigate why a schedule has been failing; proposes concrete fixes.readOnlyHint, destructiveHint, idempotentHint, and openWorldHint so a well-configured host can auto-approve safe reads and require explicit consent for destructive ops.Every error returns the same envelope and an X-Coasty-Request-Id header. The code is stable; the message is for humans. Use code + status to branch.
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This key lacks the scope this route requires.",
"type": "forbidden",
"request_id": "req_8f2c1e9a",
"suggestion": "Re-mint the key with runs:write at /developers.",
"docs_url": "https://coasty.ai/docs#errors",
"required_scope": "runs:write", // extra context varies by code
"current_scopes": ["runs:read"]
}
}Body fields: code, message, type, request_id, suggestion, docs_url, plus code-specific context (e.g. required_scope, balance, details).
Headers: X-Coasty-Request-Id (quote it in support tickets) and Link: <docs_url>; rel="help".
Auth failures also send WWW-Authenticate: Bearer; transient errors (timeouts, upstream outages) send Retry-After.
PREDICTION_FAILED and GROUNDING_FAILED submit a refund. Only X-Credits-Refunded confirms settlement; an unconfirmed attempt returns BILLING_UNAVAILABLE.
INVALID_LIMITlimit query param out of range; must be 1..200INVALID_STATUS_FILTERUnknown ?status= value on a list endpointFEATURE_NOT_AVAILABLEThe feature is gated off for your tier or this modeINVALID_API_KEYMissing/invalid key. Send X-API-Key OR Authorization: Bearer (sends WWW-Authenticate). Don't paste "Bearer " into X-API-KeyINSUFFICIENT_CREDITSWallet below required (returns required + balance). Add funds, or use a sk-coasty-test- keyWALLET_EXHAUSTEDThe API wallet hit zero mid-requestINSUFFICIENT_SCOPEKey valid but lacks scope (returns required_scope + current_scopes). Re-mint at /developersNOT_FOUNDResource id does not exist or isn't yoursSESSION_NOT_FOUNDSession id unknown (mode-isolated; test ids never match live)RUN_NOT_FOUNDRun id unknown or not owned by your key (mode-isolated)WORKFLOW_NOT_FOUNDWorkflow id unknown or not owned by your key (mode-isolated)INVALID_STATEIllegal transition (returns current_state + allowed_from)NOT_AWAITING_HUMANResumed a run/step that wasn't paused for a humanRESUME_CONFLICTTwo resumes raced; only the first winsIDEMPOTENCY_KEY_REUSEDSame Idempotency-Key reused with a different request bodyPAYLOAD_TOO_LARGEBase64 body over the 10 MB capVALIDATION_ERRORBody failed validation; error.details = field path that failedINVALID_SCREENSHOTScreenshot is not valid raw base64 or an exact PNG/JPEG data URIINTERNAL_ERRORUnexpected server error; retry, and quote the request_id if it persistsPREDICTION_FAILEDModel run failed; a refund is submitted and X-Credits-Refunded confirms itGROUNDING_FAILEDGrounding failed; a refund is submitted and X-Credits-Refunded confirms itUPSTREAM_UNAVAILABLEA dependency is down; retry with backoffUPSTREAM_TIMEOUTUpstream timed out; reuse a key only if the original request was one of the documented 18 operations and already carried that key; otherwise inspect resource stateFree account, free keys, free credits to start. No card required.