hacifootprint
Actions

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:

  • createActionRuntime constructs one isolated action generation; ActionRuntime is its framework-neutral connection, principal-port, binding, and transition-history surface.
  • ActionRuntimeOptions names the strict/disclosure activation policy and optional input-schema adapter; it does not expose mutable runtime state.
  • PrincipalActionPort is the only broker authority that can enumerate and invoke offers for one explicit principal.
  • ActionGuardContract is the nonempty grouped availability declaration, while ActionSettleContract is the nonempty grouped effect, evidence, and progress declaration.
  • DefineActionOptions names the complete mode- and progress-specific record accepted by defineAction, so wrappers and generated references do not erase it to an anonymous object.
  • ActionProgressDeclaration is the authored stage vocabulary; ActionLifecycle is the narrow transition-owned reporting capability passed at execution.
  • ActionProgress is the live read/subscribe projection, ActionProgressObservation is one retained report, and ActionProgressSnapshot is the honest open, not-started, or closed view including unreported.
  • ActionAbandonmentAuthority is the closed set of explicit facts that may end an unverified effect as abandoned.
  • ActionObservedInvocation keeps mutation and host-continuation outputs separate under its behavior discriminator.
  • BindingRegistration names the structured snapshot returned by ActionRegistry; 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:

coveragestrongest supported claim
identitythis exact control or test target is identified
semanticits authored action meaning is also known
executablethe binding has an invocation door
verifiableit 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:

inputModewho owns the payloadinvocation
nonethe definition takes no direct payloadprincipalPort.invoke(offer)
boundthe live binding captures its reader once when the offer is mintedprincipalPort.invoke(offer)
openthe caller supplies exactly one schema-shaped slot, often after HITL UIprincipalPort.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 — idempotent

You 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 attached

Each 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;
  • enabled flip → flows to TOOL_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 and available() 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:

  1. registerActions('<page>', …) — wire what is on screen;
  2. pass navigate: (href) => router.push(href) to createSession so url gestures materialise;
  3. 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.

On this page