Infrastructure

Hosting & runtime

The ports between an agent and the place it runs — AgentHost, ConversationHost and SessionLifecycle, none of which names a cloud. Plus httpHost/HttpWire for a second HTTP adapter, three reply terminals, three durability modes with stated crash semantics, the human-in-the-loop resume loop, and the three ways to serve many people at once.

An agent in a script answers once and forgets. A deployed one has to do two more things: be reachable, and continue a conversation it started before the last restart. Those two things are usually where a framework quietly becomes one vendor's framework.

Everything on this page exists so that does not happen here.

The two ports

import { standingAgent, nodeHost, memorySessions } from 'agentfootprint/hosting';

const handle = await standingAgent({
  agent,
  sessions: memorySessions(),
  host: nodeHost({ port: 8080 }),
  shutdownOn: ['SIGTERM'],   // 8.12.0 — optional; see below
});

shutdownOn is opt-in on purpose. Adding a SIGTERM listener is not observation: Node's default action for that signal is to terminate, and any listener suppresses it — so a library that installs one behind your back can turn a container's graceful stop into a wait for SIGKILL. A composition root may ask; it must not assume. When you do pass it, this composer closes the host, drains the agent's telemetry, removes its own listeners and re-raises the signal, so the process ends the way the platform meant it to. Prefer to keep the wiring yourself? Leave it out and write the line you always wrote:

process.on('SIGTERM', () => void handle.close());

That is a live agent on port 8080 that remembers each caller's conversation. Three ports, one composer, and one seam for writing your own HTTP adapter:

TypeWhat it saysMembers
AgentHostsomething can call mename, capabilities, serve(handler)
ConversationHostsomething can talk to me, both ways, until one of us ends itname, capabilities, conversationLimits?, serveConversations(handler)
SessionLifecyclethe conversation outlives the requesthydrate, persist, onWake?
HttpHost / HttpWirethe HTTP work, parameterised by the JSON dialect it speakstwo paths + five body shapes
standingAgentthe composerhydrate → resume-or-fresh → persist → reply

SessionLifecycle is deliberately two required methods:

Anything a real store also wants — a TTL, a scan, a delete — is that store's own API, not a demand this port makes of every store that will ever implement it.

(ConversationHost is the newest and has its own section — skip to it if the caller you have to serve cannot be called.)

The reply's terminals

HostReply ends exactly once, and a second call is ignored rather than allowed to corrupt the wire. A run has three ends and only three:

TerminalMeaningNever
complete(output)It answered
awaiting(pending)It stopped to ask a person something. The paused run is stored; a later request carrying decision continues itnot a failure — an adapter mapping this onto a 5xx, an error counter or a dead-letter queue is telling every dashboard it feeds something untrue
fail(error)It broke

The fourth terminal, artifact(result), ends a request that never was a run: a screen redeeming a claim ticket by ref (9.23.0).

awaiting, artifact and emit are optional on the type so a minimal adapter need not implement them; every shipped adapter does. When awaiting is absent the composer still stores the paused run — the store is not the transport's business — and ends the reply with a named refusal instead, so a pause is never lost merely because the wire could not describe it. That refusal is ERR_PAUSE_NOT_CARRIED, and it is the smallest complete example of how these ports are written (artifact gets the same treatment: absent ⇒ ERR_ARTIFACT_NOT_CARRIED, named, never silence).

/** Serve one agent, with per-session memory, on plain HTTP. */async function serve(agent: Agent, port: number) {  return standingAgent({    agent,    sessions: memorySessions(), // swap for Redis; nothing else changes    host: nodeHost({ port, hostname: '127.0.0.1' }),    // 'reject' (the default) refuses a SECOND turn of the SAME conversation    // while the first is still running. A different session is never refused —    // it waits its turn.    onConcurrentInvoke: 'reject',  });}

Why the ports look like nothing in particular

Deliberately. Not one field, name or assumption in AgentHost or SessionLifecycle comes from any hosting product, cloud or protocol.

A port shaped around one provider's request envelope stops being a port and becomes that provider's SDK with extra steps — and every adapter after the first pays for the shortcut. So the ports speak only the vocabulary every transport already has. A HostRequest carries an input, an optional sessionId, an optional userId, headers and a signal. A HostReply has complete, emit and fail.

userId (9.12.0) is the one field worth a sentence of its own, because it is the one a reader is most likely to confuse with the field beside it: a sessionId is a thread and a userId is a person, and an audit trail that reports the first where the second belongs names the wrong party. Only a wire whose transport actually authenticates the caller fills it in — the AgentCore Runtime adapter reads it from the header that runtime forwards; the generic JSON wire reads no such header, because on a container anybody can POST to, a header is a string anybody can send. When it is there, standingAgent makes it the run's identity.principal, which is what puts a real person on EventMeta.principal and in ctx.identity. When it is not, nothing is invented. A HostHandler is a function from one to the other, and HostHandle has exactly one method, close(). That is the whole surface.

Everything specific to where you deploy lives in the adapter for that place. nodeHost gets no special treatment either: its paths, its JSON body shape, its status codes and its SSE framing are all in its own file, and the port types do not know they exist. A test greps the hosting sources for cloud vendor names, crudely and on purpose — including for one particular runtime's /invocations path literal, which very nearly became this adapter's default by inheritance rather than by decision.

This is what makes a cloud adapter cheap later — and here is the receipt

A hosting adapter for someone's container runtime is close to nodeHost with that runtime's paths and a header mapping. If writing it needs a change to a port, the port was wrong.

That claim now has a receipt. agentCoreRuntimeHost (7.15.0, agentfootprint/hosting) is a real cloud runtime's container contract — different paths, different body fields, the conversation id in a header — and it passes the conformance suite below with no change to any port type. See AgentCore adapters.

One thing did have to move, and it is worth naming: nodeHost had hard-coded its own JSON dialect, which was fine while it was the only HTTP adapter and wrong the moment there was a second. The HTTP work now lives in httpHost, and both adapters are configurations of it.

Capabilities are read, never assumed

AgentHost.capabilities is a list of HostCapability — today two names, 'streaming' and 'conversation', because those are what a shipped adapter can actually honour. Names are not pre-minted for transports that do not exist yet; a capability nobody implements is a promise the library cannot keep.

Branch on it, or insist on it with requireCapability(host, 'streaming'), which throws a corrective error naming the adapter you are actually holding:

import { requireCapability } from 'agentfootprint/hosting';

if (host.capabilities.includes('streaming')) { /* … */ }  // detect
requireCapability(host, 'conversation');                  // or insist

Why a capability is decided at construction, and what that ruled out

capabilities is a fixed array on the host object, read before anything is served. That has a consequence worth stating: a host that could only sometimes keep a promise cannot honestly declare it. When the conversation door was designed, the obvious shortcut was an optional peer dependency carrying a WebSocket implementation — and it was rejected on exactly this ground. The host would have had to either claim 'conversation' and then refuse to do it, or probe node_modules and make feature detection depend on install state. The shipped adapters honour what they declare, always, with nothing to install.

The handler itself does not need to care. reply.emit(chunk) is a preview of the answer; a host that streams delivers each piece as it arrives, and a host that cannot buffers them and lets the authoritative complete(output) settle the buffer. The pieces are never added to the answer — they were the same text. Handler code is identical either way: emit freely, complete once.

nodeHost streams when the caller asks for it with Accept: text/event-stream, and sends one JSON body otherwise. Same handler, same answer.

nodeHost — the plain HTTP adapter

Zero dependencies, node:http and nothing else.

nodeHost({ port: 8080 })                       // POST /invoke, GET /health
nodeHost({ port: 0, hostname: '127.0.0.1' })   // pick a free port
nodeHost({ invokePath: '/v1/messages', healthPath: '/healthz' })

NodeHostOptions takes port, hostname, invokePath, healthPath — plus sessionHeader / sessionCookie for where the session id comes from (see Concurrency & sessions), server, for when the socket has to be yours, and onUnhandled, for when the socket stays the host's and the spare routes are yours (both below). Both paths are options because a path is a deployment detail — usually dictated by whatever sits in front of your process — and a default that silently matched one vendor's container contract would be that vendor leaking in through the back door.

serve() resolves to a NodeHostHandle: a HostHandle that also reports the url and port it actually bound, which is the only way to find out when you asked for port 0. (NodeHost is the host type itself, narrowed to that handle. standingAgent passes it straight through, so composing costs you nothing the adapter told you.)

close() drains: work already in flight finishes, and anything arriving after is refused with a HostClosedError — over HTTP, a 503.

httpHost — writing your own HTTP adapter

nodeHost is httpHost plus two paths and a JSON dialect. If you have to serve somebody else's container contract, that is all you re-decide:

import { httpHost, headerValue } from 'agentfootprint/hosting';
import type { HttpWire } from 'agentfootprint/hosting';

const wire: HttpWire = {
  readRequest: (facts) => ({
    input: typeof facts.body.query === 'string' ? facts.body.query : '',
    sessionId: headerValue(facts, 'X-Conversation-Id'),   // case-insensitive
  }),
  health: (uptimeMs) => ({ up: true, uptimeMs }),
  output: (answer) => ({ answer }),
  failure: (message, code) => ({ message, code }),
  chunk: (piece) => ({ piece }),
};

const host = httpHost({ name: 'myRuntime', wire, invokePath: '/v1/run', healthPath: '/up' });

An HttpWire is pure: it reads HttpRequestFacts (body, lower-cased headers, parsed query) and returns values. It never touches the socket, never picks a status code, and never learns whether the reply is going out as one body or as SSE frames — those are identical for every wire, which is exactly why they are not its job. headerValue(facts, name, ...alternatives) is the case-insensitive header reader wires share.

HttpHostOptions is what httpHost takes; it returns an HttpHost, whose serve() resolves to an HttpHostHandle — a HostHandle that also reports the url and port it bound. (NodeHost and NodeHostHandle are aliases of those two, kept so existing code reads the same.)

invokePath and healthPath are required, with no defaults. A default here would be inherited by every adapter built on this file, and a default that silently matched one vendor's container contract is how that vendor leaks into a library that promises not to know about one.

jsonWirenodeHost's own dialect — is exported by name, so a deployment that needs those exact bodies with something else changed reuses it rather than retyping it and getting one field subtly wrong.

HttpHostOptions also takes server — a node:http server you own, for when the port cannot be the host's alone. That is the next section, and every adapter built on httpHost inherits it.

One port, two protocols — { server }

Some runtimes hand a container exactly one port. If the agent host owns that socket privately, a container that must also answer a WebSocket upgrade — or serve routes that were there first — simply cannot use it.

So don't give it the socket. Create the node:http server yourself, listen on it yourself, and hand it over: the host attaches its two routes instead of binding anything.

import { createServer } from 'node:http';
import { nodeHost, standingAgent, memorySessions } from 'agentfootprint/hosting';

const server = createServer();
server.on('upgrade', handleWebSocket);              // your protocol
server.on('request', yourOwnRoutes);                // your routes
await new Promise<void>((r) => server.listen(8080, '0.0.0.0', r));

const handle = await standingAgent({
  agent,
  sessions: memorySessions(),
  host: nodeHost({ server }),                       // ← binds nothing
});

This works because node:http calls every 'request' listener for every request. The host is one of them, and it takes only what it owns.

Four laws come with it, and each one is pinned by a test:

The host never writes a 404 on your serverA path it does not own is yours. Answering it with a refusal from the agent host would be the library answering for your application.
Your upgrade keeps workingUpgrades are a different event; attaching a request listener cannot touch them. This is the case the option exists for.
close() detaches and drainsIt removes its listener, waits out the requests it was already serving, and stops. Your socket stays listening, your connections stay up, your routes keep answering.
It never overwrites an answerIf a listener registered before it already answered, the host writes nothing.

The consequence of never writing a 404

A request no listener answers does not get a 404 from node — it hangs until the socket times out. On a shared server, unmatched paths are yours to answer, so give your server a fallback route if you want a 404 on them. This is the price of not answering for your application, and it is stated here rather than discovered at 3am.

A framework in front of the host may mean the host never sees the request

With a framework that installs a catch-all handler (Fastify, Express), the framework answers first and the attached host never sees the request — register the framework's routes as a delegation to the host, or let the host own the socket and use onUnhandled for your own routes.

A co-listener that reads the request before the host does is safe in one more way since 7.27.0: one that calls req.setEncoding(...) used to make the host's body reader throw inside a 'data'/'end' listener, where a throw is an uncaught exception rather than a failed request. Chunks are coerced now, and no listener body in the host can be the process's failure. Multi-byte text survives it — the decoder holds a partial character across a chunk boundary, and that is pinned by a test.

Two things are refused rather than guessed:

  • port or hostname beside server throws at construction. A server you own already has an address; a port here would name a socket this host does not bind, and silently dropping it leaves you believing something untrue about where your agent answers.
  • A server that is not listening yet is refused by serve(). The handle promises the url and port it is answering on, and a server with no address has neither. Call listen() first, then serve() — attaching after listen() is safe and is the intended order. (With a caller-owned server, handle.url and handle.port report your server's real address.)

httpHost({ server }) is where this lives, so every adapter built on it inherits it: nodeHost({ server }) and agentCoreRuntimeHost({ server }) are the same option.

Still the escape hatch, and still needed

A conversation door (below) means you no longer need { server } merely to get a WebSocket beside the agent — the two doors share a socket on their own. { server } remains what it always was: the way to serve anything the ports do not express, on a socket you control. A port is a paved road, not a wall.

/** The container's own server: an upgrade, a route of your own, one port. */function buildServer(): { server: Server; upgraded: Set<Duplex> } {  const server = createServer();  // An upgraded socket is yours to end, and `server.close()` will WAIT for it:  // it never ends one for you, and it does not finish while one is still open  // (`closeIdleConnections()` does not count it as idle either). So a graceful  // shutdown has to destroy your upgraded sockets itself — measured behaviour,  // and the reason the teardown below keeps this set.  const upgraded = new Set<Duplex>();  // Your protocol, on your port. A real deployment hands this to `ws`; the  // handshake is written out here so the example has no extra dependency.  server.on('upgrade', (_request, socket) => {    upgraded.add(socket);    socket.on('close', () => upgraded.delete(socket));    socket.on('error', () => undefined);    socket.write(      'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n',    );    socket.on('data', (chunk: Buffer) => socket.write(chunk)); // echo  });  // Your routes. The host never answers for these — including the 404 for  // anything neither of you claims, which is why this fallback exists.  server.on('request', (request, response) => {    if (response.headersSent) return; // the agent host already answered    const path = (request.url ?? '').split('?')[0];    if (path === '/metrics') {      response.writeHead(200, { 'content-type': 'application/json' });      response.end(JSON.stringify({ up: true }));    }  });  return { server, upgraded };}/** Attach the agent to a server that is already listening. */async function attachAgent(agent: Agent, server: Server) {  return standingAgent({    agent,    sessions: memorySessions(),    // No port, no hostname: this host binds nothing. Passing one anyway is    // refused by name rather than quietly ignored.    host: nodeHost({ server }),  });}

The full runnable version — an agent, a WebSocket echo and a /metrics route on one port, with the socket outliving the host's close() — is examples/deploy/one-port.ts.

Your routes on the host's socket — onUnhandled

The same seam, inverted. { server } is the right answer when you have a protocol of your own to serve. It is a lot of ceremony when all you wanted was a diagnostic route beside the agent on the one port the container was given.

So let the host bind the socket, and take everything it does not own:

const host = nodeHost({
  port: 8080,
  onUnhandled: (req, res) => {
    if (req.url === '/debug/trace') {
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify(lastTrace));
      return;
    }
    res.writeHead(404, { 'content-type': 'application/json' });
    res.end('{"error":"no such route"}');
  },
});

The law, stated once: the host never answers for your application — and with this hook it no longer has to 404 for it either. An unowned path arrives exactly as it came off the wire, as a node:http request and response, and what happens next is yours.

Five laws, each pinned by a test:

It receives exactly the paths the host does not ownEverything except invokePath, healthPath and conversationPath.
Owned paths never leak to itIncluding a wrong method on one — GET /invoke is still the host's 404. A hook that could claim POST /invoke would be a second door wearing the first one's name.
Absent, nothing changesNo hook means the same 404 as before, byte for byte.
Refused beside { server }, by nameThere, unmatched paths already reach your own 'request' listeners; a second way to answer them would make the winner depend on registration order.
A throw inside it is that request's 500Never the process's failure. The agent door beside it does not notice.

Two things it does not do, and both are deliberate:

  • A hook that answers nothing leaves the request hanging until it times out. That is the same price, for the same reason, as the missing 404 on a caller-owned server: nothing writes on your behalf.
  • It does not receive upgrades. An unclaimed upgrade on a private socket keeps the answer it has always had — 400 and the socket closed. onUnhandled is a hook for routes: it is handed a ServerResponse to write, and an upgrade has none — it has a raw socket and a protocol handover to perform by hand. The field case that bought this feature is diagnostic HTTP routes, and minting a second, differently-shaped hook for a case nobody has asked for is how a port grows a surface it cannot explain. If evidence arrives, it arrives under its own name. Until then, an upgrade you want to answer yourself is exactly what { server } is for.

httpHost({ onUnhandled }) is where this lives, so every adapter inherits it: nodeHost({ onUnhandled }) and agentCoreRuntimeHost({ onUnhandled }) are the same option.

/** What the diagnostic route reports. Ordinary application state, kept by you. */interface Desk {  turns: number;  lastAnswer: string;}/** * Your routes, on the host's socket. * * Everything the host does not own arrives here exactly as it came off the * wire — a `node:http` request and response, nothing wrapped, nothing decided. * The 404 is yours to write, and so is the decision not to. */function ownRoutes(desk: Desk) {  return (request: IncomingMessage, response: ServerResponse): void => {    const path = (request.url ?? '').split('?')[0];    if (path === '/debug/trace') {      response.writeHead(200, { 'content-type': 'application/json' });      response.end(JSON.stringify({ turns: desk.turns, lastAnswer: desk.lastAnswer }));      return;    }    if (path === '/debug/boom') {      // On purpose: a route that throws is THIS request's 500. The agent door      // beside it does not notice, and neither does the process.      throw new Error('this route is broken on purpose');    }    // Nothing here is written for you. This 404 is a choice, and skipping it    // would leave the request hanging rather than refused.    response.writeHead(404, { 'content-type': 'application/json' });    response.end(JSON.stringify({ error: `no route for ${request.method} ${path}` }));  };}/** One port: the agent, the conversation door, and your routes. */async function serveEverything(provider: LLMProvider | undefined, port: number, desk: Desk) {  const host = nodeHost({    port,    hostname: '127.0.0.1',    // The whole feature. No server to create, no listen() to call, no    // registration order to reason about.    onUnhandled: ownRoutes(desk),  });  const agent = buildAgent(provider);  const requests = await standingAgent({ agent, sessions: memorySessions(), host });  // The third door, on the same socket as always — the hook changes nothing  // about it, because an upgrade is a protocol handover and not a route.  const conversations = await host.serveConversations((conversation) => {    conversation.onFrame((frame) => conversation.send(`echo:${frame}`));  });  return { host, agent, requests, conversations };}

The full runnable version — the agent, the conversation door and a /debug/trace of your own on one port, plus a route that throws on purpose to show what it costs — is examples/deploy/own-routes.ts.

A door that stays open — serveConversations()

HostRequest → HostReply is one exchange, and some doors are a conversation. Those are the words of the production integration that bought this: their agent operates the user's browser, the browser cannot host an inbound endpoint, so it dials out and parks a connection the agent pushes tool calls down. A request/reply port has no way to express that.

So there is a second port, beside the first:

const host = nodeHost({ port: 8080 });

await standingAgent({ agent, sessions, host });        // POST /invoke
await host.serveConversations((conversation) => {      // WS   /conversation
  conversation.onFrame((frame) => conversation.send(answer(frame)));
  conversation.onClose((why) => log(why.by, why.reason));
});

HostConversation is six members and that is all of it:

sessionId?the conversation this channel claims — caller data, never identity
headers?transport headers, lower-cased, as delivered
send(frame)host → far side
onFrame(cb)far side → host; returns an Unsubscribe
onClose(cb)fires exactly once, with who ended it
close(reason?)end it politely: flush, tell the far side, stop

A ConversationHandler is called once per conversation, with that conversation. Throwing ends that conversation and never the host.

Frames are strings, and that is a decision

What a frame means is your protocol's business. The port was designed against three consumers at once — a browser-parked tool channel, the standardized agent↔UI protocol, and agent-to-agent task serving — and they agree on almost nothing beyond "text". A port shaped to fit whichever one arrived first stops being a port. Binary frames are a capability nobody has minted evidence for yet; a binary frame today ends the conversation with a reason that says so, rather than being quietly stringified.

The ceilings are declared, not discovered

A transport that caps frame size or idles out has to say so, on the host — that is what ConversationLimits is, and it lives on the host rather than on the conversation because a ceiling you needed before you opened the channel is no use once you are holding it:

nodeHost().conversationLimits
// { maxFrameBytes: 1048576, maxPendingBytes: 1048576 }

agentCoreRuntimeHost().conversationLimits
// { maxFrameBytes: 32768, idleMs: 900000, maxPendingBytes: 1048576 }

The port neither chunks nor keep-alives, and that is the point. Hiding a 32KB cap inside auto-chunking would have the adapter deciding, for every consumer at once, how a message is split, how the pieces are numbered and how the far side knows the last one landed — and those answers differ per consumer. Same for liveness: a heartbeat is frames on somebody's protocol, and inventing them puts bytes on the wire that their parser never agreed to. So the ceiling is visible and the layer above acts: chunk above the port, heartbeat above the port.

Three ceilings, and the doc on each says whether it is enforced or reported:

maxFrameBytesEnforced, both ways. An inbound frame past it ends the conversation with a stated reason; send() past it throws FrameTooLargeError. A message the transport delivered in fragments counts in total — the port's frame is the whole message, so fragmentation cannot walk around the ceiling.
idleMsReported, not imposed. The thing that idles you out is usually in front of the container, not inside it. Send your own heartbeat frames on your own protocol.
maxPendingBytesEnforced. How much is held for you before your first onFrame subscriber exists.

That last one deserves its own paragraph, because it is a ceiling that only exists because of a convenience. Frames arriving before the handler subscribes are held and delivered, so an async handler that looks something up before it starts listening does not lose the far side's opening frame. But a queue somebody else fills and this process pays for is a way to kill the process — so it is bounded, in bytes rather than in frames (a frame count would still admit count × maxFrameBytes), and going past it ends the conversation naming the bound. An undeclared ceiling is exactly what the declared-ceilings rule exists to forbid, including this one.

onClose says who, not which number

conversation.onClose(({ by, reason }) => { /* 'far-side' | 'host' | 'transport' */ });

That argument is a ConversationClose, and it has exactly two fields. 'far-side' they hung up · 'host' we closed it · 'transport' it broke or timed out. There is deliberately no numeric close code on it: what all three consumers need to know is which of those three happened, and a transport's own numbers answer that only if you already know that transport. An adapter renders its vocabulary into reason.

onClose fires exactly once per subscriber — including one that subscribes after the conversation already ended, which is answered immediately rather than never.

Two refusals you will meet, and neither is silent

ConversationClosedError  ERR_CONVERSATION_CLOSED   send() after the channel ended
FrameTooLargeError       ERR_FRAME_TOO_LARGE       send() past the declared ceiling

Both carry the adapter's name; FrameTooLargeError also carries bytes and maxFrameBytes, so the code that has to chunk learns the number from the thing that refused it. ConversationClosedError carries the session, when the conversation claimed one. The alternative to refusing — accepting the frame and dropping it — looks identical to a send that worked, from the only side that could have noticed.

One socket, two doors

serve() and serveConversations() on the same host share one socket. That is not an optimisation; it is the premise. The runtimes that need a conversation are the ones that hand a container exactly one port, so two doors that each bound their own would fail on precisely the deployment this exists for. On a private socket the server is created by whichever door opens first and closed by whichever closes last; on a caller-owned { server } each door attaches and detaches its own listener and neither touches your socket.

The doors are independent: closing the request handle leaves live conversations alone, and closing the conversation handle leaves /invoke answering.

Closing a conversation door ENDS its conversations first — it has to

An upgraded socket keeps server.close() waiting forever, and closeIdleConnections() does not count it as idle. So close() ends every live conversation politely (a close frame, then the socket) before letting go of the port. If you run your own 'upgrade' listener beside this one, the same is true of yours: server.close() will wait for sockets you upgraded and never end them for you. Keep them and destroy them in your shutdown.

One more consequence of attaching a listener at all: node destroys an upgrade nobody listened for, but once any listener exists it stops doing that. An upgrade on a path neither of you claims therefore hangs rather than being dropped — the same shape as the 404 law above, and for the same reason: a path this host does not own is yours to answer.

nodeHost's door, and no dependency

nodeHost serves conversations on /conversation (its own word, chosen the way its other two paths were) with a real RFC 6455 server implementation — the handshake, text frames, continuation frames, ping/pong and close — and nothing to install. conversationPath and conversationLimits are options. httpHost is where it lives, so every adapter built on it inherits the door; a host built without a conversationPath does not declare 'conversation' and serveConversations() refuses by name.

How far the verification goes — the honest version

The codec is checked against the byte sequences RFC 6455 §5.7 publishes, so the encoder is verified against bytes the specification wrote and the decoder against bytes a real client sends — not against a client we also wrote. On top of that: the conversation conformance suite runs over real sockets, and where the runtime has a built-in WebSocket the same door is driven by that implementation too.

It is not run against the Autobahn test suite. No extension is implemented or negotiated (permessage-deflate included), there are no binary frames, and there is no client role. That is the boundary; nothing here claims past it.

A wire can read the handshake, which is how an adapter maps its own spelling: HttpWire.readConversation(facts) gets a HandshakeFacts{ headers, query }, with no body, because a handshake has none — and returns a ConversationHandshake: { sessionId?, headers?, protocol? }, where headers is merged over the raw ones so nothing a mapping did not understand is lost, and protocol is the subprotocol to echo (only the wire read the offer, so only the wire can select one). nodeHost's dialect reads x-session-id or ?sessionId=, header first.

The agent inside a conversation is governed the same way

A door decides how something reaches your agent; it decides nothing about what the agent then does. Whatever you set with .act() — the input rules, the before-tool and after-tool rules, the window, the output rules — applies identically to a turn that arrived as a frame and a turn that arrived as a POST. The ledger rows say which moment they came from, not which door.

One thing does NOT carry over, and it is worth knowing before you write the handler: an Agent runs one turn at a time, and standingAgent can only serialize the turns it drives. If both doors share one Agent instance, a frame and a request can overlap on it. Give each door its own instance, or your own lock — the runnable example does the former, and says so where it does it.

What this release deliberately did NOT do

standingAgent is not conversation-aware. The three consumers push different things down a channel — tool calls out, UI events in, task updates both ways — so baking one loop into the composer would be exactly the consumer bias the port was designed to avoid. It ships as a port plus two adapters; the browser-tool loop, the agent↔UI framing and agent-to-agent serving are each their own release, each consuming this same door. A type-regression test fails the build the day a conversation key appears on the composer's options.

/** The whole handler: one call per conversation, with that conversation. */function answerFrames(agent: Agent, maxFrameBytes: number) {  return async (conversation: HostConversation) => {    // `sessionId` and `headers` are caller data, exactly as on HostRequest —    // a claim, never identity. Authenticate above the port.    const who = conversation.sessionId ?? 'anonymous';    conversation.onClose((why: ConversationClose) => {      // 'far-side' they hung up · 'host' we closed · 'transport' it broke.      console.error(`[conversation] ${who} ended: ${why.by}${why.reason ? ` — ${why.reason}` : ''}`);      // THE ONE LINE that ends a session's tool sessions (9.7.0).      //      // A tool holding a `scope: 'session'` resource — a code interpreter, a      // browser context — keeps it across the TURNS of one conversation. The      // library cannot know when the conversation is over: a `HostRequest`      // carries a sessionId and no end, `SessionLifecycle` is hydrate/persist by      // design, and AWS itself does not tell you (an idle timeout is the      // reality). Guessing would tear down a live sandbox mid-conversation.      //      // This door DOES know. `onClose` is the boundary, so the composition root      // — which already owns the shape of the process — says when. Same      // doctrine as `shutdownOn`: the mechanism is the library's, the timing is      // yours. Not calling it is survivable (idle sweep, LRU bound, shutdown)      // but this is the moment that is actually true.      if (conversation.sessionId !== undefined) {        void agent.closeToolSessions({ sessionId: conversation.sessionId });      }    });    conversation.onFrame((frame) => {      // The frame is a string. THIS is where your protocol lives — JSON, a      // line format, whatever the far side agreed to — and the port stays out      // of it deliberately.      if (frame === 'bye') {        conversation.close('the caller said bye');        try {          // A send down a channel that has ended refuses BY NAME. The          // alternative — accepting it and dropping it — looks identical to a          // send that worked, from the only side that could have noticed.          conversation.send('one more thing');        } catch (err) {          const refusal = err as Error & { code?: string };          console.error(`[conversation] after close: ${refusal.name}(${refusal.code})`);        }        return;      }      if (frame === 'oversized') {        // The declared ceiling, met head-on. It refuses BY NAME instead of        // truncating the frame or splitting it behind your back — how a        // message is chunked and reassembled is your protocol's question, and        // an adapter that answered it would answer it for every consumer.        try {          conversation.send('x'.repeat(maxFrameBytes + 1));        } catch (err) {          const refusal = err as Error & { code?: string; maxFrameBytes?: number };          conversation.send(`${refusal.name}(${refusal.code}) cap=${refusal.maxFrameBytes}`);        }        return;      }      void agent        .run({ message: frame })        .then((result) =>          conversation.send(`${who}: ${typeof result === 'string' ? result : 'paused'}`),        )        .catch((err: Error) => conversation.send(`error: ${err.message}`));    });  };}/** One socket, both doors: requests on /invoke, conversations on /conversation. */async function serveBothDoors(provider: LLMProvider | undefined, port: number) {  const host = nodeHost({ port, hostname: '127.0.0.1' });  // Declared ceilings, read BEFORE anything is sent: chunk above the port if  // you need to. The port never splits a frame for you.  const limits = host.conversationLimits;  // TWO agent instances, and this is not incidental. An `Agent` holds per-run  // state on itself and runs one turn at a time; `standingAgent` serializes  // the turns IT drives, and it cannot serialize turns somebody else starts.  // Sharing one instance across both doors would let a conversation frame and  // an HTTP request overlap on it. One agent per door, or your own lock.  const requests = await standingAgent({    agent: buildAgent(provider),    sessions: memorySessions(),    host,  });  const conversations = await host.serveConversations(    answerFrames(buildAgent(provider), limits?.maxFrameBytes ?? 0),  );  return { host, requests, conversations, limits };}
npm run example examples/deploy/echo-conversation.ts

It binds an ephemeral port, holds a two-turn WebSocket conversation with itself, answers an ordinary POST /invoke on the same socket, meets both refusals, and shows onClose reporting who ended it. SERVE=1 keeps it listening so you can point a browser's new WebSocket(...) at it.

Sessions: what crosses the restart

SessionLifecycle has two methods, hydrate(sessionId) and persist(sessionId, envelope), plus an optional onWake(sessionId, reason) for stores that need to spin up before they can answer. WakeReason has two members: 'invoke' — a request arrived — and 'resume', when that request carries a person's decision for a run that paused earlier. 'resume' was absent until 7.19 because nothing could produce it, and naming reasons nothing fires would describe a system that does not exist.

memorySessions() is a Map. It is exactly as durable as the process, which is the point: swap it for Redis and nothing above it changes. The step in between ships beside it — sqliteSessions({ file }) is the same port in one file, on Node's built-in SQLite, so a restart is not an amnesia event and there is still nothing to install.

The session-store axis

AdapterSurvivesPeer depCeilingChoose it when
memorySessions()nothing — the processnoneone processTests and local dev. It says so in its name
sqliteSessions({ file })restarts, crashes, deploysnone (node:sqlite, Node ≥ 22.5)one machine, one file, one writer at a timeA single box, and you want conversations and paused runs to outlive it
agentCoreSessions({ store: 'session-storage' })the container's session directorynone (node:fs)one runtime sessionA hosted AgentCore runtime that gives you session-scoped disk
agentCoreSessions({ store: 'memory' })the AgentCore Memory resource@aws-sdk/client-bedrock-agentcorethe service'sYou want sessions managed, not filed
Your ownwhatever your store doesyoursyoursRedis, DynamoDB, Postgres — two methods is the whole contract

None of them silently degrades. sqliteSessions refuses by name on a Node without node:sqlite rather than falling back to memory, because a store that quietly forgot every conversation on restart looks — from the outside — exactly like a brand-new user.

What gets stored is a CheckpointEnvelope — a union of two shapes, discriminated on format:

{ format: 'conversation-v1', data: /* AgentRunCheckpoint */, savedAt: 1723… }
{ format: 'flowchart-v1',    data: /* PausedRun */,          savedAt: 1723… }

'conversation-v1' — a ConversationEnvelope — is a turn that answered. 'flowchart-v1' — a PausedRunEnvelope — is a run that stopped to ask a person something: it carries a PausedRun, which is the engine's own checkpoint, the conversation as of the pause, and the PendingAsk it is waiting on. 7.14 shipped the version field for exactly this, and said so.

toEnvelope(conversation) and toPausedEnvelope(run) pack them. readEnvelope(value) and readPausedRun(value) unpack them, and both refuse an unknown format by name:

[hosting] unknown checkpoint format 'conversation-v2'. This runtime reads:
conversation-v1, flowchart-v1. Refusing rather than restoring a session it
cannot read…

A store outlives the code that wrote to it. Someone will deploy a newer runtime, it will write a newer format, and an older instance still running will read it. Restoring what it can and hoping means an agent answering from a conversation with pieces missing — so formats are added, never redefined, and a reader that does not know one says so and stops.

The two readers also refuse each other's format, and each points at its sibling. That looks fussy until you see the alternative: a reader that quietly returned the conversation inside a paused run hands back a session that looks finished while somebody is still waiting to be asked. checkEnvelope(value, sessionId?) is the third door, for stores: it validates either format and hands the envelope straight back, because a store's job is to notice unreadable bytes, not to care which half of a session is inside them. Pass the session id and every refusal names the conversation it is about.

Unreadable is not the same as absent

An unreadable stored conversation and an absent one are different facts, and only one of them is safe to answer with a fresh start.

A session with nothing stored hydrates as undefined and is answered fresh — correct, and the ordinary case. A session whose stored bytes are present and cannot be read is a different fact: a conversation exists, somebody is in the middle of it, and answering fresh over the top of it looks exactly like the happy path from the outside. Nobody notices until a deployment boundary hands a user a blank slate where their chat was.

So the reading path refuses it by name instead, with UnreadableEnvelopeError — code ERR_UNREADABLE_ENVELOPE, the sessionId, and a short storedPreview prefix of what came back (a prefix only: the rest of those bytes is somebody's conversation):

[hosting] session 'c-1' has a STORED conversation this runtime cannot read. An
unreadable stored conversation and an absent one are different facts, and only
one of them is safe to answer with a fresh start… What the store handed back
looks like: "{format=conversation-v1, data={version=1, runId=run-7, h…" (284 chars)

The law lives at the one place a stored value is inspected, so every reader and every store adapter inherits it — including adapters nobody has written yet. It is a TypeError by ancestry, so code that already caught one keeps working. standingAgent surfaces it as the request's failure, naming the session, and never falls through to the fresh-start path. Over HTTP that is a 500, and deliberately: every other hosting refusal is a conflict or a shutdown, while this one really is something broken on the server's side. Writing a store? Return undefined only for a session you have nothing for, and let checkEnvelope speak for everything else.

Runnable: examples/deploy/durable-sessions.ts serves a store holding an unreadable session and shows the refusal arriving over real HTTP — model never called, nothing written over it, and a brand-new session still answered fresh.

A FlowchartCheckpoint through JSON

It is safe to resume from, and it is not byte-identical. JSON.stringify drops any property whose value is undefined, and a real paused run has a dozen of them — every one in the engine's diagnostic halves (executionTree, subflowResults). sharedState, the half agent.resume() actually reads, round-trips unchanged, because footprintjs already JSON-round-trips every object write on its way into committed state. So: store it anywhere that speaks JSON. Just do not assert that what came back deep-equals what went in.

standingAgent — the composer

StandingAgentOptions is { agent, sessions, host, onConcurrentInvoke?, durability? }. Per request it does four things and invents nothing:

  1. wake and hydrate the session,
  2. continue that session — a conversation, or a run that is waiting on a person — or start a fresh one,
  3. persist what the run left behind — before answering, so a queued next turn can never read state older than the answer already given,
  4. reply, with whichever of the three terminals the run earned.

A request with no sessionId is answered and not stored: there is nothing to hydrate and nowhere to persist it that the caller could ask for again.

sessionId is caller data, not identity

It is whatever the transport declared — a JSON field, a header. Anyone who can reach the host can send any string there, including someone else's. Authenticate the caller by your own means and check they are allowed that session before you serve it.

Resuming is a REPLAY, and that has a cost

A stored conversation is restored through agent.run({ message, continueFrom }) — the public conversation door since 9.2.0, and the same one a script uses (see Conversations). This composer used to assemble that continuation by hand and hand the result to resumeOnError; the hand-assembly is the door now, and the identity travels with it. Its caveat is restated here in the Agent's own words, because a composition that hides the caveat of the thing it composes is worse than no composition:

Tool re-execution / idempotency: tool side effects from the FAILED iteration are not in the checkpoint. The model re-decides from the restored history and may re-issue those tool calls — they WILL execute again (there is no built-in toolCallId dedup). Mutating tools (payments, emails, DB writes) must be idempotent — key on stable call content, not ctx.toolCallId (a re-issued call gets a new id).

One run per instance, and why it is not a tuning knob

An Agent instance holds per-run state on itself. Two runs overlapping on one instance do not crash — which is precisely the danger. They both finish, and the state read afterwards belongs to whichever started last, so one session's envelope can end up holding another session's conversation, with nothing in the recording to say so.

Runs on one instance are therefore serialized. That is a correctness requirement, and it is what decides the shape of everything below.

ConcurrentInvokePolicy is the separate question of what to do when a second turn of the same conversation arrives while the first is still running:

  • 'reject' (default) — refuse it with a ConcurrentRunError naming the session and the run already going (409 over HTTP). A user who double-submits gets one answer and one refusal, not two runs racing to write one conversation.
  • 'enqueue' — queue it, FIFO. It starts after the active run has persisted, so the second turn sees the first turn's stored state.

A request for a different session is never refused. With one shared agent it waits its turn; with a pool (below) it does not have to wait at all.

Concurrency & sessions

The bound is one run per Agent instance. Every way of serving more than one person at a time is a way of having more than one instance — the only question is who makes them.

Three strategies ship or compose. None of them relaxes the law above; they differ in where the instances come from and what that costs.

StrategyHowParallelismCostsChoose it when
Platform-per-sessionThe runtime gives each session its own container/process; you serve one agent in itThe platform's — sessions never meetWhatever the platform charges per sessionYou are on a runtime that already isolates sessions (AgentCore Runtime does)
Agent poolstandingAgent({ agentFactory })This composer builds one agent per ACTIVE session, bounded and LRU-evictedSessions run at the same time; each session's turns serialize on its own instanceOne instance per active session: memory, plus whatever your agent builds at constructionOne process serves many people and you want them answered concurrently
Process-per-workerN processes behind a load balancer, each with standingAgent({ agent })N at a time, sessions pinned or re-hydrated per requestA process each; a shared session store is mandatoryYou already run a fleet, or you want failure isolation per worker

The pool and process-per-worker compose: N workers, each with a pool. Process-per-worker needs a session store every worker can read — sqliteSessions is one machine and one writer, so that shape wants Redis, Postgres or DynamoDB behind the two-method port.

The agent pool — standingAgent({ agentFactory })

const handle = await standingAgent({
  agentFactory: () => Agent.create({ provider, model }).system('…').build(),
  sessions: sqliteSessions({ file: './sessions.db' }),
  host: nodeHost({ port: 8080 }),
  maxActiveSessions: 200,          // default 100
});

That is the whole difference. { agent } keeps today's behaviour exactly — one instance, global serialization, correct. { agentFactory } gives every active session its own instance:

  • Sessions run in parallel. Two people asking two questions are two runs, and neither waits.
  • One session still serializes, on its own instance, under the same onConcurrentInvoke policy. The Agent's own RunInFlightError never reaches a caller — the per-session queue keeps a second turn off the instance.
  • The pool is bounded and LRU. A new session at a full pool retires the least recently used idle one: its tool sessions are closed with reason 'evicted' (the same vocabulary tool sessions already use), its agent is shut down, and its conversation stays in the store — so its next request re-hydrates onto a fresh instance and the person never knows.
  • A running session is never evicted. If every session in the pool is busy the pool grows past the bound rather than ending somebody's turn; it comes back under as soon as a run finishes.
  • Anonymous requests share one fallback instance. There is no conversation to isolate, and an instance per anonymous request would be an instance per request.
  • Instances the factory made are the composer's, so close() stops them (drain, then release) unless shutdown: 'none'.

The option types are exported for the callers who name them: the two shapes are StandingAgentSharedOptions and StandingAgentPoolOptions, both extending StandingAgentBaseOptions (the store, the host, onConcurrentInvoke, durability, shutdown, shutdownOn — everything that is the same either way), and StandingAgentOptions is the union of the two. The default pool bound is DEFAULT_MAX_ACTIVE_SESSIONS (100), exported so it can be read rather than guessed.

Two refusals guard it, both by name:

[hosting] standingAgent was given both 'agent' and 'agentFactory'. They are two
answers to one question — whether sessions SHARE an instance or each get their
own — and honouring either would leave the other silently ignored…

[hosting] standingAgent's agentFactory returned an Agent it has already
returned. One instance per session is the entire point: an Agent holds per-run
state on itself, so two sessions sharing one would each finish while the
composer read the other's state — one person's conversation quietly holding
another's turn…

The second one is the load-bearing one. agentFactory: () => sharedAgent type-checks perfectly and destroys the only property the pool exists for, so it is detected on the spot rather than discovered in production.

nodeHost reads the session id from the JSON body's sessionId, then the x-session-id header. Both are options now:

nodeHost({ sessionHeader: 'x-conversation' })   // a gateway already stamps one
nodeHost({ sessionCookie: 'af_session' })       // issue + read a cookie

In a browser, the client half is one line from the main barrel:

import { browserSessionId } from 'agentfootprint';

const sessionId = browserSessionId();          // minted once, kept in localStorage
await fetch('/invoke', {
  method: 'POST',
  headers: { 'content-type': 'application/json', 'x-session-id': sessionId },
  body: JSON.stringify({ input: message }),
});

Or with no client code at all, using the cookie:

// server
nodeHost({ port: 8080, sessionCookie: 'af_session' });
// browser
await fetch('/invoke', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ input: message }),
  credentials: 'same-origin',
});

The first request comes back with Set-Cookie: af_session=…; Path=/; HttpOnly; SameSite=Lax and every later one carries it. A caller that already sent a session — by body, header or cookie — is never issued a second one.

The pieces those two options are made of are exported, for the deployments that need to reach past the adapter:

SymbolDoorWhat it is
DEFAULT_SESSION_HEADERagentfootprint/hosting'x-session-id' — the header nodeHost reads when nothing says otherwise
jsonWireWith(options) / JsonWireOptionsagentfootprint/hostingnodeHost's JSON dialect as a factory: { sessionHeader?, sessionCookie? }. jsonWire is jsonWireWith(). Hand it to httpHost({ wire }) when you are keeping these bodies but re-deciding something else
browserSessionId(options) / BrowserSessionIdOptionsagentfootprint (the main barrel)The client half: mints once, keeps it, hands it back. { storageKey? } — two keys are two conversations
DEFAULT_SESSION_STORAGE_KEYagentfootprint'agentfootprint.sessionId', named so a page can clear it deliberately

browserSessionId is on the main barrel rather than agentfootprint/hosting because the rest of that door is Node — node:http, node:sqlite — and a browser bundle must not have to reach through it to mint a session id. It uses localStorage (so the conversation survives a reload) and falls back to memory when localStorage is missing or throws, which private-mode browsers do.

A session id is not authentication

Both halves are caller data. Anyone who can reach your host can send any string, including somebody else's. Authenticate the caller by your own means, then check that the authenticated principal is allowed the session they claimed, before you serve it. Two more facts worth stating out loud: cookie mode sets no Secure flag, because this host cannot know whether it is behind TLS — add it at your proxy or terminate TLS here; and a cookie follows a browser profile, so one person on two devices is two conversations and a shared machine is one.

A session is a conversation — and now a memory namespace

A run that carries a sessionId and no identity is scoped to { conversationId: sessionId } (9.10.0). standingAgent passes the session id on every run and resume, so a .memory() registered on the agent recalls that session's earlier turns with zero configuration.

Before 9.10.0 that run got the per-run default — { conversationId: '<runId>' } with a fresh runId every turn — so a twelve-turn session wrote twelve namespaces of one exchange each and recalled nothing. The turn always looked right; only the recall was missing.

  • An identity you pass always wins, including the one a continued conversation carries.
  • A run with no session and no identity is unchanged.
  • The derivation is recorded as runIdentitySource: 'session' in the run's own state, so a trace can tell a namespace somebody chose from one the library derived.
  • It is still not published to tool.execute as ctx.identity: a synthesized namespace is not something anybody named, and tools that want the session have ctx.sessionId for it.

See Conversations for the full ladder.

Many processes — redisSessions is not built

SessionLifecycle is two methods (hydrate, persist) plus an optional onWake. That is the whole contract, and it is deliberately that small so the multi-process road is a short one:

const redisSessions = (client: Redis): SessionLifecycle => ({
  async hydrate(sessionId) {
    const raw = await client.get(`af:${sessionId}`);
    return raw ? checkEnvelope(JSON.parse(raw), sessionId) : undefined;
  },
  async persist(sessionId, envelope) {
    await client.set(`af:${sessionId}`, JSON.stringify(envelope));
  },
});

No redisSessions ships. Not an oversight — a shipped adapter has to make decisions that belong to your deployment (key prefix, TTL, cluster vs single, which client), and every one of them would be wrong for somebody. checkEnvelope is the part worth borrowing: it validates either stored format and refuses an unreadable one by name, which is the half that is easy to get wrong. If a field-tested shape emerges, it ships then — with the numbers, not before.

A pause is unfinished work, and now it is kept

A run has three ends: it answered, it asked a person something, or it failed. HostReply has a terminal for each — complete, awaiting, fail — and a pause leaves through awaiting, never through fail. Before 'flowchart-v1' there was nowhere to keep a paused run, so the middle one was delivered as an error standing in for unfinished work. It is a terminal of its own now.

When a run pauses, standingAgent:

  1. stores it as 'flowchart-v1' under that session, and
  2. calls reply.awaiting(pending) with a PendingAsk — the tool that asked, the question in plain words, the typed checkIn with its evidence pack, or the middleware ask, plus the raw pauseData uninterpreted.

Over HTTP that is a 202 Accepted: understood, acted on, not finished. Not a 200 — there is no answer. Not a 4xx or 5xx — nothing was refused and nothing broke.

A PendingAsk never carries the checkpoint

The engine checkpoint holds the whole shared state of the run: the system prompt, the entire conversation, every tool result. That belongs in the store the operator chose, not in a reply to whoever posted the request. The caller gets the question; the store gets the state. A type-regression test fails the build the day the field appears.

Answering it: the resume-invoke contract

A request carrying decision is a resume; a request without one is a new message. That is the whole contract, and it is a field rather than an inference on purpose — reading approval out of prose ("yes, go ahead") is a guess, and a consent gate may not be built on a guess.

// The run stopped and asked. Answer it on the same session:
await fetch(`${url}/invoke`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    input: '',
    sessionId: 'c-1',
    decision: checkInApproved({ by: 'alice@ops' }),
  }),
});

The port never interprets decision. It is handed to agent.resume(checkpoint, decision) exactly as it arrived — the shipped checkInApproved() / checkInDeclined() vocabulary answers a checkIn or a middleware ask; a plain askHuman pause is answered with whatever that tool's author documented.

Two mismatches are refused by name rather than guessed at:

  • a new message while a question is outstandingAwaitingDecisionError (ERR_AWAITING_DECISION, 409), naming the pending ask. The message is not run and the pause is not discarded — answering the message would step over an outstanding consent gate, and dropping the question to answer the message would throw away work somebody was asked to approve.
  • a decision when nothing is pendingNoPendingAskError (ERR_NO_PENDING_ASK, 409). Usually a duplicate delivery; running it as an ordinary message would put a raw approval into the conversation as if a person had typed it.

A pause the wire could not describe

PauseNotCarriedError (code: 'ERR_PAUSE_NOT_CARRIED') is worth reading even if you never see it, because the whole design is visible in one class.

A run paused. That is not a failure and it is not an answer — it is unfinished work, and something has to be true about it. The error therefore reports two independent facts, because they need different responses:

storedWhat happenedWhat you do
trueThe paused run is in the store. The transport simply could not describe the question, because this host does not implement reply.awaiting()Send another request for that session carrying a decision. Read the pending ask from the session store, or serve on a host that has awaiting
falseNothing was written. Either the request carried no session id — so there is nowhere to store a paused run and no later request that could ever answer it — or the session still holds exactly what it held beforeSend a sessionId, or carry the pause yourself with agent.run() / agent.resume()

The error carries toolName, sessionId and stored, and its message says which case you are in and what to do about it. Three design rules are visible in that one class:

  • A capability nobody implements is never assumed. awaiting is optional on the type, so the composer has to handle its absence — and does, by storing anyway.
  • State and transport are separate concerns. The store is not the transport's business, so a wire that cannot describe a pause does not get to lose one.
  • A refusal names the fix, not the symptom. "It paused" is useless; "it is stored, send a decision to session X" is actionable.

Resuming a pause is not a replay. The conversation-replay caveat above applies to resumeOnError; a paused run continues from the engine's own checkpoint, so no earlier tool call runs twice.

Upgrading from 7.18: a paused turn now answers 202, not 409

This is a behaviour change a deployer has to read. A run that paused used to fail the reply and store nothing; it now stores a 'flowchart-v1' envelope and answers 202. During a rolling deploy an instance still on 7.18 that hydrates one of those sessions refuses it by name — which is the unknown-format law working exactly as designed, loudly rather than silently. Drain or roll forward rather than running both versions against one store.

Redeeming claim tickets — artifact-head / artifact-get

The artifact store taught tools and the model to route refs instead of hauling data, and present({ ref, as }) hands one to the screen. This is the screen's half (9.23.0): two wire operations on the same invoke path, so a frontend can redeem the ticket — describe it first, then pay for the bytes.

// the run minted art_h7Kq… and the model presented it; now the screen asks:
await fetch(`${url}/invoke`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ op: 'artifact-head', ref: 'art_h7Kq…', sessionId }),
});
// → 200 { artifact: { ref, meta } }        meta = { kind, mediaType, bytes, label?, … }

// pick the registered component from meta.kind, then:
//    { op: 'artifact-get', ref, sessionId }
// → 200 { artifact: { ref, meta, data } }  render it

head is the render-by-ref decision: the screen picks a renderer from kind and bytes without downloading anything. get pays for the payload. There is deliberately no put, no delete and no list on the wire — a screen redeems tickets, it does not mint or sweep, and a wire list would let a caller enumerate a scope. Possession of the ref is the entitlement, and even that only under the right identity:

One isolation rule, re-composed — never a second one

A ref is redeemed under exactly the scope the run's tools minted it in. standingAgent composes the resolution scope from the request the same way it composes the run's identity: a session-only request resolves under the session's own namespace; a request whose transport carries a userId (the managed-runtime wires do; the generic jsonWire deliberately reads none) resolves under the identity tuple — same session presented with a different identity is a different scope. The store then answers a wrong scope the way it always has: "no data", never a distinguishable error.

So the wire's not-found is ONE shape — 404, code: 'ERR_ARTIFACT_NOT_FOUND' — for missing, expired and another-session's alike, byte-identical but for the ref itself. Anything distinguishable would let a caller probe scopes it does not own. There is no bare-ref mode: an artifact op with no session at all is refused (400, ERR_ARTIFACT_SESSION_REQUIRED) — an id in a log or a bug report opens nothing, ever.

That not-found is also what a reloaded conversation renders honestly: walk the transcript for present results, artifact-head each ref under the session's identity — live refs re-draw, and an expired one renders its stated absence from the description snapshot the present result already carries ("Chart — 'Q3 sales by region' (bar-chart, 41 KB) — expired; re-run to regenerate"). Never a blank pane.

The refusals, each naming its fix

ReplyStatusWhen
{ artifact: { ref, meta } }200artifact-head resolved
{ artifact: { ref, meta, data } }200artifact-get resolved
ERR_ARTIFACT_NOT_FOUND404missing, expired, or another scope's — deliberately indistinguishable
ERR_ARTIFACT_SESSION_REQUIRED400the op named no session
ERR_INVALID_WIRE_OP400an op this host does not speak, or a known op without ref. A body that named an op never falls through to a model turn — a typo'd redemption silently becoming a billed conversation turn is the failure this refusal exists to prevent
ERR_NO_ARTIFACT_STORE501the serving agent has no store — the refusal names the attach: Agent.create({ …, artifacts })
ERR_ARTIFACT_NOT_CARRIED501the ref resolved but this host/wire cannot describe the result (reply.artifact / the wire's artifact body shape is missing)

Each row throws a named class exported from agentfootprint/hosting, carrying the same code shown above for an adapter that would rather catch by class than by string: ArtifactSessionRequiredError, NoArtifactStoreError, ArtifactNotFoundError, ArtifactNotCarriedError. The op grammar itself is readArtifactWireOp/artifactWireBody plus the pieces a custom dialect reads and returns — the wire spellings ARTIFACT_HEAD_OP/ARTIFACT_GET_OP, and the port-side ArtifactWireRequest/ArtifactWireResult types.

Three more facts worth knowing:

  • A redemption is not a turn. It never starts, resumes, queues behind, or is refused as a run — a screen describing a chart while the model is mid-turn is served immediately, and a session awaiting a decision still redeems (history reload re-draws panes while the question is outstanding).
  • On the record, once. Every wire redemption rides the existing agentfootprint.artifacts.resolved / artifacts.refused events on the serving agent — with no tool field, because the redeemer was the hosting door, not a tool. The raw store emits nothing, so there is exactly one emission per fact.
  • No reply redaction exists to bypass, stated plainly. The hosting layer applies no redaction to replies today — complete(output) carries the agent's words verbatim, and artifact data passes the same boundary. Redaction in this library governs the trace (recorders), not host replies; what governs this door is the identity-scoped resolution above.

Custom HttpWire dialects join in with the shared grammar rather than re-deriving it: readArtifactWireOp(facts.body) in readRequest (unknown ops refuse there, as InvalidWireOpError), and artifactWireBody(result) as the artifact body shape — exactly how the two shipped dialects do it. The AgentCore runtime wire carries both ops with its own status: 'success' envelope beside the standard body.

Verified identity — identity: { verify }

HostRequest.userId has said the same honest thing since 9.12: it is what the transport said, and how much that is worth depends on what stands in front of you. A managed runtime that authenticates at its front door can fill it in. A container you expose yourself cannot — a header there is a string anybody can send.

Since 9.26 the door itself can know. Pass an IdentityVerificationOptions and every request's Authorization: Bearer … is checked before the run's identity and scope are composed:

import { standingAgent, nodeHost, sqliteSessions } from 'agentfootprint/hosting';
import { jwksIdentity } from 'agentfootprint/security';

await standingAgent({
  agent,
  sessions: sqliteSessions({ file: './sessions.db' }),
  host: nodeHost({ port: 8080 }),
  identity: {
    verify: jwksIdentity({
      jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
      issuer: 'https://idp.example.com/',
      audience: 'my-api',
    }).verify,
  },
});

The proven subject becomes the run's principal — which is what reaches EventMeta.principal, ctx.identity inside a tool, the memory namespace, and the artifact scope. Nothing else about the run changes.

Unset, nothing changes at all. No verification runs, userId is read exactly as it was, and every earlier release's behaviour is byte-identical.

The refusal law

With a verifier configured, a request that names a user it cannot prove is refused — never downgraded to anonymous, and never served under the name it claimed. Both alternatives are the same failure wearing different clothes: one produces an audit trail naming the wrong party, the other a door anybody opens by sending less.

Anonymous requests are refused too, by default. Set identity: { verify, allowAnonymous: true } when a deployment genuinely serves both a public lane and a signed-in one; an anonymous request may then carry no userId at all.

And it decides whose sessions are whose

A verifier is also what makes a session belong to somebody. With one configured, every door that opens a stored conversation asks the same question first — the two history ops below, an ordinary turn, and a request carrying a decision that resumes a paused run:

does this session belong to the caller who proved who they are?

A turn naming somebody else's sessionId is refused with the same indistinguishable SessionNotFoundError (404) a transcript gets — before a line of that conversation is hydrated into a model's context, and before anything is written back. One question, one answer, at every door: a gate only some doors honour is not a gate, and the door that runs the model is the expensive one to get wrong.

Ownership is the principal the first turn signed with, and no later turn moves it: a store never lets a second writer take a session it did not establish, and never erases an owner because one write happened to carry a leaner identity.

Two consequences worth planning for:

  • Conversations stored before a door started verifying name no owner, so they cannot be continued at one that does. That is a loud refusal by design — the alternative is handing old conversations to whoever names them first.
  • A refused turn does tell the caller the id is taken. That is the one fact a door which lets callers choose their own session ids cannot conceal; the alternative (starting a fresh conversation over somebody else's) destroys it.

Unset, none of this runs: a header-trust door hydrates exactly the session it is told to, as it always has.

The token never travels

A bearer token is a credential, so it appears in no error, no event, no reply body and no log line — not truncated, not hashed, not "the first eight characters". What travels is the class of the failure (IdentityFailureClass): 'no-token', 'expired', 'not-yet-valid', 'wrong-audience', 'wrong-issuer', 'unverifiable', 'claimed-another-user'. Each one is a different action for the caller, and none of them requires printing one character of the secret.

IdentityNotVerifiedError (ERR_IDENTITY_NOT_VERIFIED, 401) carries that class. VerifierUnavailableError (ERR_IDENTITY_VERIFIER_UNAVAILABLE, 503) is the other fact: your identity provider was unreachable, the caller's token was never judged, and telling every client to re-authenticate against a provider that is already down is the wrong instruction at the worst moment.

Writing your own verifier

IdentityVerifier is one method. It is handed the raw token (without the Bearer prefix) and returns a VerifiedIdentity{ userId, roles?, claims? } — or throws. An opaque token introspected against a server, a mutual-TLS thumbprint an edge proxy already checked, a signed cookie: all of them are the same shape.

roles and claims reach the admission policy and the session-history ops and nothing else — stated rather than implied, because "the agent can see my roles" is exactly the kind of belief that gets built on. The run's identity stays the three-field tenant/principal/conversation tuple every store already scopes on.

Both options are refused at construction when half-spelled — an identity without verify, an admission without decide. Both would fail closed at runtime, which sounds safe until you notice that a door refusing everybody for a configuration reason is an outage nobody can diagnose from the outside.

Two helpers are exported for custom doors: bearerToken(headers) is the ONE extraction every dialect's credential arrives through, and verifyRequestIdentity(options, headers, claimedUserId) is the composer's own answer to "who is this", so a custom composition root inherits the same refusals rather than re-deriving them.

jwksIdentity — the shipped adapter

One adapter covers cloud IdPs and on-prem ones, because JWKS is the same protocol in both: a URL that publishes signing keys, and tokens signed by one of them. JwksIdentityOptions takes jwksUrl, issuer and audience (all required — a verifier that accepted any issuer is a signature check with no opinion about who signed, and one with no audience honours a token minted for somebody else's API), plus userIdClaim (default 'sub'), rolesClaim (default 'roles', read leniently — an array, or the space-delimited scope convention), algorithms (default: the RSA and ECDSA families — symmetric algorithms are deliberately excluded, because with a public key an accepted HMAC alg is the classic algorithm-confusion forgery), clockToleranceSeconds, cacheMaxAgeMs and fetchTimeoutMs.

It checks the signature, iss, aud, exp and nbf — and nothing else. It is not an authorization decision, and it does not check revocation: a JWT is valid until it expires, and no amount of key fetching changes that. Short lifetimes are the answer; an adapter implying otherwise would be selling a guarantee the protocol does not make.

It needs the jose peer dependency (npm install jose), raising MissingJwksSupportError when it is absent. In a bundled app where a bare specifier reaches the runtime unresolved, pass an already-imported module as backend — the JoseBackend slice is declared structurally, so a stub or a future major satisfies it without this package taking a hard type dependency on an optional peer.

Admission — admission: { decide }

The cheapest refusal is the one made before the first model call. admission is where that decision goes: it is consulted once per turn, before any hydrate, any model call and any store write.

import { standingAgent, turnsPerHour } from 'agentfootprint/hosting';

await standingAgent({
  agent, sessions, host,
  identity: { verify },                  // required for a PER-USER bound
  admission: turnsPerHour({ limit: 60 }),
});

An AdmissionPolicy sees an AdmissionContext{ identity?, sessionId?, recentSpend } — and answers an AdmissionVerdict: 'allow', { queue: true } (run it, but behind whatever this session already has in flight, even where the host would otherwise refuse a second concurrent turn) or { refuse: '<sentence>' }. Deliberately not "delay by N ms" (a sleep is a thread the caller is paying for), not "degrade to a cheaper model" (silently answering from a different brain is the accepted-and-silently-wrong failure) and not a numeric priority (a priority means a scheduler, and this is a door).

A refusal answers 429 as AdmissionRefusedError, carrying the policy's own sentence. The policy writes the words because the limit and its reset are facts only the operator has; a library-authored "rate limit exceeded" is a support ticket rather than something a caller can act on.

turnsPerHour({ limit, anonymousLimit? }) is the shipped reference policy — TurnsPerHourOptions is the whole surface. Read it and write yours: a real deployment's rule is usually "this plan, this endpoint, this time of day", which is knowledge a library does not have.

What "spend" honestly means

RecentSpend is { turns, inputTokens, outputTokens, usd?, windowMs, complete }. Tokens are summed from agentfootprint.stream.llm_end; usd from agentfootprint.cost.tick, and it is absent rather than zero when no pricing table is configured — a zero would read as "this caller has spent nothing" when the truth is "nobody is counting". complete is false while the process has been up for less than windowMs, so a policy knows when it is deciding on a partial window.

The accounting is PER PROCESS. The SpendLedger (built by spendLedger(), configurable through SpendLedgerOptions, keyed by spendKeyFor(identity)) is an in-memory rolling window: two replicas keep two windows, so a limit of 20 across three of them is a limit of 60, and a restart forgets. That is what an in-process accountant can truthfully claim, and it is said here so nobody builds a billing control on it by accident. A deployment that needs one number across a fleet writes a decide that reads its own store — the seam is the same one turnsPerHour uses.

Turns are counted at admission, not completion, so a caller cannot hold N runs open under a limit of one. The stated consequence: a request the lane then refuses as a concurrent run still counts against the window. Deferring the count to the moment a run actually starts would fix that and open a bigger hole — a burst of simultaneous requests would each be decided against a window none of them had joined, and walk straight through the ceiling. Verified callers are keyed by the proven id; everyone else shares one anonymous bucket, which is the honest answer rather than a weakness — a session id would let a caller mint a fresh budget by asking for a new conversation.

Unset, no ledger is built, no listener is installed, and not one line of this runs.

The ingress record — onIngressDecision

auditExport() is a record of runs. A 401 from identity.verify and a 429 from admission.decide both happen before a run exists: no agent has been built, no observer is attached, no typed event is emitted. So neither is in the bundle, and the consequence is the sharp one — an empty bundle is not evidence that nobody was turned away. It is evidence that nobody ran.

Two independent field rounds wrote that down as the same finding (2026-08-13, again 2026-08-14), from two different angles: "the JWKS identity and admission decisions occur before the Agent runs, so they do not automatically appear in an Agent-level auditExport chain", and "the bounded audit bundle does not replace gateway/HTTP security logs."

Since 9.32 there is somewhere for them to go:

await standingAgent({
  agent, sessions, host,
  identity: { verify },
  admission: turnsPerHour({ limit: 60 }),
  onIngressDecision: (record) => securityLog.write(record),
});

One IngressRecord per request, handed to your sink at the moment the reply reaches its terminal:

{
  at: 1755100000000,
  door: 'turn',                          // 'turn' | 'session-op' | 'artifact'
  outcome: 'identity-refused',           // see below
  errorCode: 'ERR_IDENTITY_NOT_VERIFIED',
  identityFailure: 'expired',            // the class, never the token
  claimedUser: false,
  sessionId: 's-42',
  bearerPresent: true,
  admission: undefined,                  // 'allow' | 'queue' | 'refuse'
}

The whole vocabulary is four exported types. IngressSink is the callback onIngressDecision takes — (record: IngressRecord) => void, called synchronously, so buffer and batch inside it rather than awaiting a network. IngressDoor is 'turn' | 'session-op' | 'artifact'. IngressAdmissionVerdict is 'allow' | 'queue' | 'refuse', what the policy answered when one was consulted.

IngressOutcome is the field a dashboard groups by: 'served', 'identity-refused', 'verifier-unavailable', 'admission-refused', 'session-refused', 'refused' (every other refusal the door made by name) and 'failed'. errorCode carries the precision beside it.

'served' means delivered, not admitted. The record is filed at the terminal the reply actually reached, so a request the door let through whose run, store or provider then broke ends at fail and is recorded as 'failed' (or 'refused', when that error names itself with an ERR_… code) with the error's class beside it. To count what the door admitted, count everything that is not one of the door's own refusals — 'identity-refused', 'verifier-unavailable', 'admission-refused', 'session-refused' — or read admission, the verdict your policy actually returned, which is on a refused record and a broken one alike.

It is a stream, not a chain

It does not join the audit hash chain. Nothing here is hashed, sequenced against auditExport()'s records, or verifiable by verifyAuditBundle. Saying otherwise would make this fix the exact failure it exists to close — a mechanism that looks like evidence and is not. If you want ingress decisions inside a tamper-evident chain, write them into the same store your audit bundle lands in and chain them there, where the sequence is yours to define.

Why the composer, and not your own verify

Some of these refusals never reach a seam you own:

  • A request with no Authorization: Bearer …. Your verify is never called, so no log you could have written contains it.
  • A verifier outage (503). A different fact from a bad credential, and one a 401 counter would misfile.
  • A turn or transcript naming somebody else's session. The token verified perfectly and admission said yes; the refusal happens afterwards, at a door only the composer owns.
  • A session op at a door with no verifier, a store with no owner index, a concurrent-run refusal, an artifact ref that did not resolve.

And served requests are recorded too, because an absence is only readable against a census.

What it never carries, and what it cannot see

The record holds classes and identifiers — never the bearer token, never a header, never a claim set, and never an error's message (a message may carry an SDK's text, a store's detail or your own admission sentence, so none is copied). The one identity field is userId, and it is the id the token proved; a merely claimed one is never written down as if it were.

One boundary, stated rather than implied: a body the transport refused before the composer saw it — unparseable JSON, or an op this host's wire grammar does not speak (InvalidWireOpError, answered 400 by httpHost inside its own request reader) — is not in this stream. That is a malformed request rather than a decision about a caller, and your HTTP access log has it.

Unset, nothing is built, nothing is wrapped, and the reply the handler uses is the host's own object, to the byte. A sink that throws is contained (a broken log must not turn a served request into a failed one) and reported once.

Field evidence — 2026-08-13, and what it does not reach

jwksIdentity, the identity: { verify } door and the admission: { decide } seam are field-validated — an independent field trial, 2026-08-13, run against a live nodeHost in a live Google Cloud project with sessions in a real Firestore: an RS256 token verified from a remote JWKS endpoint against issuer, audience, expiry, subject and roles; a valid token paired with a claimed other identity rejected; missing, expired and wrong-audience tokens answered 401; two admitted turns and a third answered 429 before the provider or the session store was touched; four different bearer tokens absent from every captured response body and error; and the remote key set fetched once over the network, then served from the verifier cache. Rejected requests made zero provider calls.

Three bounds, so the rung is not read wider than it is. The finding does not name the identity provider, so what is proven is the JWKS protocol path, not interop with a particular commercial IdP. The provider behind that door was a deterministic mock — "before any model call" was measured as zero provider calls, a claim about where the refusal happens rather than about a real completion's cost. And turnsPerHour itself is only contract-shaped and tested: the finding records the 429 and restates the helper's per-process bound, but not the shipped helper as the policy under test, and a run of minutes cannot cross an hour boundary — so the window's roll-off and reset are proven by tests here, not in the field.

The caveats that run raised stay exactly where they were: the SpendLedger is per process, and this record is a stream rather than a chain.

Session history — session-list / session-transcript

A hosted agent stores conversations so a person can come back to one. Until 9.26 the only way back was to already know the session id. Two wire operations answer the sidebar everyone actually builds:

// the caller's own conversations, newest first
{ "op": "session-list" }
// → { "sessions": [ { "sessionId": "…", "savedAt": 1736…, "format": "conversation-v1", "messageCount": 6 } ] }

// one owned conversation's messages
{ "op": "session-transcript", "sessionId": "…" }
// → { "transcript": { "sessionId": "…", "messages": [ { "role": "user", "content": "…" } ] } }

SessionSummary is a listing row; TranscriptMessage is one projected message; SessionWireRequest and SessionWireResult are the port-side shapes behind them. HostRequest.session carries the operation and HostReply.sessions is the terminal it ends through — a host without that terminal answers SessionsNotCarriedError rather than improvising a body shape no client was written against.

Both ops REQUIRE verified identity

Listing "your" sessions from an unverified header is enumeration with a friendly interface: guess a user id, read a stranger's conversations. So at a door with no verifier — and for an anonymous caller at a door that has one — both ops are refused by name (SessionOpNeedsIdentityError, 501) rather than served under a name nobody proved. This is the one place in the hosting layer where a feature is gated on a security control being present, and it is gated because the feature without the control is a vulnerability rather than a smaller feature.

A foreign transcript is a 404, not a 403

"That session exists but is not yours" tells an attacker which ids are real. A session that does not exist, one that belongs to somebody else, and one whose stored conversation names no owner are one answerSessionNotFoundError, byte-identical but for the id the caller already knew.

The same refusal guards the ordinary turn door at a verifying host, in the same words: hydrating a named session into a model's context is the same access as reading it back, so it cannot be the lenient one. See whose sessions are whose.

What a transcript contains, and what it does not

Roles and text, in stored order. Deliberately not tool call arguments, tool results, tool names, or the system prompt: a transcript is what the two parties said, and the tool leg is the run's internals — it routinely carries resolved credentials and raw records, and a screen that rendered it would publish the inside of the agent to whoever is signed in. envelopeTranscript(envelope) is that projection, and envelopeOwner(envelope) is the one derivation of who a stored session belongs to.

The owner index — two OPTIONAL store members

SessionLifecycle gains listByUser?(userId, options?)SessionListPage (paged by SessionListOptions) and ownerOf?(sessionId). Optional and feature-detected, because the port's two required methods are a key/value map and most stores are exactly that. memorySessions() and sqliteSessions({ file }) both implement them; a store that does not makes the ops refuse by name (SessionIndexUnavailableError, naming the missing member) rather than answer "you have no sessions", which nobody could distinguish from the truth.

Ownership is derived, never declared. persist takes no owner and gains none: a store fills its index from the stored envelope's own identity.principal. A store that let a caller state an owner would be a store where owning somebody's session is a matter of asking. A conversation that ran anonymously has no owner and appears in nobody's list — the honest consequence of deriving rather than inventing.

And established once. The first turn that signs for a conversation owns it; no later write moves that — not a leaner identity (which would erase it) and not a different one (which would transfer it). Both shipped stores implement the index that way, and a custom one that let the last writer win would undo every ownership check made against it one turn later.

sqliteSessions adds two nullable columns and one index to its existing table without bumping the schema version: an older reader names its four columns explicitly in both its INSERT and its SELECT, so two extra columns are invisible to it, and refusing a 9.25 file for missing them would be the opposite mistake. Rows written before this release carry no owner and appear in nobody's list until their next turn re-persists them — a stated migration boundary rather than a bug report.

One op field, two domains

The wire's op field now names operations from two domains, so the grammar has one owner rather than one per dialect. WIRE_OPS is the list (ALL_WIRE_OPS, WireOpName, isWireOp), refuseUnknownWireOp(op) is the single refusal for a name nobody speaks, and each domain's reader declines the other's ops rather than claiming them. Custom HttpWire dialects call readSessionWireOp(facts.body) beside readArtifactWireOp, and answer with sessionWireBody(result)SESSION_LIST_OP and SESSION_TRANSCRIPT_OP are the wire spellings. A typo'd op still refuses with one message listing every operation, from either reader, so the answer never depends on which one a dialect happened to call first.

durability — how often progress becomes crash-survivable

An agent that only writes when a turn ends is one restart away from losing everything that turn had done. durability decides how much survives, and what it costs. DurabilityMode is three words and no more:

modewhat it doesa crash costs you
'exit' (default)one write, when the run finishesthe whole turn
'async'a write is started whenever the conversation changes, and never waited onwhatever the newest un-landed write carried
'sync'persist-then-proceedthe current iteration, and nothing before it

'exit' installs nothing — no observer on the agent, no barrier, no per-commit work. An agent served that way behaves and performs exactly as it did in 7.18.

await standingAgent({ agent, sessions, host, durability: 'sync' });

What 'sync' actually guarantees

Iteration N's tools do not execute until iteration N−1's write has landed, and the answer is not delivered until the last write has landed. You pay the store's latency once per iteration, knowingly, and in exchange the amount of work a crash can re-run has a number.

That bound is the whole point. Without it, iteration N's tools run while iteration N−1's write is still on the wire — so a crash replays more than one iteration of side effects, and "how much can re-execute?" has no answer. A 'sync' that did not bound it would be documented-but-misleading, which is the polite cousin of a setting that lies.

The bound is stated per iteration, not per tool, and that is exact: the agent dispatches all of one iteration's tool calls inside one stage body, and a commit is a whole stage. So a crash part-way through re-runs that iteration's tools — the same idempotency requirement resumeOnError has always carried, now with a boundary instead of a warning.

A store that refuses a write fails the request, and under 'sync' the next tool never runs. Fail-closed: a store that would not take the run's progress has not made it durable, and proceeding as if it had is the dishonesty this dial exists to remove.

Where the writes happen

At the commit boundary, which is the only mid-run moment where "what the run has agreed on so far" is a real, complete thing — footprintjs commits a stage's writes after the stage returns, and committed state is immutable after the swap.

A write happens where the conversation actually moves, not on every commit. A two-iteration turn commits about forty times; two of those change the conversation (the user's message landing, and each tool-call stage). The other thirty-eight would store bytes identical to the last write.

There is no mid-run engine checkpoint to store — footprintjs builds a FlowchartCheckpoint only at a pause — so what a mid-run write carries is a conversation, the same AgentRunCheckpoint a finished turn stores. That is exactly enough, because it is what the next turn resumes from.

agent.checkpoint()

The conversation the last completed run leaves behind, as the same AgentRunCheckpoint that run({ continueFrom }) and resumeOnError accept.

await agent.run({ message: 'Book me a table for two.' });
const conversation = agent.checkpoint();   // persist anywhere

// …later, anywhere:
await agent.run({ message: 'Make it three.', continueFrom: conversation });

Since 9.2.0 it also carries the run's identity, so a continued turn writes its memory in the namespace the conversation started in rather than under a fresh run id — and, for agents that named themselves with Agent.create({ id }), the id that recorded it. See Conversations.

It is read from the run's own recording — the committed history — cloned on the way out so a persistence layer can never mutate live state, with the final assistant turn appended from the answer run() returned.

That last part is not a detail. Nothing writes the final assistant turn back into the agent's history: the loop appends assistant turns only when they carry tool calls, and the turn that ends a run carries none. An agent that stored the conversation without it would drop its own reply every single turn and answer the next one having forgotten what it just said — still fluent, still wrong, and invisible until somebody reads a transcript. A test asserts on the provider's actual wire that turn 2's request contains turn 1's assistant reply verbatim.

Calling it adds no events, no scope writes and no capture: recordings are byte-identical to an agent that never calls it. After a paused run it returns the conversation as of the pause with no answer appended. And it does not trim: bounding what the model is shown is the memory subsystem's job, not a silent cap applied on the way to storage.

The conformance suites

The claim this whole subpath rests on is that one handler serves any host unchanged. That is not an assertion in prose, it is a test — now two of them, one per port.

The host contract runs against three subjects: nodeHost, a minimal in-process host written in the test file which declares no capabilities so the buffering path is exercised rather than assumed, and agentCoreRuntimeHost, a real cloud runtime's container contract. All three serve the same handler constant, and a final set of cases invokes all of them and compares the answers directly.

It pins: same handler and same output on every host; sessionId and headers arriving as the transport declared them; fail() reaching the caller; a handler that throws reported as a failure rather than a hang; a handler that answers nothing failed rather than left open; chunks observed if and only if the host declares streaming; requireCapability naming the adapter; close() draining in-flight work and refusing what comes after; and close() being idempotent.

The conversation contract does the same for the second port, against nodeHost's WebSocket door, an in-process conversation host with different declared ceilings, and agentCoreRuntimeHost's /ws. It pins: frames carried whole and in order both ways; sessionId and headers arriving; a frame that lands before the handler subscribed still being delivered; the ceiling living on the host and never on the conversation; send() refusing by name past the ceiling and after the close; onClose firing exactly once and saying who ended it; close() draining what was already sent; a subscriber that throws ending that conversation and leaving the host serving; and close() being idempotent.

Both suites are the artifact a future adapter has to pass, and both are run by a cloud adapter that needed no port change to pass them — which is the only evidence that means anything about a claim like this. A third test greps the hosting sources for vendor names, file by file, including every new port file.

Try it

/** One turn of a conversation. `sessionId` is what makes it a conversation. */async function say(base: string, sessionId: string, input: string): Promise<string> {  const response = await fetch(`${base}/invoke`, {    method: 'POST',    headers: { 'content-type': 'application/json' },    body: JSON.stringify({ input, sessionId }),  });  const body = (await response.json()) as { output?: string; error?: string };  if (body.error) throw new Error(body.error);  return body.output ?? '';}
npm run example examples/deploy/standing-agent.ts

It binds an ephemeral port, has a two-turn conversation with itself over HTTP, proves turn 2 remembered turn 1 and that a different sessionId saw none of it, then shuts down. SERVE=1 keeps it listening instead.

And the durable half — a crash whose work survives, and a question answered a request later:

/** One request. `decision` is what makes it a resume rather than a new message. */async function invoke(  base: string,  sessionId: string,  body: { input: string; decision?: unknown },): Promise<{ status: number; json: Record<string, unknown> }> {  const response = await fetch(`${base}/invoke`, {    method: 'POST',    headers: { 'content-type': 'application/json' },    body: JSON.stringify({ ...body, sessionId }),  });  return { status: response.status, json: (await response.json()) as Record<string, unknown> };}
npm run example examples/deploy/durable-sessions.ts

It crashes a run on purpose under durability: 'sync' and shows that both tool results survived in the store, then holds a human-in-the-loop turn over HTTP end to end: 202 with the ask and its receipts, a nudge refused with ERR_AWAITING_DECISION, and 200 once a decision arrives.

And the multi-user half — two people served at the same time, each remembering their own conversation and neither seeing the other's:

/** Serve MANY sessions at once: one agent each, bounded, evicted LRU. */async function serve(port: number, provider?: LLMProvider) {  return standingAgent({    // The one change from the single-agent shape. Return a NEW agent every    // call — a factory that closes over one instance is refused by name,    // because two sessions on one instance is the corruption this prevents.    agentFactory: () => buildAgent(provider),    sessions: memorySessions(), // swap for sqliteSessions/Redis; nothing else changes    host: nodeHost({      port,      hostname: '127.0.0.1',      // Where the session id arrives. 'x-session-id' is the default; naming it      // is how a deployment behind a gateway points it somewhere else.      sessionHeader: 'x-session-id',    }),    // How many sessions hold an agent at once. A new session at a full pool    // retires the least recently used IDLE one — its conversation stays in the    // store, so its next request re-hydrates onto a fresh instance and the    // person never knows. A RUNNING session is never evicted.    maxActiveSessions: 50,  });}
npm run example examples/deploy/multi-user.ts

It has two sessions talk to it concurrently, reports the wall clock for the pair in parallel against the same pair in series, and proves each session's recall is its own.

Status

PieceDoorPeer depStatus
AgentHost / ConversationHost / SessionLifecycle portsagentfootprint/hostingShipped
nodeHost (+ its WebSocket door, no dependency)agentfootprint/hostingnone (node:http)Shipped — passes both conformance suites over a real socket
httpHost / HttpWire (write your own HTTP adapter)agentfootprint/hostingnoneShipped
memorySessionsagentfootprint/hostingnoneShipped
sqliteSessionsagentfootprint/hostingnone (node:sqlite, Node ≥ 22.5)Shipped
standingAgent + durability (exit / async / sync)agentfootprint/hostingShipped
standingAgent({ agentFactory }) — per-session agent poolagentfootprint/hostingShipped (9.10.0); parallelism, eviction→re-hydrate and both refusals pinned by test
browserSessionId() (client half) + nodeHost({ sessionHeader, sessionCookie })agentfootprint / agentfootprint/hostingnoneShipped (9.10.0)
identity: { verify } + jwksIdentityagentfootprint/hosting · agentfootprint/securityjose (adapter only)Field-validated 2026-08-13 — an independent field trial on a live cloud-backed door: a remote JWKS fetched and cached, RS256 verified end to end, 401s with zero provider calls, and no token in any body or error. The IdP is not named by the finding, so this is the protocol path rather than interop with a particular vendor. See the evidence callout
admission: { decide } — the seamagentfootprint/hostingField-validated 2026-08-13 — the same trial: two turns admitted, the third answered 429 before the provider or session store was invoked
turnsPerHour — the reference policyagentfootprint/hostingContract-shaped and tested. The trial recorded the 429 and restated this helper's per-process bound, but not the shipped helper as the policy under test — and a run of minutes cannot cross an hour, so the window's roll-off and reset are proven by tests, not in the field. Per-process ledger — that caveat stands
Session ownership (envelopeOwner, write-once) + session-list / session-transcriptagentfootprint/hostingContract field-validated 2026-08-13 — an independent field trial exercised write-once ownership, the cross-user 404, feature detection and mayOpenSession on the ordinary turn. The store underneath was the trial's own Firestore adapter, so the CONTRACT earned this rung and no shipped adapter borrows it
onIngressDecision — the ingress recordagentfootprint/hostingShipped (9.32.0). A stream you chain into your own sink; explicitly not part of auditExport()'s hash chain
firestoreSessionsTracked ticket, not scheduled here — a SessionLifecycle over Firestore with an indexed orderBy + document cursor, a TTL policy and write-once owner. It needs a new optional peer (@google-cloud/firestore) and an SDK pin test before any adapter code, so it is its own release rather than a line in this one. The port is two methods in the meantime
Agent Runtime host (agentRuntimeHost) — the second routeTracked ticket, deliberately unbuilt — a Node service really does run on Vertex Agent Runtime through the custom-container door (proven by an independent field trial, 2026-08-14; the recipe is on the Google page). What is missing is not the runtime but the CONTRACT: httpHost serves ONE invoke path and frames streams as SSE, while that runtime wants a SECOND route at its own path framed as NDJSON. That is a port-shape change, so it is its own release, gated on real demand
redisSessionsBring-your-own via the port — two methods; see the sketch above
agentCoreRuntimeHost / agentCoreRuntimeWireagentfootprint/hostingnone — plain HTTP, no AWS SDK on its pathShipped — really verified: passes the same host conformance suite as nodeHost over a real socket
agentCoreSessions({ store: 'session-storage' })agentfootprint/hostingnone (node:fs)Shipped
agentCoreSessions({ store: 'memory' })agentfootprint/hosting@aws-sdk/client-bedrock-agentcoreShipped; contract-mapped and injection-tested, command names pinned
Binary conversation framesDeferred — a capability question, until a consumer needs it

Next

On this page

The two portsThe reply's terminalsWhy the ports look like nothing in particularCapabilities are read, never assumednodeHost — the plain HTTP adapterhttpHost — writing your own HTTP adapterOne port, two protocols — { server }Your routes on the host's socket — onUnhandledA door that stays open — serveConversations()Frames are strings, and that is a decisionThe ceilings are declared, not discoveredonClose says who, not which numberTwo refusals you will meet, and neither is silentOne socket, two doorsnodeHost's door, and no dependencyThe agent inside a conversation is governed the same wayWhat this release deliberately did NOT doSessions: what crosses the restartThe session-store axisUnreadable is not the same as absentstandingAgent — the composerResuming is a REPLAY, and that has a costOne run per instance, and why it is not a tuning knobConcurrency & sessionsThe agent pool — standingAgent({ agentFactory })Which session is this? — the header, the cookie, and the browserA session is a conversation — and now a memory namespaceMany processes — redisSessions is not builtA pause is unfinished work, and now it is keptAnswering it: the resume-invoke contractA pause the wire could not describeRedeeming claim tickets — artifact-head / artifact-getOne isolation rule, re-composed — never a second oneThe refusals, each naming its fixVerified identity — identity: { verify }The refusal lawAnd it decides whose sessions are whoseThe token never travelsWriting your own verifierjwksIdentity — the shipped adapterAdmission — admission: { decide }What "spend" honestly meansThe ingress record — onIngressDecisionIt is a stream, not a chainWhy the composer, and not your own verifyWhat it never carries, and what it cannot seeSession history — session-list / session-transcriptBoth ops REQUIRE verified identityA foreign transcript is a 404, not a 403What a transcript contains, and what it does notThe owner index — two OPTIONAL store membersOne op field, two domainsdurability — how often progress becomes crash-survivableWhat 'sync' actually guaranteesWhere the writes happenagent.checkpoint()The conformance suitesTry itStatusNext