Callable action bindings & live stores
Declare callable actions, bind exact live controls, broker agent or HITL input, and connect existing live action stores to Sessions.
Two live-binding surfaces, with different jobs
HCIFootprint has two coexisting ways to bind application behavior. The existing
session.registerActions(...) and fromLiveStore(...) APIs bind graph affordance paths into a
Session; they feed Session availability, MCP serving, guards, receipts, gaps, and trace behavior.
The callable Action Binding Protocol binds one action definition to one or more exact live control
instances. It is framework-neutral and gives each definition, binding, offer, and invocation its own
structured identity.
The callable runtime does not automatically turn a definition into a Session affordance or an MCP tool. Keep using the graph and Session APIs when those are the consumers. Use the callable protocol when a framework adapter, agent surface, test, or developer tool needs to address and invoke the exact mounted instance. The rest of this page documents the Session-shaped surface; this section introduces the newer one.
The v2 public additions are deliberately semantic or name an unavoidable public return boundary:
createActionRuntimeconstructs one isolated action generation;ActionRuntimeis its framework-neutral connection, principal-port, binding, and transition-history surface.ActionRuntimeOptionsnames the strict/disclosure activation policy and optional input-schema adapter; it does not expose mutable runtime state.PrincipalActionPortis the only broker authority that can enumerate and invoke offers for one explicit principal.ActionGuardContractis the nonempty grouped availability declaration, whileActionSettleContractis the nonempty grouped effect, evidence, and progress declaration.DefineActionOptionsnames the complete mode- and progress-specific record accepted bydefineAction, so wrappers and generated references do not erase it to an anonymous object.ActionProgressDeclarationis the authored stage vocabulary;ActionLifecycleis the narrow transition-owned reporting capability passed at execution.ActionProgressis the live read/subscribe projection,ActionProgressObservationis one retained report, andActionProgressSnapshotis the honest open, not-started, or closed view includingunreported.ActionAbandonmentAuthorityis the closed set of explicit facts that may end an unverified effect as abandoned.ActionObservedInvocationkeeps mutation and host-continuation outputs separate under itsbehaviordiscriminator.BindingRegistrationnames the structured snapshot returned byActionRegistry; the registry's internal registration/update option shapes remain private implementation plumbing.
The earlier runtime names and registry helper types are not compatibility aliases on this next-major surface.
Callable definitions and exact live instances
defineAction takes exactly two arguments. The second record is the complete v2 vocabulary:
defineAction(id, {
does,
invocation,
inputSchema?,
guard?: { when?, enabledWhen?, blockedBecause? },
settle?: { writes?, reads?, goTo?, verify?, observability?, progress? },
principal?: { mayInvoke?, decisionOwner?, requiresHumanApproval? },
needs?,
produces?,
role?,
mutate,
});guard, settle, and principal are nested by phase: availability before an invocation,
evidence/progress after it starts, and authority around it. mutate is required and is the ordinary
application function. Calling the returned action directly preserves JavaScript arguments, receiver,
return value, thrown value, and thenable behavior. Reachability, instance identity, enabledness, host
state, and locators belong to each live connection instead.
A definition authors an evidence-bearing settlement contract only with nonempty settle.writes,
settle.goTo, settle.verify, or settle.observability other than unobservable. settle.reads
describes dependencies and settle.progress describes execution; neither is evidence that an effect
settled.
Every definition authors its call shape. inputless exposes no payload slot, scalar exposes exactly
one deliberate slot, and host records only the application's exact host continuation. The runtime
never guesses from mutable Function.length. inputSchema describes the definition-side payload;
input on connectAction(...) is a lazy live value reader. The graph/Session API still uses
ActionDef.input; that is a separate legacy declaration surface.
needs contains named { kind, schema?, from? } inputs and produces contains one
{ kind, schema? } output. Their declaration records are validated, detached, and frozen; their
optional schema capabilities retain identity for the future layer that will interpret them. They
remain inert here: no channel matching, UI rendering, value collection, or output routing happens.
Contract activation is explicit. The framework-neutral runtime can enforce a self-validating or
adapter-backed inputSchema, and it always enforces principal.mayInvoke. It has no application-state
or authoritative-evidence port, so contractActivation: 'require-active' rejects guard.when,
guard.enabledWhen, and settle.verify instead of claiming to evaluate them. It also rejects
principal.requiresHumanApproval because this runtime has no approval journal. Disclosure mode
carries those clauses as metadata only. guard.blockedBecause, principal.decisionOwner, settlement
declarations other than verify, and needs/produces are disclosures here. Use a Session or another
integration with the required state, evidence, and approval ports when those clauses must be active.
import { connectAction, createActionRuntime, defineAction } from 'hcifootprint';
declare const saveButton: HTMLButtonElement;
declare function persistDraft(id: string): Promise<void>;
declare function readSavedRevision(id: string): Promise<number>;
const draftId = 'draft-42';
let saving = false;
const draftIdSchema = {
safeParse: (value: unknown) => ({ success: typeof value === 'string' }),
};
const saveDraft = defineAction('draft.save', {
does: 'Save the open draft',
invocation: 'scalar',
inputSchema: draftIdSchema,
settle: {
writes: ['draft.savedRevision'],
progress: {
stages: ['persisting', 'persisted'],
required: true,
},
},
principal: { mayInvoke: ['agent'], decisionOwner: 'either' },
role: 'submit',
mutate: async (id: string, lifecycle) => {
lifecycle?.reportProgress('persisting');
await persistDraft(id);
lifecycle?.reportProgress('persisted');
},
});
const runtime = createActionRuntime();
const connection = connectAction(runtime, saveDraft, {
node: 'draft',
instance: draftId,
input: () => draftId,
enabled: () => !saving,
});
const attachment = connection.attach({
interactive: saveButton,
valueElement: saveButton,
coverage: 'verifiable',
locators: [
{
kind: 'element',
locator: { role: 'button', name: 'Save' },
actuation: 'click',
},
],
});
const agent = runtime.forPrincipal('agent');
const [offer] = agent.offers(saveDraft);
if (!offer) throw new Error('Save is not currently available');
if (offer.inputMode !== 'bound')
throw new Error('The live control should own this input');
const invocation = agent.invoke(offer);
const outcome = await invocation.whenInvoked;
if (outcome.status === 'performed') {
const revision = await readSavedRevision(draftId); // authoritative app observation
connection.settle(invocation.transition, {
status: 'verified',
evidence: { revision },
});
await invocation.whenEffectSettled;
runtime.forgetTransition(invocation.transition);
}
attachment.detach();
connection.disconnect();One definition can have many simultaneous connections. Every connection keeps its own generated
binding reference, opaque instance, committed input/enabled/busy readers, host attachment, and
locators. update(...) replaces committed readers without changing binding identity and advances
the revision only when those facts actually change. Call touch() when stable reader identities now
refer to a different committed application generation. Reconnect to add or remove the input-reader
capability. disconnect() is idempotent.
Coverage is ordered and says only what this binding can substantiate:
| coverage | strongest supported claim |
|---|---|
identity | this exact control or test target is identified |
semantic | its authored action meaning is also known |
executable | the binding has an invocation door |
verifiable | it also has an authoritative effect-observation path |
Broker capabilities exist only on a principal port:
const broker = runtime.forPrincipal('agent');
const [offer] = broker.offers();
if (offer?.inputMode === 'open') {
broker.invoke(offer, await collectInput(offer));
} else if (offer) {
broker.invoke(offer);
}There is no root runtime.available() or root runtime.invoke(). principal.mayInvoke is checked
before offer enumeration evaluates any live enabled, busy, input, or schema reader, then checked
again at invocation. A disallowed principal receives no offer and cannot use enumeration to probe
application readers. connection.invoke() remains a direct application/test door; it is not a
broker capability and never accepts an offer.
principalPort.offers(...) returns only bindings at executable or verifiable coverage whose
enabled reader is not false. Host-only definitions are never offered. An unbound scalar definition
without inputSchema is withheld: a broker cannot ask a person or model to construct an undeclared
payload honestly. A bound scalar remains offerable because its live binding already owns the exact
payload. Each frozen offer carries its authored definition, contract, locators, coverage, principal,
contract-activation posture, input-validation posture, exact binding revision, and one explicit
input mode:
inputMode | who owns the payload | invocation |
|---|---|---|
none | the definition takes no direct payload | principalPort.invoke(offer) |
bound | the live binding captures its reader once when the offer is minted | principalPort.invoke(offer) |
open | the caller supplies exactly one schema-shaped slot, often after HITL UI | principalPort.invoke(offer, input) |
A bound value is not exposed on the offer. The offer carries only an opaque ActionInputRef; the
transition reuses that exact ref so an audit can prove which capture was invoked without recording a
secret payload. Repeated principalPort.offers() calls reuse the same capture while committed facts are
unchanged. Invocation never rereads it and never accepts a replacement payload. For open input,
principalPort.invoke() validates the caller value against
offer.definition.contract.inputSchema before
the handler runs and creates a caller-origin input receipt. An open offer never permits an omitted
slot; pass undefined explicitly when that is the intended schema-valid value.
Invoke the full offer, not a recovered binding id. The runtime resolves it by retained object
identity, revision, and principal, and fails stale, superseded, foreign, cloned, or forged offers
before any schema or handler code runs. Authority for the same principal that received an offer must
invoke it; recreating a port for that principal is equivalent, while another principal cannot cross it.
Runtime lookups likewise accept structured ActionDefinitionRef, ActionBindingRef, or
ActionTransitionRef values, never bare strings. String ids are display/transport fields, not doors.
The revision is part of the input guarantee. The failure to prevent is a commit replacing a React
latest.current reader from o-57 to o-58 without advancing the binding revision: an offer minted
against the old revision could otherwise invoke through the new reader. A relevant commit publishes a
new revision and retires the old offer before the new reader can be used. Offer generation captures
the input once; invocation never rereads it.
The full offer is an in-process capability, not an FE/BE JSON DTO. It deliberately uses retained
object identity and its definition may contain functions or opaque validator capabilities. A network
interaction broker keeps the full offer beside the runtime and sends the frontend an opaque handle
plus a serializable projection of the form/choice metadata. The frontend returns that handle and its
user input; the broker invokes the retained offer. Generated ids such as offer#1 and
transition#1 are likewise runtime-local labels, not durable cross-runtime audit identifiers.
Schemas with their own synchronous .safeParse or .parse method are enforced directly. A plain
JSON Schema describes excellent UI but carries no validator, so strict mode requires an explicit
inputSchemaAdapter—for example, the small wrapper around the Ajv instance your application already
owns:
const runtime = createActionRuntime({
inputSchemaAdapter: {
supports: (schema) => isJsonSchema(schema),
validate: (schema, input) =>
ajv.validate(schema, input)
? { valid: true }
: { valid: false, issues: ajv.errors },
},
});The adapter is synchronous, gates execution, and never transforms the payload handed to the
application handler. Without it, a non-parseable schema is rejected by require-active; disclosure
mode carries it honestly as metadata without claiming validation. The runtime captures adapter and
self-validating schema methods for the binding generation; opaque validators may still own mutable
application state and must be replaced through a new binding/runtime generation when that state is
part of validation semantics. A plain declarative inputSchema is detached and frozen; the inert
channel-schema slots remain opaque until the future channel layer interprets them.
Invocation completion and effect verification are separate rails. whenInvoked reports whether the
application handler performed, was rejected before it started (refused), or started and then
threw/rejected (failed). A returned or resolved Promise does not prove that React committed,
navigation arrived, or state changed. A valid exact offer whose caller input fails validation returns
a structured ActionInputValidationError as a refused invocation; bound-input diagnostics are
redacted because validators can echo secrets. Caller diagnostic arrays and plain records are
detached and frozen, opaque values retain identity, and diagnostics that cannot be snapshotted are
reported with issuesDisposition: 'unavailable'. The same still-current offer may be retried with
corrected input, and the never-started effect is immediately settled as refused so the transition
can be forgotten. Protocol misuse—wrong input mode, missing open slot, stale/foreign offer,
disabledness, or insufficient coverage—throws synchronously before allocating a transition.
For an observer shared by both doors, invocation.behavior distinguishes mutation execution from a
host continuation; each branch retains its own result type instead of forcing the UI callback to
match the action mutation.
whenEffectSettled otherwise remains pending until an
authoritative observer calls settle(...). A verified settlement requires both an authored
evidence-bearing settlement contract and an invocation that ran with verifiable live coverage:
the definition names what could prove the effect, while the exact binding supplies the authoritative
observation path. Once both rails have settled,
forgetTransition(...) is the explicit history-retention boundary.
If settle.progress is declared, managed invocation passes a narrow ActionLifecycle to mutate.
reportProgress(stage, detail?) retains transition-owned observations. The progress snapshot is
open while the handler runs, not-started after preflight refusal, and closed when the handler
returns, throws, or rejects. A closed snapshot contains exact declared, observed, and
unreported values; unreported is declared minus the stages observed at least once. With
required: true, a started invocation that reports no stage closes with integrity: 'unmet'.
Unknown stages and reporting failures are instrumentation errors: they are routed to
onInvocationError and never replace the handler result or invocation status.
An unverified effect may become abandoned only with explicit authority:
{ kind: 'cancelled', reason }, { kind: 'deadline', deadlineAt }, or
{ kind: 'evidence-exhausted', sources }. Detaching a host, unmounting a component, or disconnecting
a binding is not abandonment; transitions survive disconnect so late evidence may still settle.
Effect settlement is first-terminal-wins, so evidence arriving after verified, refused, or
abandoned cannot rewrite the retained terminal record.
Finally, control identity is not human attribution. A host element, locator, sensor-ownership projection, or connection-owned occurrence does not by itself mint a human principal, attribution basis, or certainty. Keep the existing sensor/Session path when you need its human-provenance receipt, or add an explicit provenance rail before making that claim. The React binding provides the committed host lifecycle for this protocol.
The wire: declare statically, bind at mount
A tool needs only does to exist in the plannable spine; its handler arrives when the
component that renders it mounts:
const group = session.registerActions('catalog', {
handlers: { search: (input) => shop.search(input) }, // your function, by reference
// actions: { ... } — or declare NEW leaf tools here-and-now
// instance: 'o-123' — one card of a repeats container
});
group.setEnabled('search', false); // greyed out → TOOL_DISABLED (retriable)
group.unregister(); // on unmount — idempotentYou never invent a group name — the returned ActionGroupHandle IS the identity. Node paths
are typed against the graph, routes-source pages included.
fromLiveStore — the third source
If your app already keeps a live store of the actions on screen, you don't write that
bookkeeping per component. fromLiveStore(store) accepts the smallest respectable store
contract — subscribe(onChange) + actions(), the shape React itself blesses — and drives
the wire above for you:
import { buildNavigationGraph, fromLiveStore } from 'hcifootprint';
const graph = buildNavigationGraph('desk', {
sources: [fromLiveStore(appActionStore)],
pages: { inbox: {}, settings: {} }, // the graph declares PLACES; actions arrive live
});
const session = graph.createSession(); // attaches the live source
// later: session.detachSources(); // idempotent — releases everything it attachedEach published action is { node, name, instance?, enabled?, ...RegisteredActionDef }, and
${node}.${name} (+instance) is its identity across snapshots. The reconcile rules:
- new → registered (an action for an already-declared tool BINDS silently — attach last, only bind; a genuinely new one mount-declares);
- gone → released;
enabledflip → flows toTOOL_DISABLED;- unchanged → never re-registered — a chatty store causes zero warnings and zero phantom structure bumps.
The direct door also works, without graph involvement:
const detach = fromLiveStore(store).attach(session).
A live row declares everything an authored action does
The ...RegisteredActionDef in that shape is the whole authoring vocabulary, and it is easy to read
past. A live row may declare enabledWhen, and it works end to end — this is worth saying in
plain words, because for a long time it was true and written down nowhere, and integrations wired
enabled: false by hand because nothing said the declarative door was open to them:
store.actions();
// [{
// node: 'categorise', name: 'next', does: 'Continue to review',
// handler: () => wizard.goNext(),
// enabledWhen: { 'receipt.uploaded': { eq: true } }, // ← a DECLARATION, from a live row
// blockedBecause: { says: 'Waiting for the receipt to upload', clearedBy: 'app' },
// }]An action a store introduces this way gets everything a hand-authored one gets: the row carries
enabled: false while the condition fails, the refusal carries the failing conjuncts as
evidence, and
unblockedBy names whatever action claims to write those same keys.
writes, goTo, confirm, input, verify and
blockedBecause travel the same way.
The carve-out, and it follows from the merge order. Live actions attach last and only bind, so
a row whose id the graph already declares binds its handler and nothing else: the declaration in
the graph keeps its own enabledWhen (and everything else it declared), because that one is the
audited copy. Declaring from a store is how you describe an action the graph does not have —
feature-flagged, server-driven, discovered at runtime. To change what a declared action says, change
the declaration.
One field is a live bit the reconcile tracks: a changed blockedBecause sentence is picked up on
the next store read and re-declared, so a reason that moves with your store reaches the row. A reader
form (blockedBecause: () => …) is never re-declared, because it is already read fresh at every row
assembly — its answer changes without the store having to say anything.
When it re-reads — the invalidation contract
Two halves, and only one of them can be yours:
Your store must emit whenever the action surface changes. NAVIGATION is covered for you — the source re-reads on every page change the app reports through
sync().
The failure that half closes is quiet and it is the shape most apps have. A store whose actions are derived from the router has no change of its own to announce when the page changes: the route moved, the store's own state did not, so nothing emits. Without the re-read the surface after a navigation is whatever the last emission left behind — the previous page's actions, served confidently as the actions available here.
// This one line is the whole other half: the report re-reads the store,
// so 'archive' arrives bound to the archive page's actions.
.(
(.., .) ?? .,
);Three properties worth knowing:
- Only an OBSERVED page change re-reads — never a navigation the app merely claimed. A claim moves the cursor before the app's own handler has run, so a read there describes the page the app has not left yet, and would bind those answers to the new position.
- A re-read that changes nothing is free. The identity ledger re-registers nothing, so a no-change re-read is a diff and no world motion — no version bump, no warnings, no phantom structure churn. A repeat report of the same page re-reads nothing at all.
- Nothing re-reads at report time.
whats_here, the facts block andavailable()read the served structure; a read that re-registered tools would mutate the answer it is in the middle of giving.
The hook is LiveBindingPort.whenPageChanges, and it is optional and severable: a hand-rolled
port without it degrades to store-emissions-only behaviour rather than breaking. session.whenPageChanges(fn)
is the same door if you want it directly — its listeners are expected to change the session, which
is exactly why it is not on('transition'), whose listeners promise never to.
The error stance — split by WHO is on the stack
The FIRST read at attach is loud: an invalid action is an authoring error and dies at
createSession — and the throw cleans up after itself (both subscriptions and any
already-registered bindings are released, so a failed attach leaks nothing). A LATER read runs on
somebody else's stack — the app's own notify loop, or the session mid-hop — where a throw would
abort their work: the app's iteration over its other subscribers, or a navigation that already
happened. Those reconciles are isolated: a failure warns through the session's onWarn sink
and leaves bindings as-is; the next read simply retries.
And it is disclosed. Bindings from before the failure are still on offer, and serving them with nothing said presents a stale list as current fact — the same confident staleness the re-read above exists to end. So a caught failure also files one row in the gap ledger:
{
"kind": "reported",
"reason": "other",
"principal": "system",
"request": "live action store read failed; serving bindings from before the failure",
}One row per failure streak, cleared by the next read that works: a store that throws on every emission is one broken read, not forty unmet demands. The row carries an authored sentence and never the store's own error text — that is your runtime string. The dev warning carries the error; the row carries the consequence.
And the model is told. actionsMayBeStale is what carries this row past your triage ledger into
the facts block a model reads, as a line this library authors:
• the app could not re-read its own list of actions here — anything listed after this may be from before that.The row's own request never crosses into that block — it is data, and the facts block admits
authored sentences only. It is the one 'reported' row the block prints: every other one is unmet
demand for your team to triage, while this one is a fact about the surface the model is looking at
while it looks. Without it the disclosure reached a developer's console and the reader about to
act on the list was served it as current fact with nothing said.
Honest limits. The library cannot tell you why a read failed, and it will not guess whether the stale bindings are still correct — they may well be. It also cannot see a change your store never announces: if your action surface moves for a reason that is neither an emission nor a reported page change, nothing here will know.
Dead-end: a page where nothing can act
Binding is what stands between a declared action and a real one — so the session watches for
the room where that gap swallowed everything. When the cursor comes to rest on a page where an
agent fire of every served action would refuse NOT_MATERIALIZED — no actions at all, or
none of them registered, url-materialisable or instance-wired — it records a
kind: 'dead-end' gap row and warns once.
Nobody has to fire for the trap to exist, so nobody has to fire for it to be recorded. An agent that lands there gets a true-but-useless list of things it cannot do, and loops.
Once per (node, served structure). The row is an observation, not a verdict, and it is deduped on the page plus the served-structure fingerprint — what is registered, what is visible, what is greyed — deliberately not the structure version, which also bumps when a journey frame opens or closes. Churn that cannot wire anything must not multiply rows for a page whose answer never moved; a page still dead after the next real wiring change is one new fact and earns one new row. A mount that fixes the page simply ends them.
The warning always names the same three fixes:
registerActions('<page>', …)— wire what is on screen;- pass
navigate: (href) => router.push(href)tocreateSessionso url gestures materialise; - read the route table with
fromRoutes(routes, { crossLinks: true })so every page offers links to the others.
The sentence in front of them does not: it names only the refusal that is actually true of
that room — NOT_MATERIALIZED where actions are served but unwired, GUARD_FAILED where every
authored action is hidden behind a closed guard and none of them is wired, and
UNKNOWN_AFFORDANCE/NOT_ON_NODE where nothing is authored there at all. A dev warning that
names the wrong refusal sends someone hunting the wrong bug.
Two things it deliberately does not call a dead end. A guard-closed action is wired:
its refusal is GUARD_FAILED, the next state report may open it, and calling it missing
wiring would prescribe a fix already done. And an off-graph cursor — a page the graph has
never heard of — is the other trap and a permanent one: no mount can add a door to a page
that does not exist (registerActions throws on an unknown node), so it gets its own
sentence, its own offGraph: true on the row, and is asked exactly once for the session's
life. Its cures are different too: author the page, or sync() the id the graph actually uses
for that screen.
The gate arms only where materialisation is a live question — something is registered
somewhere, or the session holds a navigate — and never in a
tour session, where nothing being bound is the entire point.
What it does to the trace
fromLiveStore rides registerActions, so mounts land exactly like hand-written ones —
structure-axis version bumps, coalescing, dormancy/drift telemetry all apply unchanged. No
new record shapes. And it is a zero-value-import leaf: a static-graph consumer never
bundles it — see Tree-shaking.
See it live in the Live Desk demo: a support inbox whose graph declares places and not a single tool.
A read is an action
How the agent gets your app's data — declare a tool whose handler RETURNS it. The return rides `produced`, sanitized and capped, on the data channel; serving it is never a claim that the model used it.
The human sensor
A framework-free, record-only DOM sensor. The graph you already authored IS the instrumentation manifest — the app declares values, the sensor never reads them, and anything it cannot attribute is reported honestly or not at all.