Computer Use APIv1

Give your code
eyes and hands.

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.

3.5s
median step latency
10
action primitives
99.9%
uptime SLA
One call. Four lines.

Built for any stack.

Pure REST. No SDK lock-in, no extra servers, no browser drivers.

POST /v1/predict
1import requests, base64
2 
3img = base64.b64encode(open("screen.png", "rb").read()).decode()
4 
5r = 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)
13 
14for a in r.json()["actions"]:
15 print(a["action_type"], a["params"])
Returns a stream of typed actions — coordinates, keystrokes, and confidence.
Machines API

Real desktops. Real shells. Real automation.

Provision a sandbox or production VM, then drive it with actions, terminal commands, browser automation, or file ops. One auth header, one API.

POST /v1/machines + POST /v1/machines/{id}/actions
1import requests
2 
3# 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"]
17 
18# 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)
Sandbox keys (sk-coasty-test-*) return a mock VM in < 50 ms — instant retries, zero cost.
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}
Schedules API

Cron, webhooks, chains.

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.

POST /v1/schedules  +  POST /triggers (webhook)  +  sign & fire
1import requests, hmac, hashlib, time
2 
3# 1. Create a daily 9 AM ET schedule
4sched = 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()
16 
17# 2. Add a webhook trigger — store the secret immediately
18trigger = 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 this
24 
25# 3. Sign + fire the webhook from any external system
26ts = 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)
Schedules created via API show up in your /schedules dashboard automatically — same user_id, same view.
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 · HMAC
Local automation

Automate any screen. Yours included.

predict, 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.

your screen → /v1 → your input events
1# Automate YOUR screen — no VM needed. pip install requests mss pyautogui pillow
2import base64, contextlib, email.utils, io, json, os, random, time, uuid, requests, mss, pyautogui
3from datetime import timezone
4from PIL import Image
5 
6API = "https://coasty.ai/v1"
7KEY = os.environ["COASTY_API_KEY"] # use a live key for real managed inference
8HDRS = {"X-API-Key": KEY, "Content-Type": "application/json"}
9pyautogui.FAILSAFE = True # slam the mouse into a corner to abort instantly
10 
11REAL_W, REAL_H = pyautogui.size() # your actual desktop resolution
12SEND_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 pixels
14 
15def screenshot_b64():
16 with mss.mss() as sct:
17 shot = sct.grab(sct.monitors[1]) # primary monitor
18 img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
19 img = img.resize((SEND_W, SEND_H)) # MUST match screen_width/height
20 buf = io.BytesIO(); img.save(buf, format="PNG")
21 return base64.b64encode(buf.getvalue()).decode()
22 
23WINDOW_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}
46 
47def pyautogui_key(value):
48 key = str(value).strip().lower()
49 return PYAUTOGUI_KEY_ALIASES.get(key, key)
50 
51def type_text_literal(value):
52 # PyAutoGUI 0.9.x cannot faithfully type arbitrary Unicode. Never silently
53 # drop patient names/notes: use an OS-native Unicode implementation in a
54 # 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)
58 
59@contextlib.contextmanager
60def 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 yield
66 finally:
67 for key in reversed(keys):
68 pyautogui.keyUp(key)
69 
70def 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 keys
83 
84def prohibited(a):
85 keys = normalized_keys(a)
86 return (
87 "escape" in keys
88 or a["action_type"] in WINDOW_CLOSE_ACTIONS
89 or {"alt", "f4"} <= keys
90 or (("ctrl" in keys or "cmd" in keys) and "w" in keys)
91 or {"cmd", "q"} <= keys
92 )
93 
94def execute(a):
95 t, p = a["action_type"], a["params"]
96 if t in ("done", "fail"):
97 return # terminal signals have no OS effect
98 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 / -left
110 else:
111 pyautogui.scroll(p["clicks"]) # +up / -down
112 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}")
119 
120def parse_retry_after_seconds(raw):
121 if raw is None:
122 return None
123 try:
124 return max(0.0, float(raw)) # delta-seconds
125 except (TypeError, ValueError):
126 try: # IMF-fixdate HTTP-date
127 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 None
133 
134def retry_after_seconds(res, error):
135 # Header and canonical body are independent signals. Honor the largest
136 # valid delay so a smaller or malformed intermediary header cannot shorten
137 # 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 None
144 
145def 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_seconds
149 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 = 0
157 while True:
158 delay = None
159 try:
160 res = requests.get(url, headers=HDRS, timeout=15)
161 except requests.RequestException:
162 res = None
163 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 result
186 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)
200 
201 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 += 1
211 
212def post_with_retry(url, request_key, payload):
213 # These documented POST routes are reserve-and-replay capable: keep the
214 # same key and byte-equivalent body for transport/edge retries. Typed
215 # failures must also explicitly permit same-key replay.
216 body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
217 first_failure_at = None
218 for attempt in range(4): # initial send + at most 3 retries
219 delay = None
220 remaining = (
221 120.0 if first_failure_at is None
222 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 retries
231 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 result
241 except (ValueError, requests.RequestException):
242 pass
243 # A 2xx with a truncated/malformed body may have committed. The
244 # 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)
280 
281 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_at
284 if attempt == 3 or elapsed + delay > 120:
285 # Do not poll before a server-directed Retry-After. Hand the delay
286 # to reconciliation; if it exceeds that budget, the helper tells
287 # 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)
293 
294def predict_with_retry(request_key, payload):
295 return post_with_retry(f"{API}/sessions/{sid}/predict", request_key, payload)
296 
297def delete_session_with_reconciliation(session_id):
298 url = f"{API}/sessions/{session_id}"
299 deadline = time.monotonic() + 120
300 for attempt in range(3):
301 server_delay = None
302 try:
303 deleted = requests.delete(url, headers=HDRS, timeout=15)
304 except requests.RequestException:
305 deleted = None
306 if deleted is not None:
307 if deleted.ok or deleted.status_code == 404:
308 return
309 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 means
328 # 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 return
333 except requests.RequestException:
334 state = None
335 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}")
346 
347# A live Coasty key gives real managed inference. A test key without explicit
348# 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"]
365 
366micro_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_goal
369completed = False
370rejections = 0
371try:
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]}")
377 
378 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 defense
386 # in depth. Execute NOTHING, observe again, and explain the rejection
387 # to this same session with a new key.
388 rejections += 1
389 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 continue
400 rejections = 0
401 if r["status"] == "fail":
402 raise RuntimeError(f"agent failed: {r['reasoning']}")
403 
404 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, a
407 # leftover PREPARED action is ambiguous: capture a fresh screenshot
408 # and re-plan; never blindly execute that action again.
409 execute(a)
410 time.sleep(0.5) # let the UI settle
411 
412 if r["status"] == "done":
413 # done is a model claim. Verify from pixels captured AFTER the last
414 # 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 = True
424 print("finished and freshly verified")
425 break
426 instruction = micro_goal
427 if not completed:
428 raise RuntimeError("step cap reached before verified completion")
429finally:
430 delete_session_with_reconciliation(sid) # stop the session clock
431 
Coordinates come back in the space of the screenshot you sent — scale before you click.
your desktop · mss + pyautoguia browser · Playwrighta phone · adb screencap + inputVNC / RDP · framebuffer + injected inputa Coasty VM · /v1/machines runs the loop for you
Hosted loop · your machine

Bring the screen. Keep the machine.

Enroll 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 protocol
01Enroll
POST /v1/machines/external

Use the owner key plus Idempotency-Key; store the one-machine device token immediately.

02Observe + pull
observations → commands

Upload monotonic PNG/JPEG frames, heartbeat, and long-poll allowlisted commands after a durable cursor.

03Commit
commands/{id}/results

Journal before OS input, then atomically return the fenced result with its post-action screenshot.

0 cr enrollment + transportone outstanding command15-minute encrypted framesnormal Task / Workflow inference pricing

Screenshot in. Actions out.

No selectors. No DOM parsing. No brittle XPath. Just vision.

01

Send screenshot

Base64 PNG/JPEG + plain-language intent

02

AI reasons visually

Vision model identifies the target UI element

03

Execute actions

Typed primitives: click, type, scroll, press…

Vision-First

Works on any UI — web, desktop, mobile, VNC. No DOM access, no selectors, no agents.

Stateful Sessions

Multi-step trajectories. The model remembers what it tried, what worked, and what's next.

Four Engines

v5 (default) is latency-first for long tasks; v1/v3/v4 stay available on every tier — pick per request with cua_version.

Any Screen

Browser tabs, desktop apps, mobile emulators, VNC feeds — anything you can capture visually.

10 Action Types

click, type_text, key_press, key_combo, scroll, drag, move, wait, done, fail.

Enforceable Action Policy

Allow or block actions and keys, prevent window close, cap action counts, and constrain coordinates after model output. Omitted by default.

Any Language

Plain REST + JSON. Python, Node, Go, Ruby, PHP, Java, C#, or cURL from your terminal.

Per-request pricing. No subscription.

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.

EndpointCost
POST /predict5 cr · BYOK 0
POST /sessions10 cr · BYOK 0
POST /sessions/{id}/predict4 cr · BYOK 0
POST /ground3 cr · BYOK 0
Run / workflow agent step (v3, v4, v5)5 cr · BYOK 0
Run / workflow agent step (v1)8 cr · BYOK 0
Machine running — Linux5 cr/hr default
Machine running — Windows9 cr/hr default
Machine stopped / suspended1 cr/hr default
POST /machines/{id}/snapshot1 cr default
External machine enrollment · actions · framesFree
POST /parseFree
Machine actions · terminal · browser · filesFree
Workflow control-flow stepsFree
Schedules create · run · webhook fireFree
Scheduled execution (published default)10/min · BYOK 0
GET /models, /usage, /sessionsFree

Surcharges

Provider-visible prior screenshot+2 cr each
HD image >1280×720+1 cr/image
V1 engine+3 cr/request
system_prompt + trimmed instructions >500 (task instruction excluded)+1 cr

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.

Computer Use API

Send a screenshot, get actions back

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.

Authentication

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.

header
X-API-Key: sk-coasty-live-your_key_here
# or, equivalently:
Authorization: Bearer sk-coasty-live-your_key_here
  • · Don't paste the literal "Bearer " prefix into an X-API-Key value.
  • · Billed success responses carry X-Credits-Charged + X-Credits-Remaining; the body usage has credits_charged + cost_cents (both 0 on test keys).
  • · 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.
  • · With a test key, direct BYOK requires an explicit 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.
  • · POST runs / machines / workflows / schedules accept an Idempotency-Key to deduplicate the top-level request/resource; it is not a transaction around later OS actions.

How it Works

1Capture a screenshot of the target screen
2Send it with a natural language instruction
3Receive structured actions (click, type, scroll...)
4Execute the actions in your environment

Quick Start

Choose your language. The predict endpoint is the core of the API — everything else builds on it.

install
pip install requests
predict — single screenshot
import 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"])
sessions — multi-step tasks
# 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 GET

Response Format

Every prediction returns structured actions with exact coordinates, a status signal, and token usage.

response
{
  "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.

Action Types

clickMouse click at (x, y)
type_textType a string
key_pressPress a key (enter, tab...)
key_comboCombo (ctrl+c, cmd+v...)
scrollScroll at a position
dragDrag between two points
moveMove cursor
waitPause execution
doneTask completed
failTask impossible

Add action_policy to enforce controls after model output, before anything is returned or dispatched. A violation rejects the complete proposed batch.

fail-closed action policy
"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 }
}

Request Options

Only screenshot and instruction are required.

screenshotstringrequired
instructionstringrequired
cua_version"v5" (default) | "v4" | "v3" | "v1" (+3 cr)
screen_widthint
screen_heightint
max_actionsint (1-10)
trajectoryarray
system_promptstring
toolsstring[]
action_policyobject

Predict Endpoints

Stateless prediction, sessions, and grounding utilities. All require the X-API-Key header.

Prediction
POST/v1/predict5 cr
POST/v1/sessions10 cr
POST/v1/sessions/{id}/predict4 cr
POST/v1/sessions/{id}/resetFree
DELETE/v1/sessions/{id}Free
Utilities
POST/v1/ground3 cr
POST/v1/parseFree
Management
GET/v1/modelsFree
GET/v1/usageFree
GET/v1/sessionsFree
Surcharges — added to the base fee
Per trajectory screenshot (each prior image sent)+2 cr ($0.02)
Per HD image — strictly larger than 1280×720 (current + trajectory)+1 cr ($0.01)
v1 engine (cua_version: "v1") — v3 / v4 add nothing+3 cr ($0.03)
system_prompt + trimmed instructions over 500 chars (task instruction excluded)+1 cr ($0.01)
1 credit = $0.01. Base fees: predict 5 cr ($0.05) · session create 10 cr ($0.10, never carries surcharges) · session predict 4 cr ($0.04) · ground 3 cr ($0.03, HD fee only — single image) · parse Free. A PREDICTION_FAILED or GROUNDING_FAILED response submits a refund; only X-Credits-Refunded confirms completion.

Automate Any Screen

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.

Anything that renders pixels is automatable
Your own desktopmss / screencapture / PowerShell — execute with pyautogui
A browser pagePlaywright or Puppeteer screenshot — execute with page.mouse / keyboard
A phone emulatoradb exec-out screencap — execute with adb input tap / text
A remote VNC / RDP frameframebuffer grab — execute by injecting input on the remote
A Coasty cloud VM/v1/machines does the whole loop for you, screenshots included

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.

The Local Agent Loop

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.

full agent loop on your screen
# 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
Executing every action type on a desktop (pyautogui)
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 it
key_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 modifiers
waitsecondstime.sleep(p["seconds"])
donestop ordinary loop; freshly verify before accepting completion
failagent is blocked — stop and inspect `reasoning`

Prompt Presets

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.

instructions — Precise UI control
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.
using a preset
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).

Machines API

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.

Scopes
machines:readlist, get, screenshot
machines:writeprovision, start, stop, terminate
actions:execclick, type, scroll, browser_*
terminal:execshell command execution
files:readread, exists, list
files:writewrite, edit, append, delete
browser:executearbitrary JS in browser
snapshots:writecreate AMI snapshots
connection:readfetch SSH key + VNC password
Pricing
Provision — published gate, no fee20 cr ($0.20) min
VM runtime — Linux, published default5 cr/hr ($0.05)
VM runtime — Windows, published default9 cr/hr ($0.09)
starting / stopping / restartingrunning rate
VM stopped or suspended (storage only)1 cr/hr ($0.01)
creating / error / terminatedFree
Auto-destroy TTL (ttl_minutes)Free
Snapshot create (published default)1 cr ($0.01)
Out of funds → VM auto-stoppednever destroyed
Sandbox (sk-coasty-test-*)Free
Published defaults are shown above. Metered per minute against your API wallet (1 cr = $0.01), rounded down — partial minutes and partial credits are never billed. All per-call machine endpoints (actions, batch, terminal, browser, files, screenshot, connection, lifecycle) are Free — you pay only the hourly runtime. Effective table and provision gate: GET /v1/machines/pricing.
TipUse a sk-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.

Provision & Lifecycle

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.

provision a vm — python
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"])
Lifecycle
GET/v1/machines
GET/v1/machines/{id}
GET/v1/machines/pricing
PATCH/v1/machines/{id}
POST/v1/machines/{id}/start
POST/v1/machines/{id}/stop
POST/v1/machines/{id}/restart
POST/v1/machines/{id}/snapshot
DELETE/v1/machines/{id}

Actions & Batches

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.

single action — python
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")
Common Commands
clickactions:exec
typeactions:exec
key_pressactions:exec
key_comboactions:exec
scrollactions:exec
dragactions:exec
screenshotactions:exec
terminal_executeterminal:exec
file_readfiles:read
file_writefiles:write
browser_navigateactions:exec
browser_clickactions:exec
browser_executebrowser:execute
batch action — request body
POST /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_..."
}

Browser, Terminal, Files

Typed convenience endpoints over /actions. Same dispatch path, ergonomic URL shapes, identical scope rules.

shell command — python
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-tab

Body: { parameters: {…}, timeout_ms? }. browser_execute NOT here — use /actions with browser:execute.

/files/{op}
Read (files:read)
readexistslistlist-directorydownloadlist-downloads
Write (files:write)
writeeditappenddeletedelete-directory
/terminal
Body: { 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.

Machines Endpoints

Full reference. All require X-API-Key (or Authorization: Bearer) except /health.

Lifecycle
POST/v1/machines20 cr default gate
GET/v1/machinesFree
GET/v1/machines/{id}Free
GET/v1/machines/pricingFree
PATCH/v1/machines/{id}Free
DELETE/v1/machines/{id}Free
POST/v1/machines/{id}/startFree
POST/v1/machines/{id}/stopFree
POST/v1/machines/{id}/restartFree
POST/v1/machines/{id}/snapshot1 cr default
Actions
POST/v1/machines/{id}/actionsFree
POST/v1/machines/{id}/actions/batchFree
POST/v1/machines/{id}/browser/{op}Free
POST/v1/machines/{id}/terminalFree
POST/v1/machines/{id}/files/{op}Free
Inspection
GET/v1/machines/{id}/screenshotFree
GET/v1/machines/{id}/connectionFree
GET/v1/machines/healthFree

Agents API

Hand 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.

Scopes
runs:readlist, get, stream run events
runs:writestart, cancel, resume (human takeover)
workflows:readlist/get workflows + workflow runs
workflows:writecreate, update, delete, start runs
All four are granted to new keys by default. Run/workflow ids are mode-isolated: a test key never sees a live id and vice versa.
Two primitives
Run
One task on one machine. Coasty runs the agent loop, streams events, and emits HMAC-signed lifecycle webhooks.
Workflow
A versioned JSON DSL that composes many runs: task · assert · if · loop · parallel · human_approval · retry · succeed · fail.
BillingBilled responses carry X-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.

Task Runs

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.

start a run — python
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 now
Request Body
machine_iduuidrequired
taskstringrequired
cua_version"v5" (default) | "v4" | "v3" | "v1" (8 cr/step)
max_stepsint (1-1000)
on_awaiting_human"pause"|"fail"|"cancel"
webhook_urlstring (https)
Header 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.
POST /v1/runs — 201 response
{
  "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"
}
Status Lifecycle
queued → running → (awaiting_human ⇄ running) → succeeded | failed | cancelled | timed_out

awaiting_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.

cancel + resume — python
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 — list (filterable)
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).

Run Events & Webhooks

Follow successfully persisted lifecycle frames over SSE or receive HMAC-signed callbacks; always reconcile the Run resource for authoritative state.

stream events (SSE) — python
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 / terminal
GET /v1/runs/{id}/events

Server-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.

Lifecycle Webhooks (HMAC)

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.

Workflows & DSL

A versioned JSON DSL composing many runs with branching, loops, parallelism, asserts, retries, and human approvals. {{var}} references pull from earlier steps' results.

create a workflow — python
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"])
Step Types
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.
Only task steps bill — 5 cr ($0.05) on v3/v4/v5, 8 cr ($0.08) on v1. All other step types are Free.
Condition Ops
eqneltgtltegtecontainstruthyfalsyexistsandornot

Structured + injection-safe (no free-text eval). and/or take conditions: [...]; not takes a single condition.

Validation Limits
Max steps200
Max nesting depth8 levels
Max parallel branches16
human_approval, succeed, and fail are not allowed inside a parallel branch.
run a workflow — python
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."}]}},
)

Agents Endpoints

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.

Task Runs
POST/v1/runs5–8 cr/step
GET/v1/runsFree
GET/v1/runs/{id}Free
GET/v1/runs/{id}/eventsFree
POST/v1/runs/{id}/cancelFree
POST/v1/runs/{id}/resumeFree
Workflows
POST/v1/workflowsFree
GET/v1/workflowsFree
GET/v1/workflows/{id}Free
PUT/v1/workflows/{id}Free
DELETE/v1/workflows/{id}Free
POST/v1/workflows/{id}/runs5–8 cr/step
POST/v1/workflows/runs5–8 cr/step
GET/v1/workflows/runsFree
GET/v1/workflows/runs/{id}Free
GET/v1/workflows/runs/{id}/eventsFree
POST/v1/workflows/runs/{id}/cancelFree
POST/v1/workflows/runs/{id}/resumeFree

Schedules API

Cron-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.

Scopes
schedules:readlist, get, runs, triggers
schedules:writecreate, update, delete, pause, run-now
triggers:writeadd/remove webhook, chain triggers
Pricing
Managed schedule create — wallet gate, no fee20 cr ($0.20) default
Managed fire / run-now — wallet gate, no fee20 cr ($0.20) default
Managed agent runtime per fire (subscription credits)10 cr/min default
BYOK schedule create / fire / runtime0 Coasty credits
Webhook fire — no routing fee (published rate limit)Free
Chain trigger (no extra cost)Free
Pause / resume / list / runsFree
Sandbox (sk-coasty-test-*)Free
Published defaults: gates check for 20 API-wallet credits ($0.20; 1 cr = $0.01) but do not charge them; non-Unlimited execution uses subscription credits at 10 credits/min, needs 20 credits to start, and is capped at 6 h; webhook fires have no routing fee and default to 60/min. Unlimited bypasses the credit meter but retains its token throttle. Read effective gates, rates, timeouts, webhook replay/dedup windows, body cap, and default rate limit from GET /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.
Frequency presets
every_15_minutes · every_30_minutes · hourly · every_6_hours · every_12_hours · daily · weekly · monthly · custom
Trigger kinds
webhook (HMAC) · chain (fire when another schedule completes; max depth 5)
Run history
Each fire records {status, trigger, duration, credits, error, executed_at}. Cursor-paginated up to 100 retained per schedule.

Create & Lifecycle

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.

create a schedule — python
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"])
Frequency Presets
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` field
one-shot — fire once at a specific UTC time
POST /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'.
LifecycleSchedules are auto-paused after 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.

Triggers

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 — python
# 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" }
Returns 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" }
Fire this schedule when source_schedule_id completes. Events: on_complete · on_failure · on_any.Max chain depth: 5.
Trigger Endpoints
GET/v1/schedules/{id}/triggers
POST/v1/schedules/{id}/triggers
DELETE/v1/schedules/{id}/triggers/{trigger_id}

Public Webhook Fire

POST /v1/triggers/webhook/{webhook_id} — UNAUTHENTICATED but HMAC-verified. Hit by Stripe, Linear, n8n, anything that can sign a request.

sign + fire a webhook — python
# 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)
header format
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)
example response
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_..."
}
SecurityTreat 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.

Schedules Endpoints

Full reference. Public webhook fire endpoint is the only one without auth (it uses HMAC-signed Coasty-Signature instead).

Lifecycle
POST/v1/schedules20 cr default
GET/v1/schedulesFree
GET/v1/schedules/{id}Free
PATCH/v1/schedules/{id}Free
DELETE/v1/schedules/{id}Free
POST/v1/schedules/{id}/pauseFree
POST/v1/schedules/{id}/resumeFree
POST/v1/schedules/{id}/run20 cr default
History
GET/v1/schedules/{id}/runsFree
GET/v1/schedules/{id}/runs/{run_id}Free
Triggers
GET/v1/schedules/{id}/triggersFree
POST/v1/schedules/{id}/triggersFree
DELETE/v1/schedules/{id}/triggers/{trigger_id}Free
POST/v1/triggers/webhook/{webhook_id}Free

MCP Server

Drive Coasty from any MCP-capable client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code Copilot. One install, every Coasty tool available.

What is MCP?

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.

Package
npm i -g @coasty/mcp

Or 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.

What you can do from your editor
Predict
Hand the agent a screenshot, get back a sequence of typed actions (click, type, scroll, ...) with exact coordinates.
Drive a VM
Provision a sandbox VM, run terminal commands, navigate a browser, edit files — all from chat.
Schedule a job
Set up a cron job, attach a webhook trigger, hand the secret to your AI to wire into Stripe / Linear / anything.

Install in your MCP host

Pick your client. Configs are checked into the @coasty/mcp test suite — copying any of these blocks verbatim produces a working install.

Claude Desktop — claude_desktop_config.json
// 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 Code (CLI)
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)
Cursor — .cursor/mcp.json (project) or ~/.cursor/mcp.json (global)
{
  "mcpServers": {
    "coasty": {
      "command": "npx",
      "args": ["-y", "@coasty/mcp"],
      "env": { "COASTY_API_KEY": "sk-coasty-test-..." }
    }
  }
}

// Cursor → Settings → MCP shows a green dot when reachable.
Windsurf — ~/.codeium/windsurf/mcp_config.json
{
  "mcpServers": {
    "coasty": {
      "command": "npx",
      "args": ["-y", "@coasty/mcp"],
      "env": { "COASTY_API_KEY": "sk-coasty-test-..." }
    }
  }
}
VS Code Copilot (Agent mode) — .vscode/mcp.json
// 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-..." }
    }
  }
}
TipUse a 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.

Tools the MCP server exposes

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.

Predict
coasty_predictScreenshot + goal → list of actions
coasty_groundElement description → (x, y) coords
coasty_parsepyautogui code → structured actions (free)
Machines
coasty_list_machinesRead-only — your VMs
coasty_get_machineRead-only — one VM
coasty_take_machine_screenshotRead-only — current desktop image
coasty_provision_machineCreate new VM (idempotent w/ key)
coasty_terminate_machineDestructive — irreversible
coasty_start_machineResume a stopped VM
coasty_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)
Schedules
coasty_list_schedulesRead-only
coasty_get_scheduleRead-only
coasty_list_schedule_runsCursor-paginated history
coasty_create_scheduleCron, run-once, or custom — appears in dashboard
coasty_update_schedulePATCH (any field)
coasty_delete_scheduleDestructive — soft-delete
coasty_run_schedule_nowManual fire (idempotent w/ key)
coasty_pause_scheduleDisable future fires
coasty_resume_scheduleRe-enable
coasty_add_triggerWebhook / chain (HMAC secret in create/exact-replay response)
coasty_remove_triggerDestructive
Account
coasty_get_creditsRead-only — balance + tier + period usage
Discovery
coasty_get_pricingPublic, versioned pricing snapshot
coasty_get_capabilitiesPublic API + MCP capability catalogue
Prompts
start_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.
Annotations
Every tool advertises readOnlyHint, destructiveHint, idempotentHint, and openWorldHint so a well-configured host can auto-approve safe reads and require explicit consent for destructive ops.

Error Handling

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 envelope — every failed request
{
  "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"]
  }
}
Always present

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.

Troubleshooting: first-week mistakes
401
Auth header wrong
Send your key as X-API-Key OR Authorization: Bearer, but never paste the literal "Bearer " prefix into the X-API-Key value.
402
Out of credits
Add funds, or develop against a sk-coasty-test- key. Test keys never bill and use mock VMs.
403
Missing scope
The key is valid but lacks the route's scope. Re-mint it with the needed scope at /developers; old keys are not upgraded in place.
422
Bad screenshot / missing field
Send raw base64 or an exact PNG/JPEG data URI with no whitespace; error.details carries the exact failing field path.
400INVALID_LIMITlimit query param out of range; must be 1..200
400INVALID_STATUS_FILTERUnknown ?status= value on a list endpoint
400FEATURE_NOT_AVAILABLEThe feature is gated off for your tier or this mode
401INVALID_API_KEYMissing/invalid key. Send X-API-Key OR Authorization: Bearer (sends WWW-Authenticate). Don't paste "Bearer " into X-API-Key
402INSUFFICIENT_CREDITSWallet below required (returns required + balance). Add funds, or use a sk-coasty-test- key
402WALLET_EXHAUSTEDThe API wallet hit zero mid-request
403INSUFFICIENT_SCOPEKey valid but lacks scope (returns required_scope + current_scopes). Re-mint at /developers
404NOT_FOUNDResource id does not exist or isn't yours
404SESSION_NOT_FOUNDSession id unknown (mode-isolated; test ids never match live)
404RUN_NOT_FOUNDRun id unknown or not owned by your key (mode-isolated)
404WORKFLOW_NOT_FOUNDWorkflow id unknown or not owned by your key (mode-isolated)
409INVALID_STATEIllegal transition (returns current_state + allowed_from)
409NOT_AWAITING_HUMANResumed a run/step that wasn't paused for a human
409RESUME_CONFLICTTwo resumes raced; only the first wins
422IDEMPOTENCY_KEY_REUSEDSame Idempotency-Key reused with a different request body
413PAYLOAD_TOO_LARGEBase64 body over the 10 MB cap
422VALIDATION_ERRORBody failed validation; error.details = field path that failed
422INVALID_SCREENSHOTScreenshot is not valid raw base64 or an exact PNG/JPEG data URI
500INTERNAL_ERRORUnexpected server error; retry, and quote the request_id if it persists
500PREDICTION_FAILEDModel run failed; a refund is submitted and X-Credits-Refunded confirms it
500GROUNDING_FAILEDGrounding failed; a refund is submitted and X-Credits-Refunded confirms it
503UPSTREAM_UNAVAILABLEA dependency is down; retry with backoff
504UPSTREAM_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 state

Ship your first click in minutes.

Free account, free keys, free credits to start. No card required.

Coasty - #1 Computer-Use AI Agent | Best for Desktop & Browser Automation