HACI Footprint

Human & Agent Β· Computer Interaction

Your agent can reach your app β€” but it's flying blind.
Your app already knows what can be done here, and by whom. Hand the agent that map.

HACI Footprint β€” the classic term HCI becomes HACI as a yellow A (Agent) joins the Human's side; a person and an AI act on the app as a team.

npm version 3380 tests passing zero-dependency core serves a real MCP server MIT

🌐 footprintjs.github.io/hcifootprint · Docs · Quick start

```bash npm install hcifootprint ``` > **1.0 β€” the names are frozen.** You author **actions**, you name **journeys**, and a **tool** is > what is served to a model. The pre-1.0 spellings (`tools:`, `skills:`) are gone, not deprecated β€” > see the [migration](CHANGELOG.md#100---2026-08-02). > **Agents:** [`llms.txt`](llms.txt) is the whole API surface on one page. --- ## The problem An agent can already reach your app. The question is how it *operates* one. | How agents drive a UI today | The cost | |---|---| | Screenshot the page, reason over pixels | slow, fragile, redone every turn | | Dump the DOM into the prompt | every wrapper and class, re-sent each turn β€” and it still guesses what does what | | Hard-coded selectors, RPA scripts | break on the next redesign | All three relearn your app from scratch on every visit. **Measured, on a real page**: a five-page demo's rendered DOM is 2,027 tokens; what this library sends for the same page is 332 β€” **6.1Γ— less per turn**, at 41.5 tokens per available action. That is one page of one small app whose DOM is dominated by its shell, so treat it as a floor rather than a headline; the script is in the repo (`node bench/token-cost/token-cost.mjs`) and the honest way to know your own number is to run it against your own app. A returning human doesn't β€” they carry a mental model of where things are and what they're allowed to do. **Your app holds that same map. This hands it to the agent.** And it is safe to adopt for one reason: **you are not opening your backend β€” you are letting an agent drive the frontend a human already can.** It acts as the signed-in user, through your own buttons and handlers, inside exactly the permissions they already have. No new endpoints, no new grants. --- ## The model **Three contexts.** An agent driving your app asks three questions, and this library answers exactly those β€” each in the same three parts: what you **declare**, what you **wire**, and what the **agent gets**. ### 1 Β· The map β€” *what can this app do?* **Declare** the app as the tree you already picture: places, the things inside them, the named flows worth finishing. One sentence per action β€” that sentence is your label *and* the tool description the model reads. ```ts pages: { catalog: { route: '/catalog', actions: { 'add-to-cart': { does: 'Add the open dress to the cart', writes: ['cart.items'] } } }, checkout: { route: '/checkout', actions: { 'place-order': { does: 'Place the order', enabledWhen: { 'cart.items': { gt: 0 } }, confirm: true } } }, }, journeys: { purchase: { does: 'Buy a dress end to end', steps: ['add-to-cart', 'place-order'] } }, ``` Already have a route table, a router's own nested route tree, a journey list, a live action store? `fromRoutes`, `fromReactRouter`, `fromJourneys` and `fromLiveStore` adopt them under one documented merge order β€” nobody re-types anything. `fromReactRouter` transcribes a page name from a fully-static address (`/projects/new` β†’ `projects-new`) and refuses, naming both doors, wherever there is nothing to transcribe β€” a `:param`, a `*`, the root. It never guesses a name. **Wire: nothing.** A map is static data. It validates and freezes in one call, so it can be linted in CI and argued about in a pull request before your app runs at all. **The agent gets** one tool per journey, plus four fixed generics. **The tool list *is* the map**, and its bytes never change for the life of a conversation. A whole-page dump is never served β€” that is the thesis, not an optimisation. ### 2 Β· Traversal β€” *where am I, and how do I get there?* **Declare** two fields, on the map you already wrote: `route` on a page, `goTo` on an action. An action's claim **is** the edge; pages declare no edges to one another. ```ts cart: { route: '/cart', actions: { pay: { does: 'Check out', goTo: 'checkout' } } }, ``` **Wire** the session, and one line wherever your router already knows the page changed. ```ts const session = graph.createSession({ node: 'catalog' }); session.sync('checkout'); // the router moved β†’ the cursor moves ``` **The agent gets** where it is, whether arrival is `claimed` or `observed` β€” never a guessed third value meaning *did not arrive* β€” and the declared hops to any destination. ### 3 Β· Actions β€” *what is possible here?* **Declare** what an action is, once, where it lives: `does`, `writes`, `enabledWhen`, `goTo`, `confirm`, `verify`, `input`, `humanDecides`. **Wire** your own functions, by reference, when the component that renders them mounts. ```ts const group = session.registerActions('checkout', { handlers: { 'place-order': (input) => shop.placeOrder(input) }, }); group.setEnabled('place-order', false); // the greyed button group.setBusy('place-order', 'Placing your order…'); // your words, never ours session.updateState({ 'cart.items': 3 }); // your store β†’ conditions re-evaluate ``` **The agent gets** one row per action that is offered here, carrying `enabled`, `blockedBecause`, `busy`, `holds`, `goesTo`, `expects`, `highEffect`, `humanDecides` and `unblockedBy`. Every stamp is presence-only: a key means your app said so, and no key means the library does not know. ### Declared or wired? One question decides > **Can this fact change while the page is open?** If **no**, it is a declaration > (`enabledWhen`). If **yes**, it is a wire (`setBusy`). That is why there is no `busyWhen`: a condition can prove a state, but it cannot author a label, and a library-written label would be a library-written meaning. ### And a fourth thing β€” which you never build The relations *between* actions are the part people expect to have to author. You don't. *Is `place-order` blocked, and by what?* `add-to-cart` writes `cart.items`; `place-order` waits on it. Nobody wrote an edge, and the edge is unambiguously there β€” so it is **derived, never authored**, and cannot drift from your graph: ```ts session.whatUnblocks('checkout.place-order'); // [{ affordanceId: 'catalog.add-to-cart', viaKeys: ['cart.items'] }] ``` *How do I get to checkout?* A route is walked from the `goTo` claims you already made: ```ts session.howToReach('checkout'); // [{ action: 'catalog.open', to: 'product' }, …] ``` **Everything relational is derived from declarations you make for other reasons.** There is no edge API in this library β€” not between pages, not between actions. The only thing you declare that cannot be derived is *intent*: "these steps, in this order, toward this goal" β€” a [journey](https://footprintjs.github.io/hcifootprint/docs/map/journeys), because a preferred order is meaning, and meaning is yours. β†’ [The three contexts](https://footprintjs.github.io/hcifootprint/docs/get-started/three-contexts) Β· [What would free it](https://footprintjs.github.io/hcifootprint/docs/actions/what-would-free-it) Β· [How to reach a page](https://footprintjs.github.io/hcifootprint/docs/traversal/how-to-reach) Β· [Navigation graph](https://footprintjs.github.io/hcifootprint/docs/map/navigation-graph) --- ## Map & Walker The three contexts above have one sentence under them, and it is the same sentence this library's siblings say at their own altitude: > **You declare the JourneyMap; the session is the Walker; the recording carries both.** Since 1.10.0 those two nouns have names in the API β€” `defineJourneyMap` and `JourneyMap`, **permanent aliases** of `buildNavigationGraph` and `NavigationGraph`. Same function object, same type, both names forever; neither is a rename, and a codebase may speak either dialect. ```ts import { defineJourneyMap } from 'hcifootprint'; const map = defineJourneyMap('shop', { pages: { /* … */ } }); const walker = map.createSession({ node: 'catalog' }); // the walker IS the session ``` There is deliberately **no `Walker` to construct**. A walker is not a thing you wire up β€” it is the session you already create, standing on a node, moving. Three movers move it, and each lands on the record as a `Cause` (`kind` β€” was an offered edge fired, or did the world move? β€” plus `principal`, whose move it was): | Mover | What moves the cursor | On the record | |---|---|---| | **human** | a real click in your own controls β€” `watchPage` senses it, `contextful` catches the app's own call | `kind: 'fired'`, `principal: 'user'`, with `Attribution` grading what that claim is worth | | **agent** | the four served verbs through the MCP door β€” `whats_here`, `why`, `do_action`, `did_it_work` | `kind: 'fired'`, `principal: 'agent'`, plus the `offerId` of the row it planned against | | **guard** | your data: an action's `when` / `enabledWhen` judged against the state your store pushed | the guard's own evidence, and `blockedBecause` / `unblockedBy` when it says no | And the world moves on its own β€” a back button, a server push, an expiry. That is `kind: 'stimulus'`: recorded, never silently absorbed. Five moves make up walking, and every one of them is answered by something that already ships: | A walker… | Here it is | |---|---| | **looks** | `whats_here` β€” one row per action offered on this node | | **navigates** | `do_action` on an action declaring `goTo`; the row carries `goesTo`, and `howToReach` walks the hops first | | **moves inside a screen** | a fire landing on an area, a tab or a modal moves the **focus**, not the page β€” `session.focus`, with `focusHistory` recording who moved it | | **keeps a task list** | **journeys** β€” `commitJourney` opens a frame, `journeyStanding` says where the flow stands | | **verifies** | `did_it_work` β€” the settled facts of one fire, never a guess; the drift sensor (`checkGraph`) is the honesty backstop under it | The same pattern sits at three altitudes: [footprintjs](https://github.com/footprintjs/footPrint) walks **stages**, [agentfootprint](https://github.com/footprintjs/agentfootprint) (`defineSkillMap`) walks **skills**, and this walks **screens**. ### Where the reader is: page, container, state > **Sync pages; observe the deeper place. `sync()` moves the walker and decides what is > served; `observeFocus()` says which tab or area the reader is in. Declare containers, and > report the deepest one on screen.** Position has three tiers, and each has one door: the page (`sync('run-detail')` β€” the walker moves, and what is served moves with it), the container inside it (`observeFocus('run-detail.why')` β€” any declared tab, area or modal path, and serving does NOT move), and state (`updateState`, which is not position). The middle one gets skipped because nothing forces it: declaring `tabs:` compiles, mounts and masks perfectly well while never once saying which tab the person is looking at. It has to be its own door. A tab is not a place the walker can stand β€” actions are served from the **page**, so a cursor parked on a tab would be served nothing β€” and a **person** clicking a tab fires nothing, which is exactly the case worth reporting. ```ts session.show('run-detail.why'); // which tab is VISIBLE session.observeFocus('run-detail.why', { principal: 'user' }); // where the READER is ``` ```text You are on: run-detail. β†’ You are on: run-detail. Focus: run-detail.why. ``` Every served answer carries both halves as data: `youAreOn` is the page that serves, `lookingAt` is the deeper place. Without it a screen-driving agent has to deduce the open tab from whatever unlabelled state keys happen to move. `lintGraph` proves the authored half (`unevidenceable-tab`). β†’ [Map & Walker](https://footprintjs.github.io/hcifootprint/docs/map/map-and-walker) ### When the action is already done > **An effect that is already true is not a pending one. When an action's declarative verify > contract covers every key it declares it writes and already holds at fire time, the fire never > waits for a state report that nothing will send β€” it settles on its own handler and answers > `alreadyTrue`.** An agent pressed a control while the thing it does was already the case β€” it asked to open a domain view while already inside that domain. The app's store publishes on *change*, so writing the value it already held notified nobody, no state report arrived, and the fire waited for one forever. `did_it_work` could only answer `still-pending`; the model read that as *the app is still working* and burned fifteen of its thirty steps on an outcome that could never arrive. Declare the postcondition beside the action and the library can see it, because `verify` is the one declaration carrying a **value** (`writes` is key names only, by design β€” nothing here infers what your handler would set): ```ts 'open-billing': { does: 'Open the billing domain view', writes: ['view.domain'], verify: { 'view.domain': { eq: 'billing' } }, } ``` The fire comes back carrying `alreadyTrue` β€” the conditions that already hold β€” plus one authored sentence that tells the model to move on. Your handler still runs; what changed is only what the fire *waits on*. β†’ [When it is already true](https://footprintjs.github.io/hcifootprint/docs/actions/already-true) --- ## Why this makes an agent better Not by making the model smarter. By never making it guess. Every turn, the agent gets **only what is true here, now** β€” one page's actions, not your whole app: ``` You are on: Checkout (step 3 of 4) Actions here: edit-address Change the delivery address place-order Place the order [not available] waiting on: cart.items which "Add to cart" writes β€” and it is running right now needs a person's approval before an agent may fire it Last outcome: address update β€” performed, verified ``` Follow what that removes. The model doesn't infer the page β€” it's told. It doesn't guess whether a control is clickable β€” it's told, and told *why not*, and *what would change it*. It doesn't wonder whether its last action worked β€” the outcome is a fact, not an assumption. It doesn't hunt for the finish line β€” a journey names the steps and the library says which are still open. **A greyed button is the whole argument.** A production integration's agent met one, was told only that it was disabled, fired it again to find out what would change, then told its human the app was broken. Nothing had failed β€” an upload was still running. That agent wasn't bad at its job; it was answering a question nobody had given it the facts for. Three consequences, in order of how much they matter: - **It stops hallucinating capability.** Anything derived rather than observed is flagged (`arrival: 'claimed'`, `guardUnevaluated`, `presence: 'unknown'`). Every refusal is typed and teaches β€” so the agent replans instead of inventing a cause. - **A smaller model goes further.** The reasoning that used to reconstruct your app from a DOM dump is simply not spent. This is context engineering, not model choice. - **Tokens are bounded by the page, not the app.** What's on screen is what's sent. β†’ [Reading an action row](https://footprintjs.github.io/hcifootprint/docs/actions/reading-an-action-row) Β· [Grounding](https://footprintjs.github.io/hcifootprint/docs/actions/grounding) Β· [Modes](https://footprintjs.github.io/hcifootprint/docs/map/modes) --- ## Quick start Three steps. The first two run offline, with no API key. **1 Β· Describe the app** β€” the tree you already picture. ```ts import { buildNavigationGraph } from 'hcifootprint'; const graph = buildNavigationGraph('shop', { pages: { catalog: { actions: { 'add-to-cart': { does: 'Add the open dress to the cart' } } }, checkout: { actions: { 'place-order': { does: 'Place the order', confirm: true } } }, }, }); ``` **2 Β· Connect it** β€” components register what they have when they render; your router reports the page. ```ts const session = graph.createSession(); const group = session.registerActions('catalog', { handlers: { 'add-to-cart': (input) => shop.add(input.id) }, // your own function, by reference }); session.sync('checkout'); // router change β†’ the cursor moves session.updateState({ cartCount: 1 }); // your store β†’ guards re-evaluate ``` **3 Β· Serve it** as a fixed set of MCP-shaped tools. The tool list never changes; what's doable *right now* arrives inside each result. ```ts import { mcpServer } from 'hcifootprint/mcp'; mcpServer(session); ``` β†’ [Quick start](https://footprintjs.github.io/hcifootprint/docs/get-started/quick-start) Β· [Adoption ladder](https://footprintjs.github.io/hcifootprint/docs/get-started/adoption-ladder) β€” start in guide mode, where the agent can only *describe* what's possible. The session's record is provided by `foottrace`. If you name its rows, import `CommitBundle` from `foottrace` instead of `hcifootprint`; trace readers such as `causalChain` and `sliceForKey` come from `foottrace` too. Add `foottrace ^1.0.0` to your application's dependencies when importing these names. This type export removal is a breaking change from 2.x; session methods and record bytes are unchanged. HCI supports `footprintjs ^9.47.0 || ^10.0.0` with `foottrace ^1.0.0`. --- ## Honest by construction Two properties do most of the safety work, and they are why this is worth adopting over a DOM dump. **It says what it can't see.** Derived facts are marked as derived. Unknowable ones are `unknown`, never guessed. A failed read says *"the app could not re-read its actions here"* instead of serving an empty list as truth. Silence is never a verdict, and a clock is never evidence. **A human's yes is a reference, not a claim.** For a high-effect action, an agent asserting *"the user approved"* proves nothing. The library requires a pointer to a decision a person actually recorded, bound to the receipts they were shown. **And some choices are not the agent's to make at all.** `humanDecides` says a decision belongs to a person β€” the agent presents the options and stops, and the human answers through your own control. It is disclosed on every surface and enforced on none: the flow is simply in someone's hands, and the library can say so without inventing a gate you never declared. ```ts 'choose-shipping-speed': { does: 'Choose a shipping speed', writes: ['checkout.shipping'], humanDecides: { about: 'which shipping speed', // your words, carried as data doneWhen: { 'checkout.shipping': { ne: '' } }, // your own "it has been decided" }, } ``` β†’ [Human-in-the-loop](https://footprintjs.github.io/hcifootprint/docs/actions/receipts) Β· [Whose decision it is](https://footprintjs.github.io/hcifootprint/docs/actions/whose-decision-it-is) Β· [Paused is not failed](https://footprintjs.github.io/hcifootprint/docs/actions/paused-not-failed) Β· [The human sensor](https://footprintjs.github.io/hcifootprint/docs/actions/human-sensor) --- ## More | | | |---|---| | **Async & progress** β€” the promise is the completion signal; `busy` in the app's own words | [Going async](https://footprintjs.github.io/hcifootprint/docs/actions/going-async) Β· [Waiting for the app](https://footprintjs.github.io/hcifootprint/docs/actions/waiting-for-the-app) | | **Keep the graph true** β€” a drift harness that fails in CI, not in front of a user, and a conformance check no source adapter can silently drop a declared field past | [Testing](https://footprintjs.github.io/hcifootprint/docs/reference/testing) | | **Adopt what you have** β€” routes, journeys or a live store as graph sources | [Graph sources](https://footprintjs.github.io/hcifootprint/docs/map/graph-sources) | | **React** β€” one hook per control, and a five-line port for any other framework | [React binding](https://footprintjs.github.io/hcifootprint/docs/actions/react-binding) | | **Tree-shakeable, ESM-first** β€” the sensor is 11.9 KB; the React hook 610 B | [Tree-shaking](https://footprintjs.github.io/hcifootprint/docs/reference/tree-shaking) | | **The gap ledger** β€” what the agent *couldn't* do, recorded, never hidden | [Grounding](https://footprintjs.github.io/hcifootprint/docs/actions/grounding) | --- ## Development ```bash npm install && npm test # the suite, with the badge gate npm run build # dist/, ESM-first npm run docs:truth # does the documentation describe what ships? ``` ## Built on [footprintjs](https://github.com/footprintjs/footPrint) for guard evaluation and schema normalization Β· [foottrace](https://github.com/footprintjs/foottrace) for the commit log and its readers Β· [agentfootprint](https://github.com/footprintjs/agentfootprint) if you want the agent loop too. ## Citing See [`CITATION.cff`](CITATION.cff), or use GitHub's **Cite this repository**. ## License MIT β€” see [LICENSE](LICENSE).