Understanding the envelope
The one shape every job read returns, which keys each status carries, a worked example of every status, and how each SDK waits for a job.
Every read of a job returns one envelope, and which of its keys are filled in depends on status. A client switches on status and finds everything it needs in the same object, without asking a second endpoint what happened.
#When to use it
Read this page before you handle job results in code: it is the contract between Kaabist and your client. If you only need the final data, an SDK's wait helper (at the end of this page) gives you a finished or parked job and you read data, error, input_request or attention from it.
#Creating a job
POST /v1/jobs takes exactly one of three forms:
{ "program": "acme-report@production", "inputs": { "report_id": "42" }, "tags": ["customer:42"] }
{ "url": "https://shop.example.com/p/1", "extract": { "title": "h1" } }
{ "definition": { "kaab": "1", "steps": [ { "goto": "https://shop.example.com/p/1" }, { "extract": { "title": "h1" } } ] } }
The first runs a stored program by reference (name, name@3, name@production, prg_… or pv_…). The second is the shorthand: it compiles to a two-step program at request time. The third sends a whole program inline without storing it. All three are linted, run and billed the same way, and all three answer 202 with the envelope, or 200 with a finished one when you send Prefer: wait. Every form also takes session, options, policies, webhook_url, tags, dry_run and cache.
Two neighbours take the same body:
POST /v1/jobs/estimatevalidates it and answers{ credits_min, credits_max, tier_expected, cache }without creating anything.POST /v1/jobs/batchtakes oneprogram(orurl+extract) and up to 1 000items, each with its owninputsorurlandtags. It is all or nothing: if one item does not validate, or the balance does not cover the batch, no job is created.
#The envelope
{
"id": "job_…", "object": "job", "mode": "live",
"status": "succeeded", "reason": null,
"program": { "id": "prg_…", "name": "acme-report", "version": 3, "version_id": "pv_…" },
"inputs_hash": "sha256:…",
"data": {}, "raw": {}, "warnings": [],
"meta": {},
"error": null, "input_request": null, "attention": null, "progress": null,
"tags": ["customer:42"], "webhook_url": null,
"created_at": "…", "started_at": "…", "finished_at": "…", "expires_at": "…"
}
#What each status carries
status | Carries |
|---|---|
queued | meta.blocked_reason when Kaabist is holding it: insufficient_credits (the organisation is out of credits) or session_locked (another job holds its session) |
running | progress: { step, of, checkpoint } |
awaiting_input | input_request: what it needs and where to send it; progress |
needs_attention | reason and attention: why it stopped, evidence, proposals and the actions it accepts; progress |
succeeded | data, raw, warnings, meta.steps |
failed | error; and partial data when policies.strict is false and at least one extract ran |
canceled | timings and credits; nothing else |
queued and running are on their way; awaiting_input and needs_attention are parked and wait for you; succeeded, failed and canceled are final.
#meta
meta is how the run describes itself. It is present on every status, filled in as the job progresses:
| Key | Meaning |
|---|---|
tier, tier_requested | the tier the job ran at, and the one it started at |
escalations[] | every move up the ladder: { from, to, reason } |
region, final_url | where it ran, and the URL the last page ended on |
cache | hit or miss for the shared cache |
duration_ms, parked_ms | time running, and time parked (never billed) |
proxy | { mode, pool, country } the traffic used |
session | { id, name, saved } when a session was bound |
assets | at a browser tier, what asset blocking refused: { blocked_requests, blocked_bytes_estimate } |
credits | { reserved, settled, breakdown: { compute, proxy, llm, captcha } } |
steps[] | each step's path, action, status, duration_ms and healed |
snapshot, artifacts | links to the final snapshot and to files the job stored |
dry_run | true on a job that stopped before its first side effect |
resumed_from | on a resumed run: page, checkpoint or replay |
vars | the program's variables, only when policies.include_raw is true |
#warnings
warnings[] says "you got an answer, and here is what you should know about it": a null marker parsed as empty, a list shorter than expected, a locator that had to be healed, a job that moved up a tier. Each is { code, field?, raw?, step?, message?, detail?, count?, of? } from a closed set of codes. A warning about every item of a list is aggregated onto the list instead of repeated:
{ "code": "null_marker", "field": "products[].price", "count": 3, "of": 40 }
One warning is not about the job it is on. fill_rate_drop is attached to every job of a program version whose recent runs show a field that has stopped filling; the data in this job may look fine, and there is probably a proposal waiting in your review queue. See Validation and warnings.
#error
On a failed job:
{ "code": "blocked", "message": "challenge page after 3 attempts", "tier": "stealth",
"retryable": true, "step": "steps[2]", "snapshot": "/v1/jobs/job_…/snapshot", "details": {} }
code is from the closed set of error codes; retryable says whether running the same job again could succeed.
#input_request
On an awaiting_input job: { name, prompt, type, pattern, keep_alive_until, expires_at, collect, screenshot, resolve_url }. Answer it with POST /v1/jobs/{id}/input before expires_at. resolve_url is set when the step collects the value on a hosted page.
#attention
On a needs_attention job: { reason, step, detail, screenshot, snapshot, checkpoint, expires_at, proposals, resolve_url, actions }. reason is one of obstacle, drift, unknown_page, session_expired, side_effect_ambiguous and captcha, and is repeated at the top level of the envelope. actions lists what the job accepts. See Human in the loop.
#Worked example
The site at https://portal.example.com signs in with an e-mail and password, asks for a one-time code, then shows a 40-row report. A program for it, with the code collected by an await_input step, passes through most statuses. The envelopes below are trimmed to the keys that change.
Created (202):
{ "id": "job_c2", "object": "job", "status": "queued", "program": { "name": "portal-report", "version": 1 }, "created_at": "…" }
Running, on step 3 of 8, before any checkpoint:
{ "id": "job_c2", "status": "running", "progress": { "step": 3, "of": 8, "checkpoint": null } }
Parked for the code. The fifth step, steps[4], is a when block whose first step asks for the code:
{ "id": "job_c2", "status": "awaiting_input",
"progress": { "step": 5, "of": 8, "checkpoint": null },
"input_request": { "name": "otp", "prompt": "Doğrulama kodu", "type": "string", "pattern": "^[0-9]{6}$",
"keep_alive_until": "…", "expires_at": "…", "collect": "api",
"screenshot": "/v1/jobs/job_c2/screenshot?step=steps[4].do[0]", "resolve_url": null } }
You send POST /v1/jobs/job_c2/input with { "otp": "123456" }, and the job is running again in the same browser page.
Succeeded, with the report read:
{ "id": "job_c2", "status": "succeeded",
"data": { "rows": [ { "date": "…", "description": "…", "amount": { "amount": 53.5, "currency": "TRY" } } ] },
"warnings": [],
"meta": { "tier": "browser", "session": { "id": "ses_…", "name": "portal-login", "saved": true },
"credits": { "reserved": 12, "settled": 7, "breakdown": { "compute": 5, "proxy": 2, "llm": 0, "captcha": 0 } } } }
Needs attention instead, on a later run after the site moved the amount column and the healer was not confident of the new one:
{ "id": "job_c3", "status": "needs_attention", "reason": "drift",
"attention": { "reason": "drift", "step": "steps[7]", "detail": "extract.rows[].amount: 0/40 matched, landmarks ok",
"checkpoint": "logged_in", "expires_at": "…",
"proposals": [ { "id": "prp_2", "kind": "relocate", "target": "extract.rows[].amount", "confidence": 0.86 } ],
"actions": ["apply", "skip_step", "retry_from", "takeover", "abort"] } }
Failed, on a different site that kept refusing the job:
{ "id": "job_x", "status": "failed",
"error": { "code": "blocked", "message": "challenge page after 3 attempts", "tier": "stealth", "retryable": true,
"snapshot": "/v1/jobs/job_x/snapshot" },
"meta": { "credits": { "reserved": 40, "settled": 25 } } }
Canceled with POST /v1/jobs/{id}/cancel: status: "canceled", its timings and credits, and nothing else. The reservation is released minus what the job had already spent.
#Evidence
GET /v1/jobs/{id}/steps is the timeline: one row per step attempt, with timings, the tier, what the healer did, warnings and links to that step's snapshot and screenshot. GET /v1/jobs/{id}/snapshot, /screenshot and /artifacts/{name} each redirect (302) to a link that is valid for fifteen minutes. Values of secret inputs never appear in any of them.
#Waiting for a job
Prefer: wait=<seconds>, from 1 to 60, holds one request open. Beyond a minute, each SDK has a wait helper that long-polls GET /v1/jobs/{id} until the job is final, and can answer it on the way: an onInput handler for awaiting_input (its value is posted to /input) and an onAttention handler for needs_attention (its action is posted to /resume, or it leaves the job to somebody else).
// Or start it and wait separately: `jobs.wait` long-polls until the job ends.
const started = await kaab.jobs.create({
url: `${site}/p/2`,
extract: { title: "h1" },
});
const done = await kaab.jobs.wait(started.id, { timeoutMs: 120_000 });
console.log(`${done.id}: ${done.status} ${JSON.stringify(done.data)}`);
# Or start it and wait separately: jobs.wait long-polls until the job ends.
started = kaab.jobs.create(url=f"{site}/p/2", extract={"title": "h1"})
done = kaab.jobs.wait(started.id, timeout=120)
print(f"{done.id}: {done.status}")
// Or start it and wait separately: Jobs.Wait long-polls until the job ends.
body, _ = kaab.Body[client.CreateJobJSONBody](map[string]any{"url": site + "/p/2", "extract": map[string]any{"title": "h1"}})
started, err := c.Jobs.Create(ctx, body)
if err != nil {
log.Fatal(err)
}
done, err := c.Jobs.Wait(ctx, started.ID, kaab.WaitOptions{Timeout: 2 * time.Minute})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s: %s\n", done.ID, done.Status)
| TypeScript | Python | Go | CLI | |
|---|---|---|---|---|
| Call | kaab.jobs.wait(id, options) | kaab.jobs.wait(id, …), await on AsyncKaab | c.Jobs.Wait(ctx, id, kaab.WaitOptions{…}) | kaab jobs get JOB_ID --wait N |
| Time limit | timeoutMs, default 5 minutes | timeout, default 300 s | Timeout, default 5 minutes | N seconds, 1 to 60, one long-poll |
| Poll spacing | pollMs, default 1 s | poll_interval, default 1 s | PollInterval, default 1 s | not applicable |
| Answer an input | onInput(request, job) returns the value | on_input(request, job) | OnInput(ctx, job) | kaab jobs input JOB_ID NAME=VALUE |
| Decide on attention | onAttention(attention, job) returns a resume body, or "wait" | on_attention(attention, job), a dict or "wait" | OnAttention(ctx, job) returns a ResumeAction (kaab.SkipStep(), kaab.Apply(id, promote), …), or nil to wait | kaab jobs resume JOB_ID --skip-step and the other verbs |
| Out of time | throws KaabWaitTimeoutError (code: "wait_timeout", with the last job) | raises KaabWaitTimeoutError | returns *kaab.Error with code wait_timeout | prints the job as it is |
Every helper returns at once on succeeded, failed or canceled. A parked job with no handler for its state is returned as it is, because it is a state your code has to act on. Each handler is asked once per park. With the CLI, kaab jobs run … --wait exits 2 for a failed or canceled job and 3 for a parked one.
#Pitfalls
- Switching on
data. Switch onstatus.datais present onsucceeded, and onfailedonly as a partial result. - Ignoring
warningson success. A succeeded job withnull_markerorlist_shortreturned an answer you may not want; withpolicies.strict: trueany warning fails the job instead. - Treating parked as stuck. An
awaiting_inputjob fails withinput_expiredonceinput_request.expires_atpasses, and aneeds_attentionjob fails atattention.expires_at(seven days by default). Answer, resume or cancel it before then. - Long-polling forever. A wait helper with no
onInputreturns a job parked for input; loop on it only after you have answered. - Expecting
409to be a bug.409 job_not_waitingmeans the verb does not fit the job's state;409 fence_openmeans the job is inside an unconfirmed side effect and cannot be canceled or rewound past it.