Plans and postconditions
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.
Postconditions: post:
Section titled “Postconditions: post:”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/"Plans: plan:
Section titled “Plans: plan:”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.
Compiling a run: compile-plan
Section titled “Compiling a run: compile-plan”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.
Savings in one measured run
Section titled “Savings in one measured run”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.
Background
Section titled “Background”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.