npm install hcifootprint
Beta · pre-1.0 — the API can still change until
1.0. The npm publish lands with1.0; until then, install from this repo or pin a commit.
Agents: read
llms.txt— the entire API surface on one self-contained page.
Agents driving UIs are flying blind
An agent can already reach your app. The problem is how it operates one:
| How agents drive a UI today | The cost |
|---|---|
| Screenshot the page, reason over pixels | slow and fragile — redone every single turn |
| Dump the DOM into the prompt | ~100k tokens, and it still guesses at what does what |
| Hard-coded selectors / RPA scripts | break on the next redesign |
All three relearn your app from scratch on every visit. But a returning human already carries a mental model — where things are, what leads where, what they're allowed to do. Your app holds that same map. HACI Footprint hands it to the agent as a typed skill graph the agent traverses, instead of a DOM it re-reads — with a you-are-here pin so it only ever sees what is actually doable right now.
What it is
For decades we've designed the interaction between a human and a computer — that's HCI. Now the human isn't alone: an agent joins their side, acting for them. Human and agent, working the computer as a team — that's HACI, and this is the layer for it.
The key idea, and the reason it's safe to adopt: you are not opening your backend to an agent — you are letting it drive the frontend a human already can.
- Auth and permissions are unchanged. The agent acts as the signed-in user, through your app's own buttons and handlers. It inherits exactly the capability envelope the user already has. No new endpoints, no new grants, no new attack surface.
- The app is already the boundary. Your UI decides what can be done. The agent can't do anything a user couldn't.
- Both drive the same live session. The human's clicks and the agent's actions flow through one session — a team on one side of the screen. That's the "Human and Agent" in the name.
Quick start — author → connect → serve
Three steps, and the first two run offline with no API key.
1. Describe the app as the tree you already picture — pages, the containers inside them, and the actions inside those. Each action needs one sentence; that sentence is both your label and the tool description the LLM reads.
import { buildNavigationGraph } from 'hcifootprint';
const graph = buildNavigationGraph('shop', {
pages: {
catalog: {
tools: {
'search': { does: 'Search dresses by name or color' },
'add-to-cart': { does: 'Add the open dress to the cart', when: { authenticated: { eq: true } } },
},
},
checkout: {
modals: {
'confirm-order': { tools: { 'place-order': { does: 'Place the order', confirm: true } } },
},
},
},
skills: {
purchase: { does: 'Buy a dress end to end', steps: ['add-to-cart', 'place-order'] },
},
});
D18 buildNavigationGraph is the canonical authoring surface; the v1 skillGraph() fluent builder
remains as legacy sugar.
2. Connect it to your running app through three ordinary wires. Components register what they have when they render: registration hands back a handle (you never invent a group name), your existing functions bind by reference, the router owns the page.
const session = graph.createSession();
// when the component that renders the catalog mounts:
const group = session.registerToolGroup('catalog', {
handlers: { 'search': (input) => shop.search(input.query) }, // your own function, by reference
});
group.setEnabled('search', false); // grey a button out; group.unregister() on unmount
// your existing wires report reality:
session.updateState({ authenticated: true }); // store tap → guards re-evaluate
session.sync('checkout'); // router change → the cursor moves
// passive observers — never business logic:
session.on('structure', () => rerenderToolPanel());
session.on('gap', (row) => telemetry.send(row));
Node paths are typed: registerToolGroup('catalog.filtr-rail') is a compile error, not a silent no-op.
3. Serve it to the LLM as a fixed set of MCP-shaped tools. The tool list never changes; what's doable right now arrives inside each tool result.
import { skillsAsTools } from 'hcifootprint';
const port = skillsAsTools(session);
port.tools(); // static tool array — one per skill + whats_here / do_action / why
port.call('shop.skill.purchase', {}); // → { readySteps, judgment, youAreOn, ... }
The agent plans over skills, sees only what's available at the current position, and acts through your own handlers — with the human able to approve high-effect steps.
The adoption ladder — start in guide mode
You don't wire everything at once. Adoption is a ladder, and the first rung needs no handlers at all.
Phase 0 — guide mode (read-only). Wire only the two reporting calls: sync() when the router moves and
updateState() when your store changes. Register nothing. The agent can now read the position and plan over
the declared action space (whats_here / available()), but it acts on nothing — with no handlers bound,
every offered edge is plannable-only. This rung is zero-risk: it cannot touch your app. The one rule is
never fire() an unregistered tool. Firing records a transition your app never performed, which desyncs
the cursor and mis-attributes the next real state report. In guide mode, move the cursor with sync() and
updateState() only.
Phase 1 — register handlers. As components mount, registerToolGroup(path, { handlers }) binds your
existing functions by reference, so firing runs the app's own code. Edges now report materialized (false =
still declared-only, true = wired), and you watch the surface light up.
Phase 2 — serve an agent (Mode B). skillsAsTools(session) hands your host a fixed MCP tool set; the
agent plans over skills and acts through your handlers. One authored graph carried you the whole way — no
rung forced you to maintain a second, stripped-down copy.
Pick your door
| 🔌 Serving an agent? | 🕳️ Deciding what to build? | ✅ Shipping to production? |
|---|---|---|
| A fixed, cache-warm tool surface any MCP host can drive — Claude Desktop, LangGraph, a raw loop. | Every ask your app can't serve becomes a token-lean gap row — a demand-driven backlog. | A drift harness that fails CI when the graph and the app stop agreeing. |
| → Serve to any agent ↓ | → The gap loop ↓ | → Keep the graph true ↓ |
🔌 Serve it to any agent — the MCP surface
The tool array the LLM sees holds one tool per skill plus three fixed generics (whats_here, do_action, why),
and it never changes 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 skill
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.
One turn, end to end
A single request, "find a red dress under $150 and buy it," flows through five parties. The navigation graph is what makes each step position-aware — the session only ever offers what is fireable at the current cursor.
sequenceDiagram
actor U as User
participant A as Agent (LLM)
participant T as skillsAsTools port<br/>(fixed MCP tools)
participant S as InteractionSession<br/>+ navigation graph
participant App as Your app<br/>handlers · store · router
U->>A: find a red dress under $150 and buy it
Note over A,T: The tool set is FIXED for the whole conversation — the cache stays warm
A->>T: shop.skill.find-dress (no step yet — open it)
T->>S: commitSkill find-dress
S-->>T: readySteps = search-dresses (disclosure rides the RESULT)
T-->>A: readySteps + youAreOn
A->>T: shop.skill.find-dress step=search-dresses input=red
T->>S: fire catalog.search-dresses
S->>App: run your handler shop.search — as the signed-in user
App-->>S: store tap updateState + the matched dresses
S-->>T: did + readySteps=view-dress + data=dresses
Note right of S: results ride the DATA channel — untrusted text, never instructions
T-->>A: the dresses to pick from + the next ready step
A->>T: shop.skill.purchase step=place-order
T->>S: fire checkout.place-order (high-effect)
S-->>A: judgment = needs-confirm — never auto-crossed
A->>U: Approve placing the order?
U-->>A: approve
A->>T: shop.skill.purchase step=place-order confirm=true
T->>S: fire checkout.place-order
S->>App: shop.placeOrder
App-->>A: Ordered the Floral Wrap Dress — $120
The load-bearing moves, in order: the tool set never changes, so the prompt cache stays warm; the session answers "what can I do here?" from the graph at the current cursor, not a DOM dump; firing runs your own handler as the signed-in user; produced data comes back on the data channel (so untrusted content can't become instructions); and a high-effect step stops for human approval before it ever fires.
What each turn actually sends to the model
Every turn is one Messages API request with three channels:
POST /v1/messages
├── system "You are the shopping assistant… here is how to work…" ← authored by YOU, only
├── tools[] shop.skill.find-dress · shop.skill.purchase · …
│ shop.whats_here · shop.do_action · shop.why ← FIXED — identical every turn
└── messages … prior turns …
tool_result { readySteps: […], data: [dress names…] } ← the app's DATA lands here
user: "find a red dress under $150 and buy it" ← the user's own text, as-is
Two things worth making explicit:
- The user's message is passed as-is. It's the operator talking to their own assistant, so it belongs in the instruction channel — nothing is stripped or paraphrased.
- App content never enters the instruction channel. Product names and search results ride only inside
tool_resultdata — never thesystemprompt, never a tool'sdescription. So a dress literally namedIGNORE PREVIOUS INSTRUCTIONS…reads as harmless data. That's the two-string-class firewall.
The model never receives a growing list of tools. It picks from the fixed array and passes a step name —
read from readySteps in the previous result — as an argument. The tool set is constant; only the step
changes. That's exactly what keeps the cache warm and lets any host serve it.
After a write-step, re-read whats_here. A step that changes state settles asynchronously — fire()
returns settlement: 'awaiting-state' and the step stays pending until your app reports the new state back
through updateState(). Until it does, the step that just fired is held out of readySteps (so the model
can't double-fire it), and any step that depends on its write isn't ready yet. So an agent re-reads
whats_here (or re-opens the skill) after a write-step, rather than trusting the readySteps from the
write's own result.
Plug into any framework — or run a real MCP server
hcifootprint is not tied to any agent framework. The library hands your host two functions:
const port = skillsAsTools(session);
const tools = port.tools(); // fixed, MCP-shaped: { name, description, inputSchema }[]
// → register `tools` with your agent (LangGraph, LangChain, an Anthropic/OpenAI loop, …)
// then, inside each tool's executor, route the model's tool_use to:
const result = port.call(toolName, toolInput); // → return `result` as the tool_result
That's the whole integration — tools() + call(). To expose it as a real MCP server any client
auto-discovers, there's a one-liner:
import { mcpServer } from 'hcifootprint/mcp';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const server = mcpServer(session); // tools/list + tools/call, wired to the session
await server.connect(new StdioServerTransport()); // or an SSE / streamable-HTTP transport
mcpServer returns a standard @modelcontextprotocol/sdk Server, so you pick the transport and run it
wherever your session lives. Over MCP, a high-effect step returns judgment: 'needs-confirm' and the host
decides how to get approval before calling again with confirm: true — a portable human-in-the-loop that
needs no framework-specific pause/resume. The SDK is an optional peer dependency, imported only behind the
hcifootprint/mcp subpath, so the core stays zero-dependency and you pull it in only if you use it.
This cooperates with Anthropic's MCP rather than competing with it: the skill graph is the server, and any MCP host drives the same live session.
🕳️ The gap loop — grow the app around real demand
You don't have to build the whole agentic experience up front. Ship a thin skill graph, then let real demand tell you what to build next.
Your UI is the boundary of what an agent can do. When a user asks for something the UI can't serve, HACI Footprint doesn't just fail — it records a gap: a token-lean, structured row (the ask, the position, what was available) wired to your telemetry. The agent calls the gap tool before it apologizes, so every miss becomes a ledger row your team reads. Cluster those rows and you have a demand-driven backlog for your agentic app.
A concrete one: two customers check out at the same moment, and a race lets one order through while the other
fails. The loser asks the assistant "why did mine fail?" — but that reason is in your backend logs, not
the UI. The agent can't answer, so it files a gap with reason needs-backend-data. Now you know precisely
what to build for the next release — a small UI report, or a backend tool the agent can call. You grow the
app by locating the missing data, not by guessing at features.
session.gaps(); // export the whole ledger to your analytics / triage
session.onGap((row) => { … }); // or stream rows live (sugar for session.on('gap', …))
Rows are deliberately structured and name-only — the ask plus lists of available action/skill names, never descriptions or transcripts — so a batch triage LLM can cluster thousands of them cheaply.
🛟 Human-in-the-loop
High-effect actions (confirm: true in the graph) stop at a needs-confirm gate and are never
auto-crossed. The agent must ask the human, then call again with confirm: true. Two ways to run the
approval:
- Over MCP — the gate is just data in the result (
judgment: 'needs-confirm'), and the host decides how to collect the yes. Portable, framework-free. - In-process — pause the agent's ReAct loop on a checkpoint, hand control to the human, and resume exactly where it stopped. The demo implements this with agentfootprint's pause/resume checkpointing.
Either way, the gate is enforced at the session, so an agent can't fire a high-effect action without a real approval.
🔒 Honest by construction
Two properties do most of the safety work:
- It says what it can't see. Anything the runtime derives rather than observes is flagged —
activation: 'assumed',presence: 'unknown',guardUnevaluated. Every refused action returns a typed reason (BLOCKED_BY_OVERLAY,STILL_MOUNTING,GUARD_FAILED, …) and is logged, so the agent replans instead of hallucinating. - Untrusted content can never become instructions. Page text, product names, and user content ride a strict data channel; only your authored strings reach the planner's instruction channel. It's a firewall against prompt injection — proven in the demo by a product literally named
IGNORE PREVIOUS INSTRUCTIONS…, which renders as harmless data everywhere.
Under the hood, every action lands in a real footprintjs commit
log, so session.why(key) gives a causal answer to "why is the app in this state?" with zero extra code.
Guard semantics (read this before writing a projector)
A guard (when:) is a flat filter over your projected state — key: { op: value }, ANDed across keys,
operators eq / ne / gt / gte / lt / lte / in / notIn. There is no $or and no nesting; the filter is
deliberately flat. To express "A or B", derive a boolean in your projector and guard on that:
session.updateState({ canCheckout: cart.length > 0 || savedOrder != null });
// then author: when: { canCheckout: { eq: true } }
A guard key the projected state has never contained is not treated as false. The edge is served anyway,
carrying a guardUnevaluated marker — the condition is flagged as taken on faith, with the app still the
enforcer. (A key that is present but fails its test hides the edge, as you'd expect.) The reason: absence of
data must not masquerade as false. That honesty is what lets one authored graph work before every key is
wired — but it also means an under-seeded projector quietly turns real decisions into flagged guesses.
So seed every guard key up front. graph.requiredStateKeys() returns exactly that set — every key your
guards and skill preconditions read, sorted and deduped — as the checklist for your state projector.
✅ Keep the graph true — the drift harness
The navigation graph is a second artifact you keep alongside the real app, so it drifts as the app
changes: a button's disable rule moves, a page is removed, a handler starts writing different state.
hcifootprint/testing catches that drift in dev and CI, before production. It adds zero dependencies,
is tree-shakeable, and drives the real session (never a copy), in two layers.
1. lintGraph / checkGraph — static, no test code. Read the graph alone and report stale logic: a
control gated on state nothing produces, a guard that can never be true, a skill that can never finish, a page
nothing can reach. This is the cheap CI gate. It's advisory by default and only escalates to hard errors
once you tell it the starting state — it never cries "dead" over a key it can't see.
import { lintGraph, checkGraph, expectNoStaleLogic } from 'hcifootprint/testing';
lintGraph(graph); // → findings you can inspect
expectNoStaleLogic(graph, { initialState }); // → throws in CI if the graph drifted
// Or the one-call health verdict — findings grouped by drift type + a printable report:
const health = checkGraph(graph, { initialState });
if (!health.ok) { console.error(health.summary); process.exit(1); }
checkGraph is static and pure — import it from hcifootprint/testing/lint for a CI step that loads no
engine code at all. Every finding names what drifted and where, and states the two remedies (update
the graph, or fix the app that changed by mistake). It surfaces the drift; the fix is your team's call.
2. testApp — "Playwright for your interaction logic, minus the browser." Write mock handlers (one per
action, returning a state change), then drive the graph as a user (clicking) or as the agent (the
real Mode B tool path). The library's own honesty marker, effectVerified, flips false when a handler no
longer does what the graph declares — that is the behavioral-drift alarm. Report by default; pass
strict: true to fail the instant drift appears.
import { testApp } from 'hcifootprint/testing';
const app = testApp(graph, {
initialState: { cartCount: 0 },
resolvers: { 'add-to-cart': (_p, { state }) => ({ patch: { cartCount: state.cartCount + 1 } }) },
});
await app.user.fire('add-to-cart'); // drive like a human
app.expectState({ cartCount: 1 });
await app.agent.skill('purchase', { step: 'go-to-cart' }); // drive like the LLM
app.expectOn('cart');
app.report(); // { ok, effectDrift, unevaluatedGuards, gaps }
Honest boundary (say it out loud). This tests interaction logic above the binding — guards, skills,
navigation, effect claims, typed rejections. It does not verify pixels, the DOM, or that a binding
resolves to a real element — that stays Playwright's job, and this complements it rather than replacing
it. A green lintGraph proves the graph is internally consistent, not that the app works: it reasons
about which state keys move, never their values, so a right-key/wrong-value bug is the harness's job
(effectVerified), not the linter's. And a mock is a simulation — if it diverges from the real handler, the
test is green while prod is broken. For full fidelity, pass testApp({ session }) with your own wired
session.
💰 Bounded token cost
Naively, "give the LLM your app" means dumping every action into the prompt — and the bill grows with your codebase. HACI Footprint doesn't do that:
- The skill graph loads only what the current position needs, not the whole app.
- The tool list stays fixed (one tool per skill); what's fireable right now travels in tool results, not by rewriting the tool array. So the prompt cache stays warm across the whole conversation.
Token cost tracks the task at hand, not the size of your codebase.
Frontend: framework-agnostic
The core is plain TypeScript and knows nothing about your framework — you connect it through three ordinary wires (a store subscription, router events, your existing handlers). The dress-shop demo shows the whole wiring on a real storefront with an AI stylist.
On the roadmap: a React adapter (useMount) and a DOM actuation adapter (act through the real DOM), plus
a demo gallery — the same app wired in Angular, React, Vue, and an iframe, shown as pills on one index page.
One graph, many frontends.
The model — Affordance & Transition
For the curious, the whole thing rests on two atoms:
Affordance = binding × guard × effect × schema (the static capability)
Transition = cause × payload × outcome (each occurrence)
binding— how to reach the surface (ARIA role + name first).guard— a serializable filter over projected state that decides what's offered.effect— a claim about your handler, verified at settlement (effectVerified).cause— who did it:user,agent, orsystem. First-class provenance for accountability between cooperating agents — not a security boundary; enforce that server-side.
Deeper topics — on-demand disclosure (skill frames), the context brief, the navigation-graph semantics
(modals / tabs / repeats), and the act→data-back channel (session.producedFor) — are documented inline in
the source (src/*/README.md) and in docs/design/.
Live demo — the dress shop, in three commits
The dress-shop demo tells the pitch as a diff, because the diff is the demo:
| Commit | What it adds | What it proves |
|---|---|---|
| 1 | The plain store — subscribers, a tiny router, handler methods. | The unbiased baseline, built with zero knowledge of any agent layer. |
| 2 | The agent layer (this library): the declared graph + three wires. | git diff of the app's own code → empty. Position-aware tools, guards mirroring the app's invariants, hostile catalog text confined to the data channel. |
| 3 | The assistant (agentfootprint + Claude) driving the same session. | The whole family in one loop, with human-in-the-loop by checkpoint on order placement. |
git clone https://github.com/footprintjs/hcifootprint-demo && cd hcifootprint-demo/dress-shop
npm install
npm test # commits 1+2: the app's tests + the integration proof (no API key)
cp .env.template .env # add your ANTHROPIC_API_KEY
npm run chat # commit 3: the assistant in your terminal
npm run serve # the storefront in a browser — click OR chat, same session
npm run drift # watch the drift harness catch a deliberately-drifted graph
npm run serve also ships an agent debugger at /debug — powered by
AgentThinkingUI, it drives the same live session as the
storefront, so you chat and watch the agent reason beat-by-beat (whats_here → open a skill → fire steps →
the answer).
Development
npm install
npm test # vitest — 273 tests
npm run typecheck # src + tests
npm run build
Depends on footprintjs ≥ 9.10 (evaluateFilter / normalizeSchema exports). Sibling of footprintjs
(backend logic) and agentfootprint (agents) — one self-explaining trace substrate underneath.
Built on
footprintjs — the flowchart pattern for backend code. Every
action a session fires lands in a footprintjs commit log, which is what makes session.why(key) a real
backward causal slice rather than a guess. You don't need to learn footprintjs to use this library, but if you
want to build primitives at that depth, start there.
Citing
hcifootprint is part of a research program on making software systems explain themselves — every run records why it did what it did, as a causal trace. Researching agent transparency, observability, or explainable AI? The ecosystem map is a good starting point.
If you use hcifootprint in academic work, please cite it (or use the "Cite this repository" button on GitHub):
@software{anbalagan_hcifootprint,
author = {Anbalagan, Sanjay Krishna},
title = {hcifootprint: web apps as typed skill graphs agents can operate},
url = {https://github.com/footprintjs/hcifootprint},
license = {MIT},
year = {2026}
}
License
MIT © Sanjay Krishna Anbalagan
No comments yet
Be the first to share your take.