Journeys as fixed tools (Mode B)
serveToAgent serves one tool per journey plus four fixed generics — the tool array never changes for the life of a conversation, so the prompt cache stays warm.
serveToAgent(session, opts?) projects the session as a FIXED set of MCP-shaped tools whose
bytes never change for the life of a conversation. Disclosure rides the RESULT channel: every
call returns readySteps — what's fireable at the current cursor — and the model acts by
calling the same journey tool again with a step. Tools render first in the prompt, so a
stable tool set keeps the prompt cache warm, and any plain MCP host can drive it with no
dynamic-tool support required.
// opts: { confirmHighEffect?: boolean, source?: Principal, journeyTools?: 'per-journey' | 'single' }
const = ();
const = .(); // static MCPToolDescription[] — register with your host
const = .('shop.journey.purchase', {}); // route a tool_use; return as the tool_resultFor graph id shop, tools() returns:
- one tool per journey —
shop.journey.<id>, input{ step?, input?, confirm?, decline?, instance? }; shop.whats_here— describe the current position. Optional{ sinceVersion }: the reply narrates only the DELTA since the model's last look — cheaper re-grounding after write-steps;shop.do_action— perform one available action outside any journey flow;shop.why— the causal backward slice (who produced this state). Why-text may carry committed values, so it rides results as DATA, never a description;shop.did_it_work— how an action came to rest. Input{ transitionId }, taken from that action's result — or theaskIdfrom aneeds-confirmresult, to learn whether the human has decided. See below.
The journeys a reply lists
whats_here lists the journeys you can start from here — the ones whose first step is offered
on this page with its guard holding — and never every journey the app declares. It is the same
on-demand rule Mode B already applies one level down, where steps arrive only after a journey is
entered. Measured on a 60-page app declaring 57 journeys, the un-scoped list grew from 382 to 8,651
bytes while the rest of the position block did not move at all: the whole growth, every turn,
describing flows that could not be started from where the model was.
What it leaves out, it says. When journeys are declared elsewhere the reply carries
journeysElsewhere: n and one sentence: the list is scoped to a position — not to permission,
and not to the whole app — and the way through is routeTo, which names the declared hops to the
page a journey starts on. A silently shortened list is a worse failure than a long one.
Two things stay whole. The journey you are currently inside is always listed, whatever its entry
step says now: a flow that vanishes from the list reads as a flow that ended. And
session.availableJourneys() still answers for every declared journey — this scopes
what the model is served, never what your app can see.
One journey tool, or one per journey
serveToAgent(session, { journeyTools: 'single' }) serves one <graph>.journey tool taking
journey: '<id from whats_here>' alongside the arguments it already took — the shape do_action
has always had for actions. Journey discovery moves to the result channel. The default,
'per-journey', is unchanged byte for byte.
Why the option exists, in the bytes that produced it: at 57 declared journeys the tool array was
79,199 bytes and 85% of it was two authored constants repeated 57 times — a byte-identical step
schema and the same usage sentence. The per-journey information content is the authored does,
21–121 bytes of a ~1,331-byte marginal cost. In 'single' the array stops depending on how many
journeys an app declares, so it is byte-stable across apps and not merely across turns.
What is not known, said plainly: whether a model selects as well from one generic tool plus a
list as it does from N named, described tools is unmeasured. That is a tool-selection quality
question rather than a byte-count one, and it is being measured on a task grid before any default
moves — which is exactly why this is opt-in. Switching is breaking for a host matching on
<graph>.journey.<id> tool names: those names are answered UNKNOWN_TOOL with the list that does
exist, never routed silently.
Reading a result
Every result is a plain data object. ok plus judgment route it: on an open frame
'navigate-or-wait' | 'one-ready-step' | 'needs-choice'; on completion 'done'; on refusal
'blocked' (precondition), 'error' (unknown step/action), 'needs-confirm' (high-effect,
carries receipts), 'rejected' (fire refused — carries the typed reason).
Position always rides along: youAreOn and version.
ok: false is a fact about the call, and a pause is not a failure —
so every needs-confirm result also carries performed: false and one authored sentence
saying so: Nothing has been done. This is a question for the human, not a failure. An agent that
read ok: false as the app broke went looking for another route; the marker is the
machine-readable half of the fix, and did_it_work is the other.
Capability comes before authority, here as in fire(): a high-effect control the app has
switched off is refused TOOL_DISABLED and no card
is minted. Nobody may
be asked to approve something nobody can do — and the refusal carries the
busy label too, if the app said one, which the confirm card never
would have.
Each readySteps row carries step, does, and the honesty stamps — highEffect?,
guardUnevaluated?, materialized? (false = nothing is bound to execute that step), and
expects? (the declared input contract, visible before the fire). Four more facts
are about the control itself. The first, goesTo?, rides a readySteps row as well — a
navigating step is the same working link read as a dead one — and the other three are on the
whats_here action row:
goesTo?— the page this edge claims it will move you to, from the declaredgoTo. It matters before the fire, not after: a navigation declares nowrites, so success looks exactly like nothing happening, and an agent watching the control it clicked reads a working link as a dead one. The human's confirm receipt has always disclosed this claim (willDo.navigatesTo); the agent's rows now disclose the same fact, and stay silent when the app declared no destination. The claim and the observation →holds?— what that control is holding right now: the draft already in the box, read when the row was served. Present only where the app declared a way to read it, so an absent key means the library does not know, never that the box is empty. It is a reading, not a binding — firing still sends theinputyou pass.enabled?—falsewhen the app has switched the control off: on screen, not clickable, the greyed button a person sees. Presence-only — a clickable control carries no key — and reaching for it anyway is aTOOL_DISABLEDrefusal carryingretriable: trueand a sentence that refuses to invent a cause.busy?— the app's own label for working on it right now ("Saving your draft…"), the third state beside clickable and switched off. Presence-only again, and a string only: an absent key means the library does not know, never not busy. It gates nothing — a busy control that is not disabled still fires — and nothing here will ever time it out.
There is deliberately no kind field to switch on: a Pay button can be guarded and high-effect
and navigating and busy at the same moment, so the kind of an edge is the set of declarations it
carries. Every stamp, the declaration behind it and what would prove it:
What kind of edge am I holding?
do_action results carry effectStatus too — the word crosses the wire; the whenSettled
promise deliberately does not.
do_action resolves an action NAME against what is being served, so a name matching none of
them is refused by the port itself (reason: 'UNKNOWN_ACTION', with the id list that
answers it). When the app does have that action, the refusal says which true thing is the
case rather than letting UNKNOWN_ACTION read as no such thing: why names it — on
another page, or on this page with its conditions unmet, and then evidence carries the same
per-condition detail a GUARD_FAILED fire does (plus guardUnevaluated for keys the state
view could not judge). A name the graph really lacks gets no why at all: the library does
not invent an explanation for something it has never seen. See
what the facts block leaves out for where this
refusal does not appear.
One expects law, every surface
expects is what a caller must send, wire-shaped. It used to be a Mode B result field
only: available().edges served the raw schema — a live validator, useful in process,
unusable over a wire — so a consumer driving the session directly had to re-derive the
contract by hand, guessing which schema kinds serialize and which decline. A law duplicated at
the consumer is drift by construction, so there is now one derivation behind both, and the
same action can never advertise two shapes.
Four renderings, and a deliberate fifth that is silence:
| you declared | expects is |
|---|---|
| a Zod schema | the normalized JSON-Schema form of it |
| a plain JSON Schema | a detached, deep-frozen clone — never a live reference into the spec |
any other .safeParse/.parse validator | one authored sentence: validated at fire time (non-serializable validator) |
input: 'none' | the literal 'none' — the action that takes nothing |
| (nothing) | absent — the library does not know the shape, and absence never means "send nothing" |
It rides every surface that describes an action before or after the fact: whats_here action
rows, a frame's readySteps rows, a PAYLOAD_INVALID rejection (so the correction arrives
with the refusal), and available().edges in process. The rendered contract is computed once
per schema object, cached and deep-frozen — available() is hot (every refused fire calls it
for its gap row), and one frozen object is safe to share precisely because nobody can rewrite
it.
The residual asymmetry is deliberate, so it is stated rather than smoothed over:
available() carries both the live schema and the wire-shaped expects; a served result
carries only expects. A live validator never crosses the wire — that is the firewall, not
an oversight.
facts — the authoritative block
Every whats_here result also carries facts: the app's own record of what was
attempted and how each attempt came to rest, under a header telling the model it outranks
anything said in the conversation.
It exists because of a reported failure with no library bug in it: given nothing to check itself against, a model narrated an entire flow — name set, recipe selected — having called zero tools. Its own prose had become its context.
The friendly brief beside it could not have prevented that, and the reason is structural: a
REFUSED fire is a gap-ledger row, not a transition, so a narrative built from transitions can
never show a failed attempt. facts merges both ledgers and grades every outcome in flat
words — DID happen, ran, but the effect was unobservable, did NOT happen — … was refused:
TOOL_DISABLED, not yet known — then names any decision the human still owes and any fire the
app has not answered. With nothing attempted it says so in one line: No actions have been
performed in this app this session.
Nothing is rounded up. Only a committed fire whose declared effect was actually observed earns
DID happen; a fire nobody could check says so instead of borrowing the stronger word. It
carries no state values, no payloads, no produced data and no available actions (options are
this tool's other half — facts are what happened, so the two stay non-overlapping and both stay
lean). { sinceVersion } narrows it with the rest of the result, and an id the graph does not
have renders as a constant rather than echoing a model's invention back at it.
In process the same block is session.groundTruth({ sinceVersion?, maxAttempts? }). Its
anatomy, the grading table, what it excludes and how to inject it per turn when you drive your
own loop: Ground truth.
Did it work?
effectStatus on a fire result is the truth at return time, and for anything your app
still has to do that word is 'pending' — nobody has done it yet. So the result also carries
howToSettle, naming the door out: did_it_work, called with the transitionId from
that same result.
Beside the sentence, the same pointer as data — because a consumer that re-serves this surface into an action space of its own cannot route on prose:
{ "ok": true, "did": "pay.send-money", "effectStatus": "pending",
"transitionId": "pay.send-money#2",
"howToSettle": "Not finished yet — the app's side is still running. …",
"settleWith": { "tool": "pay.did_it_work", "arg": "transitionId" } }settleWith.tool is a tool this port publishes unconditionally and arg is the property its
schema requires; the id to put in it is the transitionId on that same result. If you are
projecting this surface into fewer verbs than the four it publishes, wire this — a settlement
door that is only named inside a string is one a projection drops silently, and the agent then
holds an unsettled high-effect fire with nowhere to put the question. Both keys ride the
'pending' arm only: the three final words point at no poll.
Until that fire comes to rest, whats_here also carries
priorFireUnsettled on the control's own row.
It is a poll, not a wait — call() is synchronous, so it answers immediately, in one of
these ways, and never blocks:
- settled —
{ settled: true, did, effectStatus, outcome, outcomeNow?, effectVerified, writesObserved?, verifyHeld?, arrival?, arrivalMeans?, toNode?, error?, data? }. Three axes, side by side, none averaged into another:effectStatusis did anyone perform it,effectVerifiedis were the declared writes observed,verifyHeldis did the app's ownverifycontract hold (absent when no contract was declared — silence, never a passing grade).writesObservedis the boolean form ofeffectVerifiedand is absent when the answer isn't knowable — a model testing truthiness would otherwise read the string'unobservable'as an observed write. Each carries its own axis in its own name: as plainverifiedthe state axis collided with the contract's verdict and printedverified: truebeside an error saying the app had answered no. - still pending —
{ settled: false, judgment: 'still-pending', did, howToAct }. Honest, immediate, and it tells the model not to repeat the action. - still working —
stillWorking: true(plusstillWorkingMeans) rides either of the two arms above when your app has opened a piece of work for that fire and not closed it. On the settled arm it sits beside the receipt exactly asoutcomeNowdoes: a fire can be at rest while your app is still working, and both are true at once. No new judgment word was minted for it, and no clock will ever expire it. - paused — pass an
askIdand it answers from the ask book instead:{ settled: false, performed: false, judgment, askId, did, howToAct }, wherejudgmentis'awaiting-human'(nobody has decided),'approved-not-yet-done'(a yes is on record and nothing has fired), or'declined'. Nothing fired, so there is no outcome — and that is not a failure, which is whathowToActsays: Paused, not failed: no outcome exists because nothing was fired. A spent ask forwards to the fire it authorized and answers with that fire's settlement, so the id a model was handed keeps working after the yes is used. The fate is read at answer time, and the id is matched exactly — a near-miss would answer about someone else's card. The action name crosses; the receipts stay on the ask. - unknown —
{ ok: false, reason: 'UNKNOWN_TRANSITION', pending: [...], awaitingSettlement: [...], awaitingHuman: [...] }. A wrong id is refused by name, listing every open question. That refusal is the point: waiting on a mistyped id and then reporting a guess is the failure this tool exists to end. Three lists, because they are three different facts:pendingis fires awaiting the app's state report (updateState's own word, same meaning here),awaitingSettlementis every fire this tool can still be asked about — the superset, and the one a fire question is about — andawaitingHumanis{ askId, action }for every card a person has not answered. A step declaring nowritesnever joins the first list while its handler runs, and an ask joins none of the first two: nothing was fired.
A settlement is a receipt of how the fire came to rest, and first settlement wins — so the
record can move afterwards (a server rejecting an order the app already reported flips it to
'rolled-back'). When the live record no longer agrees with the receipt, the settled arm
carries outcomeNow alongside outcome — never over it — plus the one instruction that
resolves it: go look at whats_here. The receipt is never rewritten; both truths are carried.
arrival rides that same rail, on an action that declared a navigation:
'claimed' or 'observed', with arrivalMeans carrying the authored
sentence for whichever one you got. It is read live, because the observation that corroborates a
claim can land long after the receipt was written — so an action can be performed with
arrival still 'claimed', and that pair is the truth. There is no third value for did not
arrive, and the field is absent entirely on an action that declares no destination.
In process, session.settlementOf(transitionId) is the same truth as a
promise, and port.whenSettled(transitionId) is that promise for a caller holding only the
port. Over a real server, mcpServer usually settles it for you before the result
ever leaves.
port.settledAnswer(transitionId) is the same truth as a result rather than a promise — the
one builder did_it_work answers from, minus that tool's own envelope, for a caller that already
holds the id and wants the facts to fold into a payload of its own. Three answers, and they are
three different things: the facts for a fire at rest, undefined while it is still in flight
(no answer yet, never a guessed one), and a synchronous throw on an id no settlement can ever
exist for — a mistyped id refused by name, because silence there reads as not finished and that
is how a wrong id becomes a confident wrong answer. It is what mcpServer folds with.
serveToAgent returns a JourneyToolsPortWithSettlement, where both methods are required —
hold the factory's port and you never check for them. On the published JourneyToolsPort
itself they are optional, and that is the whole reason for two names: somebody's object
literal implements that interface (a test double, a relay facade), and a required member added
underneath it would be a compile error in code that never asked for the feature.
The firewall
Text fields are authored strings only; runtime values (state, payloads, instance keys,
evidence, produced data) are structured DATA fields. Product names and search results ride
only inside tool_result data — never the system prompt, never a tool description. A dress
literally named IGNORE PREVIOUS INSTRUCTIONS… reads as harmless data. That's the
two-string-class firewall, and it is enforced at emission, not by convention.
The source stamp
Leave source at 'agent' for any port a MODEL drives. It stamps the principal on every
fire the port makes, and the NOT_MATERIALIZED guarantee keys off it: a port stamping
'user'/'system' is declaring app self-report, so its unbound fires are deliberately NOT
gated. Only stamp a non-agent source for a port your own code drives.
Graph sources & the merge order
fromRoutes, fromReactRouter, fromJourneys and fromLiveStore turn the descriptions your app already owns into graph input — under one documented merge order, so nobody re-types anything.
The MCP server
mcpServer(session) returns a standard @modelcontextprotocol/sdk Server — you pick the transport, and the SDK stays an optional peer imported only behind hcifootprint/mcp.