Concepts

Human in the loop

How a job parks for a person (awaiting_input and needs_attention) and the ways to answer it: inputs, the five resume actions, hosted pages and takeover.

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 decidesWhat happens
a slow page, a dropped connection, a recycled browserwaits and tries the step again, up to three times with backoff
the page needs a heavier tiertakes a checkpoint and continues the job at the next tier
the element moved, and the new one is a confident matchuses it for the rest of this job and reports it
the element moved, and the match is not confidentparks needs_attention: drift, with proposals
the session has expiredruns the session's refresh program, or parks session_expired
a dialog is in the waydismisses it, or parks needs_attention: obstacle
a captchafollows policies.captcha: park, solve or fail
the target or expect fails on the step's own pageparks needs_attention: unknown_page, naming the selector
anything elsethe 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 (default 5m, at most 15m): the browser stays open on the page. An answer inside it carries on in that page, still signed in.
  • expires (default 24h, at most 7d): 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 with input_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:

ReasonMeaning
drifta field or target moved and the healer was not confident of the replacement
obstaclesomething covers the page that the obstacle layer could not dismiss
unknown_pagethe job is somewhere it did not expect, or a target or expect failed on its own page
session_expiredthe session stopped working and could not be refreshed unattended
side_effect_ambiguousa side-effect step started and was never confirmed
captchaa 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:

BodyWhat 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: false and remember: "none" (the defaults) apply the fix to this job only.
  • promote: true publishes a new program version with the fix, moving latest but not production.
  • 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}`);

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 succeeded and failed lets parked jobs expire. Handle awaiting_input and needs_attention, or subscribe to their webhook events.
  • skip_step on a step that mattered. The job carries on without it; data that step would have produced is missing. Prefer apply or retry_from when a proposal or a checkpoint fits.
  • promote: true by reflex. It publishes a new version and moves latest. 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 choose skip_step (it ran) or retry_from an 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?