Agent runs: task in, JSON out
Send a task in plain words, a page to start on and, if you like, a JSON Schema. A hosted Clearcote browser runs Clearcote Jet on it and you get JSON back: what happened, every step it took, and the data you asked for, checked against your schema.
Quick start
With an API key in CLEARCOTE_API_KEY (from the API keys page) and SDK 0.34.0 or newer:
from clearcote.cloud import Cloud
cloud = Cloud() # reads CLEARCOTE_API_KEY
run = cloud.runs.create(
"Find the date of the next bank holiday in England and Wales.",
url="https://www.gov.uk/",
schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"},
},
"required": ["name", "date"],
},
country="gb",
) # wait=True (the default): returns when the run has finished
print(run["status"]) # succeeded
print(run["result"]["output"]) # {'name': 'Christmas Day', 'date': '2026-12-25'}
print(run["costEur"]["total"])What runs it: Clearcote Jet
Clearcote Jet is our open-source browser agent (MIT). It works in a loop, one step at a time:
- Look. It lists the controls a person could use right now: visible, enabled, not hidden behind a pop-up, including those inside iframes. Password fields are never read.
- Decide. One request to a fast decision model picks the kind of step and the control together, with a probability for each option, usually in a few hundred milliseconds. The model only chooses from that list; it never writes code or coordinates.
- Act. It checks the page has not changed, then moves a real mouse along a curved path, clicks, and types key by key, the way a person does.
- Answer. When the task is done, the parts of the final page that answer it are turned into markdown, and, with a
schema, into your JSON.
There is no per-site code: the same loop looks at the page again after every step. Jet does not follow links that open a new tab, and it does not handle CAPTCHAs, file uploads, canvas apps or closed shadow roots. For those, use a hand-off.
Always the current Jet
Every server tracks Jet's main branch on its own. When a new version lands, the server installs it next to the current one, runs a self-test, and only switches to it when that passes; the next runs use it. A run in progress keeps the version it started with, and an update that fails leaves the previous version in place. Each result reports the version it ran in result.jet (version and commit), and so does the run's run.started event, so a change in behaviour can be traced to the exact commit.
The request
POST /api/v1/runs takes these fields, plus every option of POST /api/v1/browsers: persona, proxy and exit location, identity, profile, version, adblock, the limits, note and worker.
| Field | Type | Meaning |
|---|---|---|
task | string | Required. What to do, in plain words: 1 to 4000 characters. Line breaks and tabs are fine, other control characters are not. |
url | http(s) URL | The page the run starts on. Jet works with the controls on the page, so almost every task needs one. |
schema | object | A JSON Schema for result.output (draft-07). At most 16 KB, nested at most 20 levels, and its top-level type must be "object" or "array". See Structured output. |
secrets | object | Values the run may type without the model ever seeing them, including TOTP codes. At most 20. See secrets and 2FA. |
maxSteps | integer | 1 to 100, default 40. The most steps Jet may take before it stops with budget (the run is then failed). |
handoff | boolean | Default false. When Jet stops with needs_input or blocked, pause and hand the browser to a person. See hand-off. |
handoffTimeoutSec | integer | 60 to 3600, default 600. How long a hand-off waits for the person. |
record | boolean | Default false. Record the run as an MP4. See recordings. |
urlis where the run starts: Jet opens it itself, rather than having it loaded before you connect.keepAlive(anything butfalse) is a400: a run has no client to reconnect, and ends when Jet is done.timeoutSecdefaults to 900 for a run.maxGb,idleTimeoutSecand your balance apply as for any session.- A run is placed on a server with Jet when you create it. If none has room, or none supports what it asks for (
schema,secrets,handofforrecord), the create answers503 NO_CAPACITYand nothing is queued. The run then waits for that server to pick it up; if it does not within five minutes, it ends asexpired. - A run whose task, schema and secrets make its settings larger than 60 KB is a
400 TOO_LARGE.
The answer is a 201 with the new run. Its pricing holds the rates this run is billed at:
{
"id": "bs_…", // GET /api/v1/runs/<id>
"status": "queued",
"worker": "w_…",
"createdAt": "…",
"expiresAt": "…", // a server picks it up before this, or it expires
"pricing": { "eurPerGb": …, "eurPerHour": …, "eurPerMTok": …, "eurPerMTokTextIn": …, "eurPerMTokTextOut": … },
"limits": { "maxSeconds": 900, "idleSeconds": …, "maxBytes": … },
"engine": { "version": …, "revision": …, "pinned": false }, // the Clearcote release it runs on
"note": "…", "profile": { "name": "…", "persist": true }, // only when you set them
"warnings": []
}warnings lists what was accepted but looks wrong, for example a secret the task never uses.
Statuses
| status | Meaning |
|---|---|
queued | Created, waiting for its server to pick it up. |
running | The browser is up and Jet is working. Also while its server is not reporting: session.status is then lost. |
waiting_for_human | Paused for a hand-off (handoff: true). handoff.liveUrl is the link to send. |
succeeded | Jet finished with done and outputError is null (with a schema: the output was valid). |
failed | It ended any other way: blocked, needs_input, budget or error, an output that did not match the schema or was over 1 MB, a limit, a browser that did not start, secrets that could no longer be opened (secrets_unreadable), or no result at all. result and session.endReason say which. |
cancelled | You stopped it: DELETE /api/v1/runs/<id>, or a stop of its session. |
expired | No server picked it up in time (five minutes). Never billed. |
queued, running and waiting_for_human are the only states that change; the rest are final. Poll GET /api/v1/runs/<id> every second or two, let the SDK wait for you, or get a run.finished webhook. A run cancelled or expired before a server picked it up sends no run.finished (or session.ended): poll for those.
Waiting in the SDK
runs.create waits by default: it polls every 1.5 seconds with no time limit and returns the finished run. timeout (in seconds) caps the wait; past it the SDK raises CloudTimeoutError, whose last is the run as it last saw it, and the run carries on. wait=False (Node: { wait: false }) returns the create answer at once, and runs.wait(id) waits for it later. waiting_for_human is not finished, so a wait goes on through a hand-off: on_update (Node: onUpdate) is called with the run each time its status or hand-off changes, and without one the SDK prints the hand-off's live link to stderr.
from clearcote.cloud import Cloud, CloudTimeoutError
cloud = Cloud()
run = cloud.runs.create("Open Orders and read the latest order number",
url="https://shop.example.com/orders", handoff=True, wait=False)
print(run["id"], run["status"]) # queued
def show(r):
print(r["status"], (r.get("handoff") or {}).get("liveUrl") or "")
try:
run = cloud.runs.wait(run["id"], timeout=1800, on_update=show)
except CloudTimeoutError as e:
print("still", (e.last or {}).get("status"), "after 30 minutes; it carries on")The result
GET /api/v1/runs/<id> answers with the run. result is null until it has finished, and stays null for a run that never ran:
{
"id": "bs_…",
"status": "succeeded",
"task": "Find the date of the next bank holiday in England and Wales.",
"url": "https://www.gov.uk/",
"hasSchema": true,
"createdAt": "2026-10-02T09:14:03.120Z",
"startedAt": "2026-10-02T09:14:05.410Z",
"endedAt": "2026-10-02T09:14:21.006Z",
"result": {
"status": "done",
"detail": null,
"url": "https://www.gov.uk/bank-holidays",
"title": "UK bank holidays - GOV.UK",
"output": { "name": "Christmas Day", "date": "2026-12-25" },
"outputError": null,
"markdown": "## England and Wales\n\nThe next bank holiday in England and Wales is Christmas Day, 25 December …",
"steps": [
{ "step": 1, "kind": "click", "action": "Reject additional cookies", "text": null, "probability": 0.85,
"decideMs": 361, "atMs": 1200, "url": "https://www.gov.uk/", "ledTo": "https://www.gov.uk/", "pageChanged": true },
{ "step": 2, "kind": "fill", "action": "Search", "text": "next bank holiday", "probability": 1,
"decideMs": 284, "atMs": 3410, "url": "https://www.gov.uk/", "ledTo": "https://www.gov.uk/", "pageChanged": true },
…
],
"usage": {
"decision": { "inputTokens": 13654, "outputTokens": 120, "requests": 5 },
"text": { "inputTokens": 0, "outputTokens": 0, "requests": 0 },
"extract": { "inputTokens": 2210, "outputTokens": 26, "requests": 1 },
"estimatedUsd": 0.0006
},
"handoffs": 0,
"elapsedMs": 14800,
"jet": { "version": "0.1.0", "commit": "cd7011c…" }
},
"handoff": null, // see Hand-off
"session": { … }, // the full GET /api/v1/browsers/<id> view: traffic, usage, endReason, recording
// (its costEur is one number: the total)
"costEur": { "browser": …, "agent": …, "total": … }
}| result.status | Meaning |
|---|---|
done | Jet judged the task complete. This is the model's judgement: check what matters, for example with required fields in a schema. |
blocked | Nothing on the page could move it forward, or it scrolled 12 times in a row. |
needs_input | The task is missing a value a field needs, or a secret was asked for on a site it is not allowed on. Put the value in the task, or use a secret. |
budget | It reached maxSteps. |
error | Something broke: the browser, a model call or the runner. detail says what. |
detail: why it stopped, in words, when it did not finish.urlandtitle: the page it ended on.markdown: the parts of the final page that answer the task; for a run that did not finish, what was on screen.outputandoutputError: your JSON, or why there is none (below).usage: tokens and requests per model:decision(the step picker),text(the model that writes values to type, when used) andextract(structured output).estimatedUsdis Jet's own estimate of its model cost, not what you are billed; that iscostEur.handoffs: how many times the run was handed to a person.elapsedMs: how long Jet worked.jet: the version that ran, ornullif it is not known.- A result is at most 1 MB. A bigger one is trimmed rather than refused:
markdownfirst, then the laststeps, and last of alloutputbecomesnullwith anoutputError(so the run isfailed).
Each entry in steps:
| Field | Meaning |
|---|---|
step | 1, 2, 3, … |
kind | What it did: click, fill, select, scroll, wait. |
action | The control it used, by its label. |
text | What it typed, with secrets shown as {{name}}; null for steps that type nothing. |
probability | How sure the decision model was of this step, 0 to 1, or null. A low one marks a shaky step. |
decideMs | How long the decision took, or null. |
atMs | When the step happened, in milliseconds since the run started, or null. |
url | The page the step happened on. |
ledTo | The page address after the step, or null. |
pageChanged | Whether the page changed after the step. |
Structured output
With a schema, a separate extraction model reads the page the run ended on (the task, its address and title, and its text) and writes JSON for your schema. The JSON is validated against the schema; if it does not match, the model gets one more try with the validation error. Page text is passed to it as untrusted data, never as instructions, so a page cannot talk it into a different answer.
- Valid JSON lands in
result.output, andoutputErrorisnull. - If it still does not match,
outputisnull,outputErrorsays why, and the run isfailedeven when Jet reporteddone. - Without a
schema, there is no extraction: the answer is inmarkdownanddetail.
Keep schemas small and say what you mean. description fields are read by the model, so use them for formats (“YYYY-MM-DD”, “price in euros, a number”). Allow null for values a page may not have, so a missing price is null instead of a failed run:
{
"type": "object",
"properties": {
"plans": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"priceEur": { "type": ["number", "null"], "description": "monthly price in euros" }
},
"required": ["name", "priceEur"]
}
}
},
"required": ["plans"]
}Writing a task
- Say what done looks like. “Open the pricing page and read the cheapest plan” ends on a page with the answer; “look at their pricing” does not.
- Write the values into the task. Jet types words that are in the task: “search for blue running shoes size 44”, not “search for shoes”. Passwords and codes go in secrets, never in the task text.
- Start where the work is. Give the deepest
urlyou know rather than the home page. Each step costs time and tokens. - Sign in once. Run on a profile that is already signed in instead of logging in on every run.
- Cap it. Lower
maxStepsfor short tasks so a run that goes in circles stops early, and setmaxGb.
Steps, time and limits
maxSteps (default 40, at most 100) caps the steps; at the cap the run stops with budget. Jet also stops by itself after 12 scrolls in a row, with blocked. The session limits still apply on top: timeoutSec (900 seconds by default for a run), maxGb, idleTimeoutSec and your balance. When one of them ends the run, it is failed and session.endReason names the limit.
A run is a session
A run is a browser session with an agent attached, under the same id. GET /api/v1/browsers/<id> shows its traffic and usage, the dashboard shows it with its live view, and the event timeline lists every step as it happens. You cannot connect to a run over CDP (that is a 409): Jet is its only driver.
# one run
curl -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/runs/<id>
# your runs, newest first: { runs: [{ id, status, task, createdAt, endedAt, resultStatus, costEur }] }
curl -H "authorization: Bearer cc_live_..." "https://www.clearcotelabs.com/api/v1/runs?limit=20"
# the page before that: before=<the createdAt of the last run you got>
curl -H "authorization: Bearer cc_live_..." "https://www.clearcotelabs.com/api/v1/runs?limit=20&before=2026-10-01T08:00:00.000Z"
# cancel: answers with the run. A queued run is cancelled at once; a running one
# stops within about 15 seconds (the answer may still say running)
curl -X DELETE -H "authorization: Bearer cc_live_..." https://www.clearcotelabs.com/api/v1/runs/<id>In the list, limit is 1 to 100 (default 20) and before is an ISO date; task is cut to 200 characters, resultStatus is result.status and costEur is the same object as on the run. The SDK has the same calls: runs.get, runs.list, runs.cancel, and runs.wait for a run you created with wait=False.
Hand-off and recording
With handoff: true, a run that gets stuck (needs_input or blocked) does not fail straight away. It pauses as waiting_for_human with a live link in handoff.liveUrl; a person opens it, signs in or gets past the check, and presses “I'm done”. Jet then starts again on the same page with the same task. A run can be handed off up to three times; if nobody finishes within handoffTimeoutSec, it ends as it was. Details on Hand-off and webhooks.
With record: true you also get an MP4 of the run, which you can download or share. See Recordings.
Billing
A run is billed in two parts, from the same prepaid balance as your browsers:
- The browser, exactly like any hosted session: per GB of traffic, residential proxy included.
- The agent, per million tokens: the decision model's input tokens at
eurPerMTok(its output tokens are not billed), and the text and extraction models' input and output tokens ateurPerMTokTextInandeurPerMTokTextOut. It is charged once, when the run finishes, rounded up to a millionth of a euro.
agent = decision.inputTokens × eurPerMTok / 1,000,000
+ (text.inputTokens + extract.inputTokens) × eurPerMTokTextIn / 1,000,000
+ (text.outputTokens + extract.outputTokens) × eurPerMTokTextOut / 1,000,000The rates are taken when the run is created and do not change while it runs. The current ones are in the pricing of every create answer. A run shows what it costs in costEur from the start: browser grows as it runs, agent is added once at the end, and total is the two together. Starting a run needs the same minimum balance as starting a browser.