Demo to App
The same agent, from your laptop to production.
Write it against a scripted model, debug it from its own record, deploy it on your infrastructure one adapter at a time, and monitor what it does.
01 · Build
Start with a scripted model. Then bring the real one.
Test the wiring for free
A scripted model is free, offline and the same every time. Use it to prove the tools, the context and the control flow work. Most bugs show up here.
Testing with mock()import { Agent, defineTool } from 'agentfootprint';import { mock } from 'agentfootprint/providers'; const getRefund = defineTool<{ orderId: string }, unknown>({ name: 'get_refund', description: 'Look up a refund by order id.', inputSchema: { type: 'object', properties: { orderId: { type: 'string' } }, required: ['orderId'] }, execute: ({ orderId }) => ({ orderId, status: 'pending' }),}); // A scripted model: free, offline, and the same every time.const provider = mock({ replies: [ { toolCalls: [{ id: 'c1', name: 'get_refund', args: { orderId: 'A-1042' } }] }, { content: 'Your refund for A-1042 is still pending.' }, ],}); const agent = Agent.create({ provider, model: 'mock' }) .system('You answer refund questions.') .tool(getRefund) .build(); const answer = await agent.run({ message: 'Where is my refund for A-1042?' });Bring the real model to tune its reasoning
A scripted model can’t tell you how a real one reasons. So change the provider and model, and leave the rest of the agent alone. A local model through ollama() works the same way.
const agent = Agent.create({ provider: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }), model: 'claude-sonnet-5-5',}) .system('You answer refund questions.') .tool(getRefund) .build();As it grows
Turn the runbook into a path.
One state, one reachable skill. With tool scoping on, the selected skill brings its procedure, tools and model into the step. To run a written procedure as one tool, use runbookAsTool.
Route
Keep the payload out of the prompt.
For stored results, the model gets a ticket. Your product redeems the real data.
Follow the two lanes →What it knows
Give it a map of your data
When a result doesn’t hold what a question needs, the map tells the model what each term means, which source holds it and which tool reads it. So it can say where the answer would be, instead of guessing.
It’s a map, not a way to fetch. A tool still reads the data.
The ontologyimport { Agent } from 'agentfootprint';import { defineOntology } from 'agentfootprint/ontology'; const map = defineOntology({ id: 'fleet', version: '1', sources: { inventory: { meaning: 'the switch inventory export', configured: true }, }, nodes: { port: { meaning: 'a physical switch port', sources: [{ source: 'inventory', via: ['lookup_port'], coverage: 'every port on every switch' }], }, port_error_rate: { meaning: 'CRC errors per minute on a port', unit: 'errors/min' }, }, edges: [{ from: 'port_error_rate', to: 'port', relation: 'measured-on' }],}); const agent = Agent.create({ provider, model: 'mock' }).tool(lookupPort).ontology(map).build();Pass a ticket, not the rows
Past a size you set, the store keeps a tool’s result and the model gets a short ticket. A tool that declares wants receives the data behind the ticket, so 48,000 rows never enter the prompt.
import { Agent, defineTool, inMemoryArtifacts } from 'agentfootprint'; // 48,000 rows. Past the threshold, the store keeps them and the model gets a short ticket.const getSales = defineTool({ name: 'get_sales', description: 'Fetch the Q3 sales rows (large).', inputSchema: { type: 'object', properties: {} }, execute: () => Array.from({ length: 48_000 }, (_, i) => ({ order: i, amount: (i % 97) + 1 })),}); // This argument is a ticket. The tool receives the data behind it.const summarize = defineTool<{ sales: string }, string>({ name: 'summarize', description: 'Summarize sales rows.', inputSchema: { type: 'object', properties: { sales: { type: 'string' } }, required: ['sales'] }, wants: { sales: 'tool-result/get_sales' }, execute: ({ sales }) => { received.push((JSON.parse(sales) as unknown[]).length); return 'summary'; },}); const agent = Agent.create({ provider, model: 'mock', artifacts: { store: inMemoryArtifacts(), placement: { maxInlineChars: 2_000 } },}) .tool(getSales) .tool(summarize) .build();Give it a clock
“Errors since 8” means nothing without a clock: which day, which zone, which eight. Declare it for each run, and it goes on the record along with where it came from.
Timeimport { Agent } from 'agentfootprint'; const agent = Agent.create({ provider, model: 'mock' }) .time({ zone: 'America/Los_Angeles' }) // the fallback, and each run's own zone wins .build(); await agent.run({ message: 'Any errors since 8?', time: { now: '2026-11-05T17:30:00Z', zone: 'America/New_York' },}); const clock = agent.findings()?.find((row) => row.kind === 'clock');// { kind: 'clock', now: '2026-11-05T17:30:00Z', nowSource: 'app', zone: 'America/New_York', zoneSource: 'run' }02 · Debug
Turn on the tools that read the run.
This is what to switch on. For how the walk back from a wrong answer works, see How we debug.
Let it read its own log
One line mounts a skill whose tools read the agent’s own record. They stay out of the tool list until someone asks why, and they read the previous turn’s evidence.
Ask your agent whyconst agent = Agent.create({ provider, model: 'mock' }) .system('You are a refunds assistant.') .tool(getRefund) .selfExplain() // one line, and the trace tools stay hidden until someone asks .build(); await agent.run({ message: 'Should order A-1042 be refunded?' }); // The next turn asks about the last one. It answers from its own record.const why = await agent.run({ message: 'Why did you approve it?' });Or point a second agent at a saved run
traceDebugAgent() works from a saved recording, so it can be a cheap model in its own session, long after the run has finished.
import { openRecording, recordRun, traceDebugAgent } from 'agentfootprint/observe'; const recorder = recordRun(agent);await agent.run({ message: 'Where is my refund for A-1042?' });const artifacts = openRecording(recorder.toRecording()); // A second agent, whose only tools read that saved run.const debuggerAgent = traceDebugAgent({ artifacts, provider, model: 'mock' });const report = await debuggerAgent.run({ message: 'Why did it say pending?' });See it in Lens
Three lines of wiring give you a React view of the run. One recorded run, read five ways:
- Flow What did it actually run?
- Why Why did it decide that?
- Story Can someone else follow it?
- Skill graph What could it even reach?
- Data graph Where did the numbers come from?
import { useMemo } from 'react';import { Agent } from 'agentfootprint';import { mock } from 'agentfootprint/providers';import { Lens, lensRecorder } from 'agentfootprint-lens'; export function App() { const { agent, recorder } = useMemo(() => { const agent = Agent.create({ provider: mock({ reply: 'Hi!' }), model: 'mock' }).build(); const recorder = lensRecorder(); recorder.observe(agent); return { agent, recorder }; }, []); return ( <> <button onClick={() => agent.run({ message: 'Hello!' })}>Run</button> <Lens recorder={recorder} runner={agent} /> </> );}Bring your own evals
The library ships no grader, so that part stays yours. It gives you the record to grade, a typed event to file the score, and a way to compare two versions under the same baseline.
Compare two strategiesagent.on('agentfootprint.eval.score', (event) => { scores.push(event.payload.value);}); const answer = await agent.run({ message: 'Where is my refund for A-1042?' }); // Your grader decides the score. The library only carries it.agent.emit('agentfootprint.eval.score', { metricId: 'refund-accuracy', value: myGrader(answer), target: 'run', targetRef: 'this-run', evaluator: 'heuristic',});03 · Deploy
Your infrastructure, through adapters.
Everything the agent touches is a port, and each adapter fills one port for one backend. It grows by adding adapters, and the agent you wrote doesn’t change. The library provisions nothing: you bring the runtime.
Bind
One graph. Your stack.
Pick the ground and the adapters resolve. The agent you wrote does not move.
Ports and adapters →Memory, sessions and runtime, on two clouds
The same three things change, and the agent’s shape doesn’t. Both samples are type-checked, not run, because they need a cloud account.
import { Agent } from 'agentfootprint';import { agentCoreRuntimeHost, agentCoreSessions, standingAgent } from 'agentfootprint/hosting';import { AgentCoreStore, MEMORY_STRATEGIES, MEMORY_TYPES, defineMemory } from 'agentfootprint/memory';import { bedrock } from 'agentfootprint/providers'; const memory = defineMemory({ id: 'conversation', type: MEMORY_TYPES.EPISODIC, strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 }, store: new AgentCoreStore({ memoryId: process.env.AGENTCORE_MEMORY_ID!, region: 'us-west-2' }),}); await standingAgent({ agentFactory: () => Agent.create({ provider: bedrock({ region: 'us-west-2' }), model: 'bedrock' }).memory(memory).build(), host: agentCoreRuntimeHost(), sessions: agentCoreSessions({ store: 'session-storage' }),});import { Agent } from 'agentfootprint';import { agentEngineSessions, nodeHost, standingAgent } from 'agentfootprint/hosting';import { MEMORY_STRATEGIES, MEMORY_TYPES, defineMemory, memoryBankStore } from 'agentfootprint/memory';import { gemini } from 'agentfootprint/providers'; const vertex = { project: process.env.GOOGLE_CLOUD_PROJECT!, location: 'us-central1', reasoningEngine: process.env.REASONING_ENGINE_ID! }; const memory = defineMemory({ id: 'conversation', type: MEMORY_TYPES.EPISODIC, strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 }, store: memoryBankStore(vertex),}); await standingAgent({ agentFactory: () => Agent.create({ provider: gemini({ project: vertex.project, location: vertex.location }), model: 'gemini' }).memory(memory).build(), host: nodeHost({ port: Number(process.env.PORT ?? 8080) }), sessions: agentEngineSessions(vertex),});Where each port has an adapter today
| It touches | AWS | Google Cloud | Azure | Your machines | Or write your own |
|---|---|---|---|---|---|
| Models | bedrock | gemini | azureOpenaifoundry | ollamafoundryLocal | LLMProvider |
| Embeddings | bedrockEmbedder | geminiEmbedder | write one | localEmbedderstaticEmbedder | Embedder |
| Memory | AgentCoreStores3VectorsStore | memoryBankStore | write one | RedisStorepgVectorStoresqliteVectorStore | MemoryStore |
| Sessions | agentCoreSessions | firestoreSessionsagentEngineSessions | write one | sqliteSessions | SessionLifecycle |
| Hosting | agentCoreRuntimeHost | nodeHostrecipe | foundryResponsesHost | nodeHosthttpHost | AgentHost |
| Credentials | agentCoreIdentity | googleIdentity | entraIdentity | vaultCredentialsstaticTokens | CredentialProvider |
| Telemetry | xrayObservabilitycloudwatchObservabilityagentcoreObservability | otelObservabilityrecipe | write one | otelObservabilityfileObservability | ObservabilityStrategy |
| Tools | mcpClient wherever the agent runs | McpClientToolProvider | |||
| Permissions | PermissionPolicy wherever the agent runs | PermissionChecker | |||
A recipe is a documented setup, not a dedicated adapter. Stores that speak a protocol, like RedisStore and pgVectorStore, can point at a managed Redis or Postgres on any cloud. The full map for each place: AWS, Google Cloud, Microsoft Foundry, on-premises. A session store you write can be tested with runSessionLifecycleConformance.
No adapter yet? Write one.
New model, store or tracing backend next year? Write one adapter, and your agent code stays the same. This is a whole model adapter, and the record keeps working with it.
Not every port is this small: MemoryStore has eleven required members. And something no port or moment covers would still need a change to the library.
const myModel = { name: 'my-model', complete: () => callMyModel(), // reach your model however you like}; const agent = Agent.create({ provider: myModel, model: 'any' }).build();Connect your front end
AG-UI frontends such as CopilotKit POST one request and read one stream of typed events. One handler serves them, with a fresh agent per request, the conversation kept on the server, and threads keyed by the verified caller.
AG-UI frontends/** * WHO is calling — proven, never read off a header. A real app passes its IdP: * `jwksIdentity({ jwksUrl, issuer, audience })` from `agentfootprint/security`. * This demo verifier knows one token. */const verify = async (token: string) => { if (token !== DEMO_TOKEN) throw new Error('unknown token'); return { userId: 'ada' };}; /** The whole server side: one handler, mounted at any route of any framework. */function serve(provider: LLMProvider) { return streamHandler({ agent: ({ tools }) => buildAgent(provider, tools), // fresh per request adapter: agUi(), // No valid token → 401. The thread is keyed by the VERIFIED caller, so the // same thread id from someone else is someone else's conversation. threads: verifiedThreads({ verify }), sessions: memorySessions(), // sqliteSessions({ file }) to survive a restart }); // Next.js: export const POST = serve(provider); // Hono: app.post('/agent', (c) => handler(c.req.raw)); // Bun/Deno: Bun.serve({ fetch: handler }) · Deno.serve(handler)}04 · Monitor
Watch it, ship the events, and keep your own.
Native events, OpenTelemetry and the rest
Listen to typed events directly, or send the same stream to OpenTelemetry, CloudWatch, X-Ray or AgentCore. Detach delivery with a driver, and a sink’s work moves off the agent’s call path.
Exportersimport { otelObservability, xrayObservability } from 'agentfootprint/observe';import { setImmediateDriver } from 'footprintjs/detach'; // Native: typed events, as the agent runs.agent.on('agentfootprint.stream.tool_end', (event) => console.log(event.payload)); // Any OTLP backend, through your own OpenTelemetry tracer.agent.enable.observability({ strategy: otelObservability({ serviceName: 'refunds', tracer }), detach: { driver: setImmediateDriver, mode: 'forget' }, // off the agent's call path}); // Or straight to AWS X-Ray.agent.enable.observability({ strategy: xrayObservability({ region: 'us-west-2', serviceName: 'refunds' }) });Your own recorder, for anything specific to your product
The built-in recorders work the same way. Yours sees every state write as the run happens, and your own events reach your listeners and exporters alongside the library’s.
Observability// A recorder of your own sees every state write as the run happens.const refundWatch: CombinedRecorder = { id: 'refund-watch', onWrite: (event) => written.push(event.key),}; const agent = Agent.create({ provider, model: 'mock' }).watch(refundWatch).build(); // Your own events travel the same stream as the library's.agent.on('*', (event) => { const { type, payload } = event as { type: string; payload: unknown }; if (type === 'myapp.refund.flagged') alertTeam(payload);});agent.emit('myapp.refund.flagged', { order: 'A-1042' });Keep the whole run
For proof that a record hasn’t changed since it was captured, see auditing.
Archive a runrecordRun()captures state, events and structure together.persistRecording()writes the versioned envelope to your sink.contextLedger()adds up which skills, tools and injections earned their context.
The family
The libraries around it.
- Underneath it
footprintjsThe flow engine. It records each stage as the run happens. - Underneath it
foottraceReads a saved record: replay, time travel and attribution. - Underneath it
contextfootprintCompares claims for conflicting values, for the context integrity checks. - To see a run
agentfootprint-lensReact components for inspecting a recorded agent run. - To see a run
footprint-explainable-uiReact components for a run’s stages, its state changes and time travel.
Why TypeScript on Node.js
An agent mostly waits on the network. Node.js was built for that.
JavaScript began in the browser. Node.js took its event loop to the back end, for servers that spend their time waiting on other services. An agent spends its time waiting on models and tools. Node.js describes itself as designed for scalable network applications.
- It waits well. One event loop holds many model and tool calls open, and each stage commits in order with no locks.
- It reaches the screen. The same engine runs on the server and in the browser, so the record can reach the person reading the answer.
- It captures by itself. Reads and writes are tracked through a JavaScript Proxy, so stages need no annotations to be stitched.
This is a fit, not a claim that it is the best language. Python stays the usual home for training and data, so your tools and trainers can stay in their own language, over MCP or as sidecars.
