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:
| Type | What it says | Members |
|---|---|---|
AgentHost | something can call me | name, capabilities, serve(handler) |
ConversationHost | something can talk to me, both ways, until one of us ends it | name, capabilities, conversationLimits?, serveConversations(handler) |
SessionLifecycle | the conversation outlives the request | hydrate, persist, onWake? |
HttpHost / HttpWire | the HTTP work, parameterised by the JSON dialect it speaks | two paths + five body shapes |
standingAgent | the composer | hydrate → 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:
| Terminal | Meaning | Never |
|---|---|---|
complete(output) | It answered | — |
awaiting(pending) | It stopped to ask a person something. The paused run is stored; a later request carrying decision continues it | not 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 insistWhy 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.
jsonWire — nodeHost'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 server | A 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 working | Upgrades are a different event; attaching a request listener cannot touch them. This is the case the option exists for. |
close() detaches and drains | It 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 answer | If 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:
portorhostnamebesideserverthrows 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 theurlandportit is answering on, and a server with no address has neither. Calllisten()first, thenserve()— attaching afterlisten()is safe and is the intended order. (With a caller-owned server,handle.urlandhandle.portreport 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 own | Everything except invokePath, healthPath and conversationPath. |
| Owned paths never leak to it | Including 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 changes | No hook means the same 404 as before, byte for byte. |
Refused beside { server }, by name | There, 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 500 | Never 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 —
400and the socket closed.onUnhandledis a hook for routes: it is handed aServerResponseto 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 → HostReplyis 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:
maxFrameBytes | Enforced, 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. |
idleMs | Reported, 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. |
maxPendingBytes | Enforced. 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 ceilingBoth 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.tsIt 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
| Adapter | Survives | Peer dep | Ceiling | Choose it when |
|---|---|---|---|---|
memorySessions() | nothing — the process | none | one process | Tests and local dev. It says so in its name |
sqliteSessions({ file }) | restarts, crashes, deploys | none (node:sqlite, Node ≥ 22.5) | one machine, one file, one writer at a time | A single box, and you want conversations and paused runs to outlive it |
agentCoreSessions({ store: 'session-storage' }) | the container's session directory | none (node:fs) | one runtime session | A hosted AgentCore runtime that gives you session-scoped disk |
agentCoreSessions({ store: 'memory' }) | the AgentCore Memory resource | @aws-sdk/client-bedrock-agentcore | the service's | You want sessions managed, not filed |
| Your own | whatever your store does | yours | yours | Redis, 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:
- wake and hydrate the session,
- continue that session — a conversation, or a run that is waiting on a person — or start a fresh one,
- persist what the run left behind — before answering, so a queued next turn can never read state older than the answer already given,
- 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 aConcurrentRunErrornaming the session and the run already going (409over 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
Agentinstance. 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.
| Strategy | How | Parallelism | Costs | Choose it when |
|---|---|---|---|---|
| Platform-per-session | The runtime gives each session its own container/process; you serve one agent in it | The platform's — sessions never meet | Whatever the platform charges per session | You are on a runtime that already isolates sessions (AgentCore Runtime does) |
Agent pool — standingAgent({ agentFactory }) | This composer builds one agent per ACTIVE session, bounded and LRU-evicted | Sessions run at the same time; each session's turns serialize on its own instance | One instance per active session: memory, plus whatever your agent builds at construction | One process serves many people and you want them answered concurrently |
| Process-per-worker | N processes behind a load balancer, each with standingAgent({ agent }) | N at a time, sessions pinned or re-hydrated per request | A process each; a shared session store is mandatory | You 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
onConcurrentInvokepolicy. The Agent's ownRunInFlightErrornever 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) unlessshutdown: '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.
Which session is this? — the header, the cookie, and the browser
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 cookieIn 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:
| Symbol | Door | What it is |
|---|---|---|
DEFAULT_SESSION_HEADER | agentfootprint/hosting | 'x-session-id' — the header nodeHost reads when nothing says otherwise |
jsonWireWith(options) / JsonWireOptions | agentfootprint/hosting | nodeHost'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) / BrowserSessionIdOptions | agentfootprint (the main barrel) | The client half: mints once, keeps it, hands it back. { storageKey? } — two keys are two conversations |
DEFAULT_SESSION_STORAGE_KEY | agentfootprint | '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
identityyou 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.executeasctx.identity: a synthesized namespace is not something anybody named, and tools that want the session havectx.sessionIdfor 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:
- stores it as
'flowchart-v1'under that session, and - calls
reply.awaiting(pending)with aPendingAsk— the tool that asked, the question in plain words, the typedcheckInwith its evidence pack, or the middlewareask, plus the rawpauseDatauninterpreted.
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 outstanding →
AwaitingDecisionError(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 pending →
NoPendingAskError(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:
stored | What happened | What you do |
|---|---|---|
true | The 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 |
false | Nothing 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 before | Send 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.
awaitingis 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 ithead 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
| Reply | Status | When |
|---|---|---|
{ artifact: { ref, meta } } | 200 | artifact-head resolved |
{ artifact: { ref, meta, data } } | 200 | artifact-get resolved |
ERR_ARTIFACT_NOT_FOUND | 404 | missing, expired, or another scope's — deliberately indistinguishable |
ERR_ARTIFACT_SESSION_REQUIRED | 400 | the op named no session |
ERR_INVALID_WIRE_OP | 400 | an 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_STORE | 501 | the serving agent has no store — the refusal names the attach: Agent.create({ …, artifacts }) |
ERR_ARTIFACT_NOT_CARRIED | 501 | the 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.refusedevents on the serving agent — with notoolfield, 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 artifactdatapasses 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 …. Yourverifyis 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 answer — SessionNotFoundError,
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:
| mode | what it does | a crash costs you |
|---|---|---|
'exit' (default) | one write, when the run finishes | the whole turn |
'async' | a write is started whenever the conversation changes, and never waited on | whatever the newest un-landed write carried |
'sync' | persist-then-proceed | the 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.tsIt 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.tsIt 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.tsIt 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
| Piece | Door | Peer dep | Status |
|---|---|---|---|
AgentHost / ConversationHost / SessionLifecycle ports | agentfootprint/hosting | — | Shipped |
nodeHost (+ its WebSocket door, no dependency) | agentfootprint/hosting | none (node:http) | Shipped — passes both conformance suites over a real socket |
httpHost / HttpWire (write your own HTTP adapter) | agentfootprint/hosting | none | Shipped |
memorySessions | agentfootprint/hosting | none | Shipped |
sqliteSessions | agentfootprint/hosting | none (node:sqlite, Node ≥ 22.5) | Shipped |
standingAgent + durability (exit / async / sync) | agentfootprint/hosting | — | Shipped |
standingAgent({ agentFactory }) — per-session agent pool | agentfootprint/hosting | — | Shipped (9.10.0); parallelism, eviction→re-hydrate and both refusals pinned by test |
browserSessionId() (client half) + nodeHost({ sessionHeader, sessionCookie }) | agentfootprint / agentfootprint/hosting | none | Shipped (9.10.0) |
identity: { verify } + jwksIdentity | agentfootprint/hosting · agentfootprint/security | jose (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 seam | agentfootprint/hosting | — | Field-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 policy | agentfootprint/hosting | — | Contract-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-transcript | agentfootprint/hosting | — | Contract 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 record | agentfootprint/hosting | — | Shipped (9.32.0). A stream you chain into your own sink; explicitly not part of auditExport()'s hash chain |
firestoreSessions | — | — | Tracked 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 route | — | — | Tracked 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 |
redisSessions | — | — | Bring-your-own via the port — two methods; see the sketch above |
agentCoreRuntimeHost / agentCoreRuntimeWire | agentfootprint/hosting | none — plain HTTP, no AWS SDK on its path | Shipped — really verified: passes the same host conformance suite as nodeHost over a real socket |
agentCoreSessions({ store: 'session-storage' }) | agentfootprint/hosting | none (node:fs) | Shipped |
agentCoreSessions({ store: 'memory' }) | agentfootprint/hosting | @aws-sdk/client-bedrock-agentcore | Shipped; contract-mapped and injection-tested, command names pinned |
| Binary conversation frames | — | — | Deferred — a capability question, until a consumer needs it |
Next
- Sessions in a file —
sqliteSessionsin depth - Infrastructure: ports & adapters — the boundary this page is three ports of
- AWS & Bedrock AgentCore — the AgentCore runtime and session rows
- Pause & resume · Check in with the receipts — what a pause carries
Governance & policy
Four places a tool call can be stopped — visibility, the in-process permission gate, the middleware chain, and a gateway upstream where this library has nothing to attach. What enforces what, the exact refusal each one hands the model, and why agentCorePolicy was retired.
Tools & gateways
The McpClient port — four members, three transports. Where a tool comes from is a transport decision (stdio, fixed HTTP headers, per-request vending, or your own signing fetch); what a tool is allowed to do is a governance decision, and they compose without knowing about each other.
