Human in the loop
When a job needs a decision or a value only a person has, it parks: it saves a checkpoint, says what it needs, and waits instead of failing or guessing. You answer through the API, the panel or a hosted page, and the job carries on from where it stopped, never re-running a side effect it cannot prove did not happen.
When to use it
Plan for parking whenever a program signs in with a one-time code, places an order, or runs against a site that changes without notice. Use await_input for values you know will be needed (a code from a phone), handle needs_attention for everything Kaabist could not decide on its own, and use hosted resolve pages when the person who can answer is your own customer rather than you.
How a step runs
Every step is a precondition, an action and a postcondition. For an action on an element, Kaabist first finds the element and waits, up to the step's timeout_ms and checking four times a second, for it to be there, visible, enabled and not covered. Only then does the action run. If the step has an expect, Kaabist then waits for that too: a step is finished when its postcondition holds, not when its click returned. An expect on every navigation and every click is the difference between "the button was clicked" and "the page did what the click was for".
When a step does not work
Kaabist classifies the failure, then does exactly one thing:
| What Kaabist decides | What happens |
|---|---|
| a slow page, a dropped connection, a recycled browser | waits and tries the step again, up to three times with backoff |
| the page needs a heavier tier | takes a checkpoint and continues the job at the next tier |
| the element moved, and the new one is a confident match | uses it for the rest of this job and reports it |
| the element moved, and the match is not confident | parks needs_attention: drift, with proposals |
| the session has expired | runs the session's refresh program, or parks session_expired |
| a dialog is in the way | dismisses it, or parks needs_attention: obstacle |
| a captcha | follows policies.captcha: park, solve or fail |
the target or expect fails on the step's own page | parks needs_attention: unknown_page, naming the selector |
| anything else | the step's on_error: fail (the default), skip or park |
Two step keys change this. optional: true turns a failure into a step_skipped warning and moves on. retry: { "max": 3, "backoff_ms": 1000 } sets how many times (0 to 5) a transient failure is retried. A step marked side_effect: true can have neither.
Parking
A parked job releases its worker and holds no credits. Time parked is not billed and does not count against max_duration_ms. There are two parked states.
awaiting_input: planned
An await_input step asks for a named value:
{ "await_input": { "name": "otp", "prompt": "SMS code", "type": "string", "pattern": "^[0-9]{6}$",
"ttl": "5m", "expires": "24h", "collect": "api" } }
The job's envelope carries input_request with the name, prompt, type, pattern, a screenshot of the page, keep_alive_until and expires_at. Two clocks run:
ttl(default5m, at most15m): the browser stays open on the page. An answer inside it carries on in that page, still signed in.expires(default24h, at most7d): after the browser is released the job keeps waiting, checkpointed. An answer now reopens the checkpoint and carries on, a few seconds slower. Past it, the job fails withinput_expired.
collect: "api" means your code answers. collect: "hosted" puts a resolve_url in input_request, a page where your own end user types the value; set hosted.notify to have Kaabist e-mail it.
needs_attention: unplanned
The job could not continue without a decision. Kaabist writes a checkpoint and an attention bundle, and the envelope's reason and attention.reason say why:
| Reason | Meaning |
|---|---|
drift | a field or target moved and the healer was not confident of the replacement |
obstacle | something covers the page that the obstacle layer could not dismiss |
unknown_page | the job is somewhere it did not expect, or a target or expect failed on its own page |
session_expired | the session stopped working and could not be refreshed unattended |
side_effect_ambiguous | a side-effect step started and was never confirmed |
captcha | a captcha, with policies.captcha: "park" or a solver that could not finish |
attention carries the step, a plain detail, a screenshot and snapshot, the checkpoint it will resume from, any proposals, expires_at (seven days by default), and actions, the resume actions this park accepts. Past expires_at, the job fails with timeout.
Each park sends a webhook event, job.awaiting_input or job.needs_attention, and appears in the panel's attention inbox.
Answering an input
POST /v1/jobs/{id}/input
{ "otp": "482913" }
or { "values": { "otp": "482913" } }. The value is checked against the step's type and pattern, lands in vars.otp, and the job answers 200: running when the browser was still open, queued when it has to reopen the checkpoint. 409 job_not_waiting means the job is not waiting for input; 410 input_expired means it stopped waiting; 402 insufficient_credits means the job cannot reserve its credits again, and it stays parked. A secret-typed value never appears in any log, snapshot or response.
The five resume actions
POST /v1/jobs/{id}/resume takes exactly one of:
| Body | What it does |
|---|---|
{ "apply": "prp_…", "promote": false, "remember": "none" } | applies a proposal from attention.proposals and carries on |
{ "skip_step": true } | skips the step that parked and carries on with the next |
{ "retry_from": "logged_in" } | runs again from a checkpoint, by name or by id (attention.checkpoint) |
{ "continue": true } | carries on after a takeover, from the page the person left |
{ "abort": true } | ends the job as canceled |
The answer is 200 with the job, queued (or canceled after abort). promote and remember decide whether a fix outlives this job:
promote: falseandremember: "none"(the defaults) apply the fix to this job only.promote: truepublishes a new program version with the fix, movinglatestbut notproduction.remember: "site"records a dismissed obstacle on the site's profile, so later jobs dismiss it without asking.
Both are approvals and are recorded in the audit log. Refusals: 409 when the job is not parked, 409 fence_open for a retry_from across a side effect that ran, and 402 insufficient_credits, with the job left parked.
A resume soon after the park (the browser is held for a few minutes) carries on in the same page. Later, the run reopens the checkpoint's storage state and URL and checks that it really is back where it stopped. If the checkpoint's URL now lands somewhere else (a page behind a single sign-on hop), retry_from, apply and continue replay from the newest earlier checkpoint whose page opens, or from the start, never across a side effect that ran. Data extracted before the park stays in the result, and meta.resumed_from says how the run carried on: page, checkpoint or replay.
Hosted resolve pages
A hosted resolve page (app.kaab.ist/r/<token>) lets someone without a Kaabist account finish one parked job. It exists when a hosted await_input parks (input_request.resolve_url), or when someone in your panel shares a parked job (attention.resolve_url). The page can take the input, apply a proposed fix, open a takeover, and, if the person who shared it allowed it, abort. It never offers skip_step or retry_from, which are decisions about the program. A fix applied there affects only that job. The link is the credential: it opens one job, expires with the park, and stops working when the job finishes. See Hosted resolve pages.
Takeover
POST /v1/jobs/{id}/takeover opens a live browser on a parked job, restored from its checkpoint, and answers 201 with a live_url and a token valid for fifteen minutes. A person drives the page, then resumes with continue. What they did is recorded and becomes an insert_steps proposal on the program version; it is never inserted into your program without approval. The job stays parked until the continue. 503 live_unavailable means no browser was free; retry after Retry-After. See Taking over a job.
Checkpoints and side effects
A checkpoint step saves the page's URL, the program's variables and the browser's storage state. It is what a parked job comes back to and what retry_from rewinds to. Every park also writes a checkpoint of its own.
A step marked side_effect: true (placing an order, sending a message) runs behind a fence: Kaabist records that it is about to act, then records that it acted once the step's expect holds. If the worker stops in between, nobody can know whether the order was placed, so nobody guesses: the job parks as side_effect_ambiguous. For the same reason, no retry and no retry_from ever rewinds past a side effect that ran, and a job inside an open fence cannot be canceled (409 fence_open).
dry_run: true on a job runs until the first side-effect step, stops there, and returns succeeded with everything extracted up to that point and meta.dry_run: true.
Worked example
A login that parks for a code. The site at https://portal.example.com asks for a six-digit code after the password. The program parks on await_input with collect: "api"; the client reads input_request.prompt, gets the code, and posts it. The SDK wait helpers do this with an input handler:
await kaab.programs.push({ ...definition, name }, { publish: true });
// `password` is declared `{ "type": "secret" }` in the program: it is encrypted at rest, never
// returned, and redacted from logs, step records and snapshots.
const job = await kaab.jobs.create({
program: name,
inputs: {
base_url: `${site}`,
password: process.env.LOGIN_PASSWORD ?? "secret",
},
});
// The job parks at the code; `onInput` answers it and the wait goes on.
const done = await kaab.jobs.wait(job.id, {
timeoutMs: 300_000,
onInput: async (request) => {
console.log(`${job.id} asks for ${request.name}`);
return askForCode(request.prompt ?? request.name);
},
});
console.log(`${done.id}: ${done.status}; session saved: ${done.meta.session?.saved === true}`);
# password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
# and redacted from logs, step records and snapshots.
password = os.environ.get("LOGIN_PASSWORD", "secret")
job = kaab.jobs.create(program=name, inputs={"base_url": f"{site}", "password": password})
# The job parks at the code; on_input answers it and the wait goes on.
done = kaab.jobs.wait(job.id, timeout=300, on_input=ask_for_code)
name := fmt.Sprintf("login-2fa-example-%x", time.Now().UnixMilli())
publish := true
if _, err := c.Programs.Push(ctx, definition, kaab.PushOptions{Name: name, Publish: &publish}); err != nil {
log.Fatal(err)
}
defer func() { _ = c.Programs.Delete(ctx, name) }()
// password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
// and redacted from logs, step records and snapshots.
password := os.Getenv("LOGIN_PASSWORD")
if password == "" {
password = "secret"
}
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
"program": name,
"inputs": map[string]any{"base_url": site + "", "password": password},
})
if err != nil {
log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body)
if err != nil {
log.Fatal(err)
}
// The job parks at the code; OnInput answers it and the wait goes on.
done, err := c.Jobs.Wait(ctx, job.ID, kaab.WaitOptions{Timeout: 5 * time.Minute, OnInput: askForCode})
if err != nil {
log.Fatal(err)
}
if done.Status != "succeeded" {
log.Fatalf("job %s is %s", done.ID, done.Status)
}
fmt.Printf("%s: signed in\n", done.ID)
The job answers running and carries on in the same page, signs in, and reads the 40-row report.
The same code, from your end user. Change collect to "hosted" and the envelope carries a resolve_url. Send it to the person who has the phone; they type the code on Kaabist's page, and your client sees the job finish:
const definition = {
kaab: "1",
name,
inputs: { email: "string", password: "secret" },
options: { max_tier: "browser" },
steps: [
{ goto: `${site}/login`, expect: { visible: "input[name=email]" } },
{ fill: "input[name=email]", value: "{{inputs.email}}" },
{ fill: "input[name=password]", value: "{{inputs.password}}" },
{ click: "form button[type=submit]", expect: { visible: "input[name=code]" } },
{
await_input: {
name: "otp",
prompt: "The six-digit code we sent you",
pattern: "^[0-9]{6}$",
collect: "hosted", // a page at input_request.resolve_url collects it
hosted: { notify: "none" }, // or { notify: "email", to: "{{inputs.email}}" }
},
},
{ fill: "input[name=code]", value: "{{vars.otp}}" },
{ click: "form button[type=submit]", expect: { url: "**/dashboard" } },
{ extract: { heading: "h1" } },
],
};
await kaab.programs.create({ name, definition, publish: true });
const job = await kaab.jobs.create({
program: name,
inputs: { email: "[email protected]", password: "secret" },
});
// Without an onInput handler, wait returns as soon as the job parks.
const parked = await kaab.jobs.wait(job.id, { timeoutMs: 120_000 });
console.log(`${parked.id} is ${parked.status}: answer at ${parked.input_request?.resolve_url}`);
An order that must not be placed twice. The shop at https://checkout.example.com takes orders with a non-idempotent POST /orders. A program that fills the order form marks its submit step side_effect: true and follows it with a checkpoint. Run it with dry_run: true and the job stops before the submit, returning what it read and meta.dry_run: true, with no order placed:
const definition = {
kaab: "1",
name,
inputs: { sku: "string", qty: { type: "number", default: 1, min: 1, max: 40 } },
options: { max_tier: "browser" },
steps: [
{ goto: `${site}/orders/new`, expect: { visible: "#order-form" } },
{ fill: "input[name=sku]", value: "{{inputs.sku}}" },
{ fill: "input[name=qty]", value: "{{inputs.qty}}" },
{
// Never retried or replayed blindly: if the run is cut here, the job parks instead.
click: "#order-form button[type=submit]",
side_effect: true,
expect: { url: "**/orders/*" },
},
// Anything after this resumes from here, not from the start: the order is not placed again.
{ checkpoint: "order-placed" },
{
extract: { order: "h1", sku: "#order-sku", qty: { selector: "#order-qty", type: "integer" } },
},
],
};
await kaab.programs.create({ name, definition, publish: true });
Pitfalls
- Treating parked as failed. A client that only handles
succeededandfailedlets parked jobs expire. Handleawaiting_inputandneeds_attention, or subscribe to their webhook events. skip_stepon a step that mattered. The job carries on without it; data that step would have produced is missing. Preferapplyorretry_fromwhen a proposal or a checkpoint fits.promote: trueby reflex. It publishes a new version and moveslatest. Apply transiently, then approve the proposal in the review queue once you have looked at it.- Retrying a side effect by hand. After
side_effect_ambiguous, check on the site whether the order exists before you chooseskip_step(it ran) orretry_froman earlier checkpoint where that is allowed. - Sharing a resolve link widely. Whoever holds it can act on that job. Send it only to the person meant to answer.
Reference
Was this page helpful?
Sessions
Staying signed in across jobs: what a session stores, how jobs bind and lock it, how Kaabist checks it, what happens when it expires, and refresh programs.
How healing works
What Kaabist does when an element your program points at moves, how confident it has to be, and why a person approves every change to a program.