Coasty/

Developer API

Primitives

Stateful prediction loops with memory

Sessions

Sessions, at a glance

Stateful prediction loops with memory

POST /v1/sessions/{id}/predict
A session pins rules and model authority once, then receives only fresh screenshots and the next micro-goal until the flow ends.

One session = one flow run

60-second checklistWhat to do
1. CreateCreate one session for this flow run. Send short immutable rules, tools, model choice, and action policy once.
2. RequestSend a fresh screenshot plus one observable micro-goal, its expected screen, and an explicit completion condition.
3. ActValidate the structured actions, reject prohibited input, then execute the admitted batch. Never evaluate raw_code.
4. Observe and verifyCapture a fresh screenshot. Verify Save, navigation, modal transitions, irreversible operations, and completion.
5. FinishDelete the session. Start a new session before another patient, task, or run; reset only for deliberate same-config reuse.

A session keeps the trajectory — the running history of screenshots and actions — on our side, so each step only needs the latest screenshot and instruction. This produces better multi-step behaviour on long tasks and keeps your request bodies small. Create a session once, step through the task, then delete it to release your concurrency quota.

Deleting a session is execution cleanup, not a data-erasure or regulated-data retention control. It removes the active trajectory and releases the concurrency slot; idempotency/replay, metering, audit, and model-provider records follow their own retention settings. Before sending patient, financial, or other regulated data, confirm that your Coasty agreement, selected model provider, region, logging settings, and retention configuration meet your requirements. Use the applicable privacy/export/erasure controls for data-rights requests.

Put short immutable rules in instructions when you create the session. That field appends to the tuned Coasty prompt; system_prompt replaces it, so use replacement only when you intentionally own the full agent prompt. Custom prompt fields are not plan-gated. Every tier, free included, may send instructions and system_prompt, and every tier resolves to the same budget. Each field is capped at 16,000 characters by the request schema, and the two draw from one combined per-tier budget of 32,000 characters (len(system_prompt) + len(instructions)). Overshooting the combined budget is 400 INPUT_TOO_LARGE. A short, self-contained micro-goal is still the better prompt, but it is a technique, not a limitation.

Put invariant execution constraints in create-time action_policy, which is enforced by the server on every prediction in that session and is likewise not plan-gated. Prompt rules still explain intent to the model; policy is the fail-closed boundary. The Escape example below uses both, plus a client-side check as defense in depth.

Recommended micro-goal prompt

Recommended prompt shape
Immutable rules (send once in session instructions):
- Never press Escape or close the Group Note window.
- Paste only with one Ctrl+V chord after focus is visible.

Micro-goal A (send now):
Goal: focus the Group Note body.
Expected screen: the caret or focus highlight is visible in the note body.
[done] when: note-body focus is visible.

Micro-goal B (send only after A is verified):
Goal: paste the clipboard once with one Ctrl+V chord.
Expected screen: the note body visibly contains the pasted text.
[done] when: the pasted text is visible in the note body.

Every session-predict body still needs an instruction. Use one observable micro-goal per turn. While one micro-goal is unfinished, repeat only that short goal and its observable condition with the latest screenshot. After it is verified, send the subsequent micro-goal. Do not resend the whole workflow description or the immutable rules on every turn. Session-predict accepts only screenshot, instruction, and the two output flags; model, rules, tools, and action policy are fixed at session creation, and extra fields are rejected with 422 VALIDATION_ERROR.

The Python and Node tabs show the guarded lifecycle: atomic batch rejection, unique per-turn keys, a bounded rejection counter, fresh completion verification, and cleanup. The curl, Go, Ruby, and PHP tabs are compact transport sketches; their executor placeholders must apply the same guard before any real input, and their named cleanup helpers must use bounded DELETE plus GET reconciliation. The complete retry-ready desktop and browser drivers are in the Local automation examples below.
import base64, email.utils, os, random, time, uuid, requests
from datetime import timezone

BASE = "https://coasty.ai/v1"
HEADERS = {"X-API-Key": os.environ["COASTY_API_KEY"]}
MICRO_GOAL = "Open tomorrow at 3:00 PM. Expected screen: the day view shows 3:00 PM. [done] when that slot is visible."
VERIFY = "Verify only whether tomorrow's 3:00 PM slot is visible. Return done if visible; otherwise describe the mismatch. Do not click or type."

def screenshot() -> str:
    # Replace this with a capture made NOW; never reuse a pre-action image.
    with open("screen.png", "rb") as f:
        return base64.b64encode(f.read()).decode()

def prohibited(action) -> bool:
    params = action.get("params", {})
    values = []
    for field in ("key", "keys", "hold_keys", "modifiers"):
        raw = params.get(field)
        values.extend([raw] if isinstance(raw, str) else (raw or []))
    aliases = {"esc": "escape", "control": "ctrl", "ctl": "ctrl", "command": "cmd", "super": "cmd", "win": "cmd", "windows": "cmd", "meta": "cmd", "option": "alt", "ctrl_l": "ctrl", "ctrl_r": "ctrl", "control_l": "ctrl", "control_r": "ctrl", "cmd_l": "cmd", "cmd_r": "cmd", "command_l": "cmd", "command_r": "cmd", "meta_l": "cmd", "meta_r": "cmd", "super_l": "cmd", "super_r": "cmd", "win_l": "cmd", "win_r": "cmd", "windows_l": "cmd", "windows_r": "cmd", "alt_l": "alt", "alt_r": "alt", "option_l": "alt", "option_r": "alt"}
    keys = set()
    for value in values:
        for part in str(value).strip().lower().replace("-", "+").split("+"):
            part = part.strip()
            if part:
                keys.add(aliases.get(part, part))
    close_actions = {"close_window", "window_close", "browser_close", "browser_close_tab", "close_tab", "terminal_close"}
    return (
        "escape" in keys or action["action_type"] in close_actions
        or {"alt", "f4"} <= keys
        or (("ctrl" in keys or "cmd" in keys) and "w" in keys)
        or {"cmd", "q"} <= keys
    )

def policy_failure(result) -> bool:
    return any(
        a["action_type"] == "fail"
        and a.get("params", {}).get("code") == "ACTION_POLICY_VIOLATION"
        for a in result["actions"]
    )

def parse_retry_after_seconds(raw):
    if raw is None:
        return None
    try:
        return max(0.0, float(raw))
    except (TypeError, ValueError):
        try:
            value = email.utils.parsedate_to_datetime(str(raw))
            if value.tzinfo is None:
                value = value.replace(tzinfo=timezone.utc)
            return max(0.0, value.timestamp() - time.time())
        except (TypeError, ValueError, OverflowError):
            return None

def cleanup_retry_after_seconds(response):
    try:
        envelope = response.json()
        if not isinstance(envelope, dict):
            envelope = {}
        error = envelope.get("error") or {}
        if not isinstance(error, dict):
            error = {}
    except (ValueError, AttributeError, requests.RequestException):
        error = {}
    values = [
        parse_retry_after_seconds(response.headers.get("Retry-After")),
        parse_retry_after_seconds(error.get("retry_after")),
    ]
    valid = [value for value in values if value is not None]
    return max(valid) if valid else None

def close_session(session_id: str) -> None:
    url = f"{BASE}/sessions/{session_id}"
    deadline = time.monotonic() + 120
    for attempt in range(3):
        server_delay = None
        try:
            deleted = requests.delete(url, headers=HEADERS, timeout=15)
        except requests.RequestException:
            deleted = None
        if deleted is not None:
            if deleted.ok or deleted.status_code == 404:
                return
            if deleted.status_code not in (429, 502, 503, 504):
                deleted.raise_for_status()
            server_delay = cleanup_retry_after_seconds(deleted)
        # A lost DELETE response is ambiguous. GET is authoritative.
        try:
            state = requests.get(url, headers=HEADERS, timeout=15)
            if state.status_code == 404:
                return
        except requests.RequestException:
            pass
        if attempt < 2:
            delay = max(server_delay or 0, random.uniform(0, min(4, 2 ** attempt)))
            if delay > deadline - time.monotonic():
                raise RuntimeError(f"cleanup retry exceeds budget; honor Retry-After and resume GET {url}")
            time.sleep(delay)
    raise RuntimeError(f"session cleanup unconfirmed; reconcile GET {url}")

# 1. Open a session — it remembers the trajectory across steps
session_payload = {
    "screen_width": 1920,
    "screen_height": 1080,
    "action_policy": {
        "blocked_keys": ["escape"],
        "block_window_close": True,
        "max_actions": 5,
    },
}
create_key = f"session-create-{uuid.uuid4().hex}"
# If transport fails, retry only this same key + exact payload. Reconcile an
# ambiguous result at GET /v1/idempotency/{create_key} before using a new key.
created = requests.post(
    f"{BASE}/sessions",
    headers={**HEADERS, "Idempotency-Key": create_key},
    json=session_payload,
    timeout=60,
)
created.raise_for_status()
session = created.json()
session_id = session["session_id"]

# 2. Drive the task one step at a time
instruction = MICRO_GOAL
completed = False
rejections = 0
try:
    for step in range(20):  # safety cap
        response = requests.post(
            f"{BASE}/sessions/{session_id}/predict",
            headers={**HEADERS, "Idempotency-Key": f"step-{session_id}-{step}-{uuid.uuid4().hex}"},
            json={"screenshot": screenshot(), "instruction": instruction},
            timeout=60,
        )
        response.raise_for_status()
        res = response.json()

        # Server policy replaces a prohibited batch with one fail action. The
        # local scan is defense in depth. Execute none of that batch.
        if policy_failure(res) or any(prohibited(a) for a in res["actions"]):
            rejections += 1
            if rejections >= 3:
                raise RuntimeError("three prohibited proposals; human review required")
            instruction = "CONTROL FEEDBACK: the previous prohibited action was rejected and NOT executed. The next screenshot is authoritative. Continue without Escape."
            continue
        rejections = 0
        if res["status"] == "fail":
            raise RuntimeError(res.get("reasoning", "agent failed"))

        for action in res["actions"]:
            if action["action_type"] not in ("done", "fail"):
                perform(action)      # your guarded action executor
        time.sleep(0.5)              # replace with a bounded DOM/app-state wait when available

        if res["status"] == "done":
            # done is a model claim: observe AFTER the actions, then verify.
            verify_response = requests.post(
                f"{BASE}/sessions/{session_id}/predict",
                headers={**HEADERS, "Idempotency-Key": f"verify-{session_id}-{uuid.uuid4().hex}"},
                json={"screenshot": screenshot(), "instruction": VERIFY},
                timeout=60,
            )
            verify_response.raise_for_status()
            verified = verify_response.json()
            if verified["status"] != "done" or any(
                a["action_type"] not in ("done", "fail") for a in verified["actions"]
            ):
                raise RuntimeError("completion did not match the fresh screenshot")
            completed = True
            break
        instruction = MICRO_GOAL
    if not completed:
        raise RuntimeError("step cap reached before verified completion")
finally:
    # 3. Always release the session to free your concurrency quota
    close_session(session_id)

Session creation accepts max_trajectory_length from 1 through 20 (default 3). The current screenshot always occupies its own provider slot, so a prediction can include at most the newest 19 prior screenshots. The trajectory surcharge uses the exact number of prior screenshots actually sent after history compaction, not merely the configured ceiling.

EndpointPurpose
POST /v1/sessionsCreate a session. Returns a session_id with a 2-hour (7200-second) idle TTL.
POST /v1/sessions/{id}/predictPredict the next step. Body is just screenshot + instruction.
POST /v1/sessions/{id}/resetClear trajectory for explicit same-configuration, non-sensitive reuse. It preserves configuration, BYOK binding, cumulative usage, and replay history. Free.
DELETE /v1/sessions/{id}End the session and free a concurrency slot. Free.
Treat session creation as a replay-safe operation from its first send: generate one Idempotency-Key, serialize the create body once, and keep both until the result is known. After a timeout or retryable edge failure, resend only that same key with the identical method, path, body, and BYOK identity. If the response is still ambiguous, reconcile GET /v1/idempotency/{key} before creating a new logical operation. Session delete has no generic replay record; after an ambiguous delete, GET /v1/sessions/{id} is authoritative: 404 means cleanup completed, while 200 means a bounded delete retry is still needed.
Always delete a session in a finally block. Sessions count against your tier's concurrent-session limit. The 2-hour (7200-second) idle TTL is reset on each predict and reset; deleting the session releases the slot immediately. A returnedstatus: "done" does not close the session. Before a new patient, task, or run, delete and create a new session for the cleanest execution boundary. Reset is acceptable only for deliberate same-config reuse: it clears trajectory but preserves session configuration, BYOK binding, cumulative usage, and replay history. Use new Idempotency-Key values after reset.
API reference — Coasty Computer Use API | Coasty - AI Computer-Use Agent