The walk
Many actions as one correlated route — a plan admitted up front, executed without a model call between steps, answered with a manifest.
Why
A model that returns to its orchestrator between every screen action spends a round trip per step re-deciding things it already decided. And a batch that plows ahead blindly is worse: a screen action cannot be rolled back, so "the batch failed" while the screen sits two steps along is a lie of summary. The walk is the middle: admission checks what declarations can prove before anything runs; execution re-derives every step's offer at its own turn; and the answer is a manifest — ran, refused, never-reached, per row — so partial execution is legible instead of summarized.
Browser automation can batch operations too. The walk's guarantee is reuse of the app's declared actions and live invocation checks; it does not require or imply a model round trip per browser click.
The example below uses a fixed local archive and records an open request; it makes no model or network call. Replace those application functions with the ones your app already owns.
const = ('runs.find', {
: 'Find the most recent zone-redundancy run',
: 'inputless',
: { : 'run.ref' },
: { : { : ['searching', 'found'], : true } },
: async (?: ) => {
?.('searching');
const = await .('zone-redundancy');
?.('found');
return ;
},
});
const = ('runs.open', {
: 'Request that one run opens on screen',
: 'scalar',
: ,
: (: string) => .(),
});
const = ();
(, , {
: 'runs',
() {
.?.( => .());
},
});
(, , { : 'runs' });
const = (, 'agent');
const = await .([
{ : },
{ : , : { : 0 } },
], { : => .(.) });
// manifest.rows → [{ status: 'ran', transition }, { status: 'ran', transition }]
// manifest.counts → { planned: 2, ran: 2, refused: 0, neverReached: 0 }The first action's declared output is carried explicitly into the second action's validated
payload. The progress subscription reports stages inside the first invocation; onRow reports
each manifest row as it lands. ran rows land after whenInvoked settles. Neither requires a model
call between these known steps. A missing offer, ambiguous instance, refusal or failed invocation stops later steps;
the caller can inspect the manifest and replan under the same walk identity.
Invocation completion and effect truth
manifest.completed means every invocation performed: the walk awaits whenInvoked, not
whenEffectSettled. In this example, an open request returning is not proof that the screen
committed or that a business effect succeeded. The transition's effect status and authoritative
settlement remain separate. A closed progress stream is not that settlement either.
Batch steps whose next inputs and availability are known. If the next step depends on a delayed
commit, a fresh observation or a decision nobody owns yet, return to the host/planner or person,
read the actual evidence, and submit the next plan. The walk does not invent a pause/resume
protocol, infer a dependency graph from needs, or automatically become a
Session journey served through MCP/WebMCP.
Browser observations can be authoritative evidence when the app's adapter defines and reports that contract. Merely clicking or seeing a change does not automatically verify the intended business outcome. The binding settlement rules keep a handler result, execution progress and effect evidence distinct.
Parallel independent actions
Available since 3.2.0. beginWalk returns an ActionWalkWithParallel
that extends the existing ActionWalk; sequential run and its output carry are unchanged.
Each item uses the public ActionParallelStep type, which excludes output carry between items.
Use parallel only for actions the application knows are independently available.
One tab still has one current page
Navigating from page 1 and then using page 2 is dependent work. Keep that sequence out of a parallel
group, and read the application's actual readiness evidence before acting on the next page.
The callable runtime treats node as a binding label. It does not inspect the browser, prove
visibility, establish independence or create isolated contexts. Different node names are not a
parallel-safety test.
The host must disconnect a control when it unmounts. If hidden controls remain mounted, their enabledness must reflect the active page. Only describe separate browser contexts when the host actually provides them. Parallel work fits independently available actions or background work that does not compete for the same navigation state; an action's name or placement alone is not proof.
Two independent background reads
This checked example uses fixed local values and makes no network or model calls. Both connections use the same background label: their independence comes from the application functions, not the label.
import { , , , } from 'hcifootprint';
const = ('summary.read-orders', {
: 'Read the order count from the local snapshot',
: 'inputless',
: async () => ({ : 12 }),
});
const = ('summary.read-stock', {
: 'Read the stock count from the independent local snapshot',
: 'inputless',
: async () => ({ : 7 }),
});
const = ();
(, , { : 'background' });
(, , { : 'background' });
const = (, 'agent');
const = await .([
{ : },
{ : },
], {
: 2,
: => .(., .),
});
// manifest.execution → { mode: 'parallel', concurrency: 2 }
// manifest.rows stays in input order; onRow reports each row as it completes.Every dispatch resolves the current offer and uses the existing principal, input and live-binding gates. Each invocation keeps its own transition, any declared progress channel and effect receipt. There is no second action registry or ledger, and no automatic Session/MCP/WebMCP bridge.
The concurrency limit counts open whenInvoked lifetimes only. If a handler returns after
starting a backend job, its slot becomes free even while that job or an effect is still running.
This limit does not cap backend jobs or effects that outlive their handlers, and it does not
create worker threads. The host still owns any longer-lived work limit and the evidence that
establishes its result.
Limits and results
- Omit
concurrencyto dispatch up to all N items. An explicit limit must be a positive safe integer; the actual limit is at most N and rides the manifest asexecution: { mode: 'parallel', concurrency }. carryis not accepted inside a parallel group. A consumer of another action's output belongs in a sequential plan or a later plan after the required evidence arrives.- Declaration/admission errors refuse before work starts. Once dispatch begins, an individual
item's refusal or failed invocation does not stop other independent queued items. Every item
gets its own manifest row; a
ranrow points at the existing transition and its outcomes. onRowreports completion order, while final manifest rows remain in input order. A refused or never-reached item has no invocation to await.ranrows land afterwhenInvokedsettles.interruptstops dispatching queued items and lets running invocations finish. It does not cancel a handler or undo an effect. Overlappingparallelwith anotherparallelorrunon the same walk is refused; this does not change legacyrunbehavior. A stop requested by an offer reader is checked again before invocation; work already inside the invocation is not cancelled. If every item was already dispatched, the interrupt remains armed for the next plan.completedremains an invocation verdict: all invocations performed. Neither the concurrency limit nor the returned manifest waits for every effect to settle or makes an effect verified.
The laws, and why each one
- An agent may not pre-plan a decision it does not own. A step whose
decisionOwnerthe walking principal does not hold is refused at plan time — not at step three of a half-executed screen. The same step is admitted for the owner: the law is ownership, not fear of batching. - A carry needs a declared
produces. Chaining is what makes plans worth having — but free references are a dataflow language by the back door. The gate is the declaration: a carried value comes from a declared output, never from whatever a handler happened to return. - Batched is not blind. The plan was made from what was true at step zero, and step one can invalidate step two — so every step re-reads its offers when its turn comes. A guard that stopped holding is a refused row carrying the runtime's own teaching sentence.
- One turn, one walk, many plans. A refused step means the caller
replans under the same walk id;
walk.record()then holds the route actually taken — which is not the route anybody planned, and comparing the two is where the interesting failures live.
Admission failures throw before execution. Reached invocation refusals and failures are recorded in the manifest, with transition refs for steps that ran, so partial execution is not hidden behind a single success/failure summary.
Breaking the walk
The human has a door, and the reason travels. A batch a person can watch is a batch a person must be able to stop — and a break without a reason is a guard failure wearing a trench coat: the model replans blind, and probably replans the same thing.
const walk = beginWalk(runtime, 'agent');
const running = walk.run(steps, {
onRow: (row) => renderStatus(row), // the FE's live loop — and where the
}); // stop control belongs, beside it
// The person presses stop:
walk.interrupt({ by: 'user', reason: 'wrong array — I meant NORTHWIND-01' });
const manifest = await running;
manifest.interrupted;
// → { by: 'user', reason: 'wrong array — I meant NORTHWIND-01', beforeStep: 1 }The semantics that keep it sound:
- Step-boundary only. The current invocation finishes; its effect may still await authoritative observation. The walk stops future steps, without cancelling that invocation or rolling back a side effect.
interruptedis a manifest field, not a row status. Un-run rows staynever-reached, but the manifest says who stopped it and why — because "the plan was wrong" and "the person knows something the plan didn't" must be treated completely differently by whoever replans.- The walk survives. A break ends the plan, not the walk: the model reads the reason and replans under the same walk id. The break is just the most human way a plan ends.
- The intent stands. An interrupt armed before a run is consumed at step zero — pressing stop between plans is still pressing stop.
Bring your own skin
The React hook is sugar, not the seam — write a framework skin of your own against the same primitives, and the four laws it must uphold.
Kind governance
needs and produces name kinds — a vocabulary governed before matching exists, so two teams' meanings of one word collide at connect time, not in production.