Skip to content
Draft, pre-release documentation

A postcondition says what must be true after a step: the URL changed, a window opened, a process started, a button became enabled. A plan is a list of steps that each carry at least one postcondition. The runtime replays a plan with no model call, checks each postcondition as it goes, and hands over to its agentic loop only if one of them fails.

A task the loop worked out once can then run the same way every time. Let the loop solve it, compile the run into a plan, review the plan, commit it to the scenario, and later runs replay it.

Replaying a plan needs no model; only the fallback loop does. With no model configured, a plan that completes passes. If a step fails, the loop does not run. The report notes that no model endpoint was available, and the verdict comes from the deterministic checks.

Each entry in a post: list sets exactly one kind. Every kind except screen is polled until it holds, every 250 ms by default, for up to timeout_s seconds (default 10). A condition that never holds fails the step, and the report records what was observed instead.

Kind Surface Holds when Needs a model
url: "<substring>" browser, over Chrome DevTools Protocol the tab’s location.href contains the substring no
dom: "<js boolean>" browser the expression is true in the page no
state: "<element> [not] <state>" desktop, over AT-SPI the application reports the control in that state no
window: "<substring>" desktop, X11 a window whose title or class contains it exists no
no_window: "<substring>" desktop, X11 no such window exists no
process: "<substring>" any a process whose command line contains it is running no
no_process: "<substring>" any no such process is running no
file: "<path>" any the path exists in the session no
screen: "<expectation>" any the model judges the expectation true on a settled screenshot yes

screen is judged once and takes no timeout_s. It is a judgement rather than a read-back, which makes it the weakest evidence in the list. Use it only where nothing else can observe the effect. With no model configured, a screen condition is recorded as not judged and does not fail the step.

A post: list is accepted on a scripted step under steps:, where it is optional and replaces an action followed by a separate wait; on a plan step under plan:, where at least one entry is required; and on a tool the scenario declares under tools:. If a declared tool runs but its postcondition does not hold, the loop’s model gets an error naming the condition, with the command’s own output attached.

Each evaluated condition is written to the step’s post array in report.json as {kind, arg, held, detail, ms}.

steps:
- action: cdp_navigate "https://pcpartpicker.com/products/cpu/"
post:
- url: "pcpartpicker.com/products/"

A scenario with a plan runs in three phases: steps: (setup, must pass), then plan:, then goal: only if the plan did not complete.

plan:
- action: cdp_click_described "allow cookies"
post:
- dom: "[...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Add')"
- action: cdp_click_described "AMD Ryzen 7 9800X3D"
post:
- url: "/list/"
goal: "Dismiss any cookie banner, then click Add on one suitable product."

When the scenario loads, the runtime rejects a plan without a goal:, since the goal is the fallback; a plan step without an action and at least one post: entry; an observe step inside a plan; and a plan of more than 40 steps, counted separately from steps:.

When a plan step fails, or its postcondition does not hold, the run does not end. The runtime records the failed row, adds a note naming the step and the reason, and starts the loop from wherever the plan left the screen. The loop’s first action must look at the screen before acting, as after any other change. A lost browser connection or a missing display still ends the run as test-error.

A plan that completes is a pass on deterministic evidence: every step ran and every postcondition held. report.plan records {steps, completed, failed_step, reason} either way, so a run that fell back is distinguishable from one that never had a plan.

Terminal window
kasm-session-runtime compile-plan <run output dir> [--scenario base.yaml]

compile-plan reads report.json and trace.jsonl from a finished agentic run and prints a plan: block. With --scenario, it prints the base scenario with the plan appended and checks that the result loads. It never edits a file in place: the output is YAML for a person to read and commit. It runs offline, with no session and no model.

It refuses a run whose model verdict was not goal-met, because a plan compiled from an unfinished run would replay the unfinished part and then pass on its own postconditions. --allow-incomplete overrides this.

Each tool call in the transcript becomes one plan step.

The loop called The plan gets
cdp_click_described cdp_click_described "<resolved name>", using the element name from the result, never the per-page handle the model passed
cdp_click on a declared target cdp_click "<selector>", when --scenario supplies the target
click_element click_element "<name>"
press_key key "<chord>"
click click "<target>" or click center
type_text, cdp_fill nothing: the transcript does not record typed values; add these by hand with a variable reference
click_grounded nothing: a vision-guided click has no deterministic postcondition

Each step’s postcondition is inferred from what happened next, in this order: the page’s URL changed; a cdp_wait the model ran held; a later cdp_url read a new URL; a filtered cdp_list_interactive found something; a check_expectation held (this one compiles to a screen condition); failing all of those, the next action’s target is on the page. If a step ends with no inferable postcondition, the output marks it TODO and compile-plan exits 1.

A plan is specific to the page it was compiled from and does not generalise across parameters. When the application changes, a postcondition fails, the run falls back to the loop, and the report names the step where the page had moved.

Measured on 2026-09-04 with the 08-parallel-agentic-goals example in the SDK : nine product categories on pcpartpicker.com, one Kasm session each, three sessions at a time, gemma-4-12b served by llama.cpp on one GPU with 4 slots sharing a 16k-token context pool. Phase 1 ran the loop; phase 2 replayed the plans compiled from phase 1.

Agentic loop (phase 1) Compiled plan (phase 2)
Categories passed 9 of 9 8 of 8 that ran
Model iterations (8 categories both phases completed) 50 0
Model seconds 242.8 0.0
In-session time, summed 542.8 s 140.6 s
Parallel wall clock 245.4 s 87.7 s

One phase-2 session lost its upload to a proxy error before the runtime started, so eight plans ran. All nine transcripts compiled without a TODO, and every plan that ran completed on its postconditions with no fallback.

The measurement covers one site, one model, one day and a two-step task. The parallel gain is page loads, browser start-up and DevTools work overlapping; the sessions shared one model server, so inference did not run in parallel. With all nine sessions at once against the same 4-slot server, per-call model latency rose from 4 to 7 s to 15 to 36 s and 8 of 9 loops ended without a verdict. Size the model server for the number of sessions you run at once.

Agent JIT compilation (Winston et al., ICML 2026) found that most web-agent failures are a tool called in the wrong state, and that most of an agent’s wall clock is per-step inference. From it we took postconditions declared on steps and plans compiled from earlier runs. We did not adopt its planner, which has a model write code that calls tools. The runtime keeps a closed vocabulary of actions, and our compiler is deterministic and produces YAML a person can review.