Skip to main content
Most warehouses, carriers and 3PLs still have one console that does something important and exposes no API for it. This guide takes one such task — mark a return as received in a 3PL’s returns console and read back the confirmation number — and runs it as a Computer Use job: an agent works the console in a sandboxed browser and hands you a summary plus the screenshots that prove what it did.
api.computer.stateset.app is the production host the platform’s MCP client defaults to, and it is still being provisioned — at the time of writing it answers 503. Everything below is the versioned v1 contract, so you can run it today against a local instance: from the repository’s dashboard/ directory, docker compose up -d brings the API up at http://localhost:8000 (see the repo README). Point CUA_URL at http://localhost:8000/api/v1 and the calls are identical.

How a job moves

A job is queued, a worker provisions an Xvfb desktop sandbox with a browser, the agent loops screenshot → decision → action until it is done, and the worker stops the sandbox. You never hold a connection to the sandbox — the job id is the handle for everything. Status runs queued → running → succeeded | failed | cancelled, with a detour through awaiting_approval whenever a policy pauses a step. GET /jobs/{job_id}/timeline shows the same lifecycle as annotated phases: job_created, sandbox_ready, one tool_call per step, then task_completed, task_failed or task_cancelled.
1

Confirm the key and its scopes

GET /auth/whoami echoes the auth context the key resolves to. A successful response proves the key is active and within rate limit; scopes tells you whether the approval step later on will be allowed. An empty list means a full-access key; an admin key can mint a narrower one with POST /keys ({"name": "rma-receiver", "scopes": ["trigger:write", "jobs:read", "jobs:write"]}) — the plaintext comes back once in that response and never again.
2

Decide which step needs a human

The console’s Mark received button is the one action in this task that changes a system of record. An approval policy makes the job pause before a matching tool call and wait for a reviewer. Policies are admin-scoped and match on tool-name patterns.
computer:* matches every action of the computer tool; without auto_approve_rules each one would pause the job. The spec names allow_safe_subset and max_per_hour as supported rule keys — their semantics are in the platform’s operator docs. Skip this step on a first run if you prefer: the job then never enters awaiting_approval.
The agent cannot tell an instruction from text it reads on a page. Give it a console account with the narrowest access that completes the task, and set max_cost_usd on every job.
3

Preview the job

Save the payload as job.json. general is the free-form built-in template: the instruction is the whole brief — URL, record id, and the exact shape of the answer you want back.
POST /jobs/preview takes that exact payload, resolves template defaults, the model alias and agent mode, and checks quota — without creating a job or consuming an idempotency key.
The response echoes agent_type_kind (builtin here), resolved_model (claude-sonnet-4-6), resolved_agent_mode (claude) and quota_ok. An agent_type_kind of unknown means /trigger would reject the template name — preview exists so a typo surfaces here rather than as a wasted run.
4

Trigger it — with an idempotency key

Same payload, different endpoint. Idempotency-Key (scoped to your tenant and /trigger) makes a retry return the existing job instead of running the task twice — a replay carries Idempotency-Replayed: true. Key it on the record; add a run counter for a deliberate rerun.
5

Watch it run

Stream events. GET /jobs/{job_id}/events is Server-Sent Events. The first frame is a snapshot, so a late subscriber still gets one event; later frames carry status changes, the sandbox id once provisioned, and running token and cost totals. It closes after a terminal status.
: keepalive comments arrive every 15 s; after the 30-minute lifetime cap you get event: timeout — reconnect, and the first frame is a fresh snapshot. GET /events/stream is the same shape across every job in the tenant.Block in a shell. POST /jobs/{job_id}/wait returns as soon as the job is terminal, or after timeout_seconds (1–60) with is_terminal: false — loop until it is true.
Poll. GET /jobs/{job_id} supports conditional GET — send the previous ETag back as If-None-Match and an unchanged job returns 304 with no body. updated_at refreshes every iteration, so a stalled timestamp is a real signal.
6

Approve the step that changes the record

When the job hits a gated action it sits in awaiting_approval until a reviewer answers. List what is pending (pending_only=true is the default); screenshot_key is the screen the agent was looking at when it asked.
Approve and the worker resumes the job; reject and it treats the call as refused, so the agent has to find another way or report that it was blocked. Both take an optional comment for the audit trail and both need jobs:write (403 otherwise — check whoami).
One other intervention exists while a job is live: POST /jobs/{job_id}/extend-budget raises max_cost_usd on a job about to trip its cap (raise-only; 409 once the job is terminal).
7

Read the result and the evidence

GET /jobs/{job_id}/result is the one-call answer: status, summary, cost and artifact pointers.
output_schema is enforced caller-side today: parse summary and validate it against the schema you sent before writing the confirmation number back to your own returns record. summary is the agent’s final text, truncated to roughly five segments of 280 characters — a small JSON object fits, a report does not. Artifacts are the evidence: every screenshot is an image artifact, every tool output and assistant message a text one; url is a short-lived presigned download, storage_key the stable pointer. Four views over the same run:
8

Shut it down

A finished job has nothing left to close — the worker stops the sandbox at a terminal status. Shutting down is about jobs still live and the history you keep.
Cancel sets a signal the in-flight worker sees at its next iteration checkpoint, so token spend stops within one step. POST /jobs/cancel-by-tag ({"tags": ["flow:rma-receive"]}) does the same for every job carrying the tag; POST /jobs/cleanup deletes terminal jobs older than older_than_days (minimum 7). Both default to dry_run: true and neither touches a live job.

The same task through the MCP server

The hosted stateset-computer-use MCP server is a thin client for exactly these endpoints, so an agent in Claude Desktop, Claude Code or your own host runs the task without writing HTTP. Configure it with STATESET_CUA_API_URL and STATESET_CUA_API_KEY as on the MCP servers page: A host prompt that drives it: “Use stateset-computer-use. Preview first, then trigger with idempotency_key rma-receive-48213, max_cost_usd 2, tags flow:rma-receive and rma:48213, and the instruction in job.json. Wait for the result with timeout_ms 600000 and give me confirmation_number from the summary.”
The hosted MCP server has no approval tool. If a policy pauses the job, wait_for_result keeps polling through awaiting_approval; a person approves in the dashboard or with POST /approvals/{approval_id}/approve as above. Keep timeout_ms generous when a gate is in play.

When it does not go to plan

The cost cap tripped: the worker cancels before the next model call once max_cost_usd is crossed, and error says so. Read /replay to see how far it got; raise the cap next run.
The agent hit something it could not resolve — a login wall, a record it could not find. The status enum in the schema gives it a legitimate way to say not_found or blocked; the transcript has the reason.

What you built

Next steps

Make it repeatable

POST /jobs/{id}/clone-to-template turns this run into a named template you trigger by agent_type.

Let the platform call you

Register a tenant webhook for job.completed and job.failed instead of polling — and verify it per the webhook security guide.

Full v1 reference

Every endpoint used here, generated from the platform’s own OpenAPI.