AgentCore adapters — runtime, sessions, policy, gateway
agentCoreRuntimeHost, agentCoreSessions, agentCorePolicy and gatewayTransport — the AgentCore adapter set on ports that already existed, plus a plain statement of which parts are really verified and which are contract-mapped.
A cloud adapter should be vendor paths, a header mapping and some SDK calls. If writing one needs a change to a port, the port was wrong.
Hosting shipped two ports that name no cloud, and a conformance suite that any host adapter has to pass. This page is what happened when the first cloud adapter was written against them.
What shipped
| Adapter | Port it fills | Subpath |
|---|---|---|
agentCoreRuntimeHost() | AgentHost and ConversationHost — the container contract's two doors, /invocations and /ws, on one socket | agentfootprint/hosting |
agentCoreSessions({ store }) | SessionLifecycle — where a conversation lives between requests | agentfootprint/hosting |
agentCorePolicy({ policyStoreId }) | Retired in 9.4.0 — AgentCore enforces policy at the Gateway; there was never a data-plane call to make | agentfootprint/security |
gatewayTransport({ url, credentials }) | McpTransport — MCP auth headers vended per request | agentfootprint/providers |
AgentCoreStore.search() | MemoryStore.search — server-side semantic retrieval | agentfootprint/memory |
How much of this is verified — the honest version
agentCoreRuntimeHost is plain HTTP and plain WebSocket with no AWS SDK on
its path, and it passes the same host conformance suite as nodeHost AND the
same conversation conformance suite, both over real sockets. That is real
verification.
Everything that talks to AWS — agentCoreSessions({ store: 'memory' }),
agentCorePolicy, AgentCoreStore.search() — is contract-mapped and
injection-tested: the SDK calls are exercised through the _client / _sdk
seams, and no test in this repo reaches AWS or pretends to. Confirm the command
and field names against your installed @aws-sdk/client-bedrock-agentcore.
Both session modes have now run against the real service. A production
integration deployed them and reported back: { store: 'session-storage' }
works as documented, and { store: 'memory' } had a real defect that no
injected fake could have shown — fixed in 7.22.1, below.
AgentCoreStore.search() is still contract-mapped only.
And contract-mapped was not enough. A second field report tested 9.3.0 live
and found two adapters dispatching calls that were never made against AWS:
agentCorePolicy sent a command that does not exist (retired in 9.4.0, below),
and agentCoreIdentity called a method that a command-based *Client does not
have (fixed in 9.4.0 — it now goes through send(new Command(...)), like the
memory adapter always did). Both compiled and both passed their tests, because
every test injected past the SDK. Since 9.4.0 a single registry pins the
command names every AWS adapter dispatches and fails the build if one is
reached for that the installed SDK does not export.
Run it: the whole container entry point
AgentCore Runtime is a container contract — an ARM64 image serving HTTP on
0.0.0.0:8080:
| Endpoint | Contract |
|---|---|
POST /invocations | { "prompt": "..." } in → { "response", "status" } out |
GET /ping | { "status": "Healthy" | "HealthyBusy", "time_of_last_update": <unix seconds> } |
| Session | X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header |
/** * The container's entry point. `agentCoreRuntimeHost()` already knows the two * paths, the port, the session header and the body shapes, so nothing here is * HTTP. * * `{ store: 'session-storage' }` keeps each conversation in a JSON file in the * runtime's own session storage, which survives a stop/resume of the container. * Swap it for `{ store: 'memory', memoryId }` to outlive the session entirely — * the agent above does not change either way. */async function serve(agent: Agent, port: number, sessionPath?: string) { return standingAgent({ agent, host: agentCoreRuntimeHost({ port, hostname: '127.0.0.1' }), sessions: agentCoreSessions({ store: 'session-storage', ...(sessionPath !== undefined && { path: sessionPath }), }), });}That is the entire host. standingAgent is the same composer you would use on
a laptop; only the two adapters passed to it name a cloud.
npm run example examples/deploy/agentcore-runtime.tsIt binds an ephemeral port, probes /ping, has a two-turn conversation through
the session header, proves turn 2 remembered turn 1, and exits.
AGENTCORE_SERVE=1 keeps it listening on :8080 instead.
/** One call, exactly as the runtime makes it: prompt in the body, conversation in a header. */async function invoke(base: string, prompt: string, sessionId?: string): Promise<AgentCoreReply> { const response = await fetch(`${base}/invocations`, { method: 'POST', headers: { 'content-type': 'application/json', ...(sessionId !== undefined && { [SESSION_HEADER]: sessionId }), }, body: JSON.stringify({ prompt }), }); return (await response.json()) as AgentCoreReply;}/** `{ response, status: 'success' }` on the way out, `{ error, status: 'error' }` when it fails. */interface AgentCoreReply { readonly response?: string; readonly status?: string; readonly error?: string;}busy is the one option worth knowing about. The runtime polls /ping to
decide whether to send you more work, so "am I busy" is a live fact about the
process rather than a setting:
let inFlight = 0;
agentCoreRuntimeHost({ busy: () => inFlight > 0 }); // → 'HealthyBusy' while workingThe container gets one port — { server }
A container that must serve a WebSocket upgrade and the runtime's two routes
has one port to do it on, so the host can attach to a node:http server you own
instead of binding its own:
import { createServer } from 'node:http';
const server = createServer();
server.on('upgrade', handleWebSocket); // yours
await new Promise<void>((r) => server.listen(8080, '0.0.0.0', r));
const handle = await standingAgent({
agent,
host: agentCoreRuntimeHost({ server }), // ← binds nothing
sessions: agentCoreSessions({ store: 'session-storage' }),
});/invocations and /ping answer on your socket; every other path is yours, and
this adapter will not write a 404 on it. close() detaches and drains, leaving
your server listening. port and hostname are refused beside server — the
contract's own :8080 default included, because with your server this adapter
binds nothing and so names nothing. The laws (and the one consequence worth
knowing: an unmatched path hangs rather than 404s if nobody routes it) are the
same for every adapter built on httpHost — see
One port, two protocols.
If all you want is a route of your own beside the runtime's — a /debug/trace,
say — the inverse option is cheaper: agentCoreRuntimeHost({ onUnhandled })
keeps the host binding the container's port and hands you every path it does not
own, instead of answering 404 for your application. /invocations, /ping and
/ws never reach it, and it is refused beside { server }, where unmatched
paths already reach your own listeners. See
Your routes on the host's socket.
The runtime's second door — /ws
The runtime exposes exactly two doors into a container: request/response, and a
bidirectional WebSocket on the same port. agentCoreRuntimeHost now serves
both, on one socket, with nothing to install:
const host = agentCoreRuntimeHost(); // 0.0.0.0:8080
await standingAgent({ agent, host, sessions }); // POST /invocations
await host.serveConversations((conversation) => { // WS /ws
conversation.onFrame((frame) => conversation.send(handle(frame)));
conversation.onClose(({ by, reason }) => log(by, reason));
});You do not need { server } for this: the two doors share the socket by
construction, which is what the single-port container required.
Three things here are this runtime's spelling and live only in its adapter — the port knows none of them:
| Runtime fact | How it reaches your handler |
|---|---|
{ maxFrameBytes: 32768, idleMs: 900000 } | host.conversationLimits, declared. Chunk and heartbeat on your own protocol; the port does neither. maxFrameBytes is enforced both ways here, idleMs is reported — the timeout belongs to what sits in front of the container. |
| Session affinity | the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header, or the query string (?…Session-Id= or ?sessionId=) — because a browser's WebSocket API cannot set a header. The header wins when both arrive. Arrives as conversation.sessionId. |
Sec-WebSocket-Protocol bearer | the vendor's documented browser scheme, mapped into conversation.headers.authorization as Bearer <token> with the base64url wrapper undone. The echoed subprotocol is the sentinel, in the client's own spelling — never the token. The raw sec-websocket-protocol header survives so you can read the offer yourself. |
The browser scheme, in AWS's words: "The token must be base64url-encoded and prefixed with base64UrlBearerAuthorization., followed by the sentinel subprotocol base64UrlBearerAuthorization" — and, on the same page, "Subprotocols other than base64UrlBearerAuthorization are not yet supported." So a browser offers the pair:
new WebSocket(url, [`base64UrlBearerAuthorization.${base64url}`, 'base64UrlBearerAuthorization']);Two shapes are refused by name rather than degraded, because both would produce a connection that looks authenticated and is not: a dotted value that is not valid base64url (a token that does not decode is not a credential), and a dotted value offered without the sentinel beside it (there is then nothing safe to echo — the token must never travel back in a response header).
Before 7.27.1 this mapping read spellings nobody documented
It looked for bearer and bearer.<token> — words this library invented. AgentCore's front door forwards no subprotocol but base64UrlBearerAuthorization, so a real documented browser handshake matched neither and the mapping returned {}: the credential silently dropped. The invented spellings are gone rather than kept beside the real one — a door nobody can walk through should not be advertised as one. If you want a generic bearer-subprotocol mapping off this runtime, that belongs to the generic wire, with its own evidence.
What is still unverified: whether the service forwards the Sec-WebSocket-Protocol offer through to your container at all. Confirming it needs a CUSTOM_JWT runtime, and nobody has run one. If it forwards, this fix is what makes browser OAuth work; if it does not, this mapping implements the documented contract correctly for the day it does. Either way the shape above is what the vendor documents — no guesses of ours remain in it.
That last row is the rule the whole port rests on: a credential a transport
spells its own way becomes an ordinary header, never a port field. Nothing on
HostConversation is spelled the way one vendor spells it — and nothing here
authenticates anything: a token that arrived is a claim, exactly like the
session id beside it.
readAgentCoreConversation(facts) is exported so the mapping is reviewable
without binding a socket, the same way the body shapes are.
This door is really verified, in the same sense the request door is
/ws is plain WebSocket with no AWS SDK on its path, and it passes the same
conversation conformance suite as nodeHost's door — over a real socket, with a
plain client, driven by the same handler constant. That is real verification.
The frame codec underneath is checked against the byte sequences RFC 6455 §5.7 publishes. It is not run against the Autobahn suite, no extension is negotiated, and there are no binary frames — see Hosting for the boundary stated in full.
Where the conversation lives
agentCoreSessions makes you choose the checkpoint's home at construction,
never per call — a store that silently changed where it wrote is one you cannot
reason about after an incident.
// A JSON file in the runtime's own session storage. No AWS SDK at all.
// Survives a stop/resume of the container; ends when the session ends.
agentCoreSessions({ store: 'session-storage' }); // default /tmp/agentcore-session
agentCoreSessions({ store: 'session-storage', path: '/data/sessions.json' });
// One AgentCore Memory event per persist. Outlives the session, the container
// and the deployment; costs an API call per turn.
agentCoreSessions({ store: 'memory', memoryId: process.env.MEMORY_ID!, region: 'us-west-2' });Both store the same CheckpointEnvelope, and both refuse an unknown format
by name rather than restoring half a conversation — that law comes from
readEnvelope, the one implementation of it, not from a copy in each adapter.
The same is true of its other half: a stored session that is present but
unreadable raises UnreadableEnvelopeError naming the session, rather than
hydrating as "no conversation". See
Unreadable is not the same as absent.
The file mode writes then renames, so a container killed mid-write leaves the previous conversation intact rather than a truncated file nothing can parse.
The 'memory' mode stores the envelope as JSON text (7.22.1)
Blobs written by 7.15.0–7.22.0 are unrecoverable — sessions AND memory entries
Field-reproduced on the real service. Given an OBJECT as an event's blob, the
service stores its own host language's toString() rendering of it and returns
that string — {format=conversation-v1, data={...}}. It is not JSON, and the
mangling is lossy: there is nothing to migrate and no archaeology worth
doing. Worse, the reader accepted objects only, so the string decoded to nothing
and hydrate answered "no session" — every conversation the store kept was
unreadable, and invisible until a deployment boundary lost somebody's chat.
AgentCoreStore wrote its memory entries the same way and lost them the same
way; both are fixed here.
7.22.1 writes JSON.stringify(envelope) and reads a string blob back
through JSON.parse (object blobs still work, for a caller-supplied client).
Anything it still cannot decode is refused loudly, naming the session, so a
pre-7.22.1 envelope announces itself instead of quietly becoming a fresh start.
Sessions written before upgrading are gone; new ones round-trip.
AgentCoreStore had the same defect, fixed in the same release
The session store and the memory store wrote event blobs the same way, so they
had the same bug. AgentCoreStore now writes each MemoryEntry as JSON
text too, parses a string blob back (objects still accepted), and refuses a
blob it cannot decode with UnreadableMemoryEntryError — naming the event and
the session — instead of skipping it.
Skipping was the whole problem: a list() one entry short reads as "that was
never remembered", and an agent answering without a memory it has looks
exactly like an agent that was never told. Entries written before 7.22.1 are
unrecoverable for the same reason session envelopes are — delete them, or
point the store at a fresh memory resource.
The refusal quotes nothing of the entry, where the session refusal quotes a
capped prefix. Same discipline, different shape: a CheckpointEnvelope opens
with format / data / savedAt, so a prefix is metadata; a MemoryEntry
opens with id and then value, so its second field is the thing somebody
asked the agent to remember. storedShape reports type, length, JSON-ness and
the opening character — enough to recognise a mangled encoding, and no part of
the memory.
Two things the service checks that its docs do not say loudly
Both are field facts, recorded here because this is where an integrator looks:
runtimeSessionIdmust be at least 33 characters. The service validates the length and rejects shorter ids. A tidy"c-1"or a bare user id will not do — pass something long (a UUID, or your own id with a stable prefix). The same value arrives as theX-Amzn-Bedrock-AgentCore-Runtime-Session-Idheader and becomesHostRequest.sessionId;agentCoreSessionsthen slugs and length-bounds it for AgentCore Memory's own id rules, which is a separate constraint from this one.- A direct-code (zip /
NODE_22) deployment serves/wsfine. The vendor documentation describes WebSocket support for container deployments only, which reads as a restriction; it is not one in practice. Since 7.25.0serveConversations()serves that door for you on the same socket as/invocations;{ server }remains the escape hatch for anything the ports do not express.
Policy: enforced at the Gateway, not here (retired 9.4.0)
agentCorePolicy() filled the PermissionChecker port by asking an AgentCore
policy store to evaluate every attempted tool call. It could never have
worked, and as of 9.4.0 it refuses to be constructed.
import { agentCorePolicy } from 'agentfootprint/security';
agentCorePolicy({ policyStoreId });
// throws AgentCorePolicyRetiredError — with the reason and the alternativesWhy. It dispatched EvaluatePolicyCommand against
@aws-sdk/client-bedrock-agentcore. That command does not exist in any version
of that package, under any name. AgentCore has no data-plane
"evaluate this permission" operation at all: the policy surface is
control-plane only, and AgentCore enforces policy AT THE GATEWAY, in
front of the tool, before a request reaches your process. Every evaluation the
adapter ran therefore ended in its own catch — and the default there is
fail-closed, so it denied every tool call while reporting the policy engine
unreachable. It compiled, and its forty-odd tests passed, because every one of
them injected a client past the SDK.
The library's role is to surface Gateway denials honestly, not to
pre-evaluate a second copy of the rule. A Gateway-enforced denial comes back
as an MCP error on the tool call made through mcpClient(...), and lands
in the ReAct loop as that tool's result — which the model reads and adapts to,
exactly like any other refusal.
For rules you own, the same port is open and nothing about it changed:
import { PermissionPolicy } from 'agentfootprint/security';
const policy = PermissionPolicy.fromRoles(
{ readonly: ['lookup'], admin: ['lookup', 'refund'] },
'readonly',
);
const agent = Agent.create({ provider, model, permissionChecker: policy }).build();For anything conditional — per-argument, per-sequence, ask-a-human — use the
.toolMiddleware() chain, which sees the call and the run's
own history. A remote checker is still perfectly possible: a PermissionChecker
is an object with a check() method, and yours can call whatever service you
like.
The export stays, and refuses
Deleting an export breaks a build with a module-resolution error that explains
nothing. agentCorePolicy and all of its types are still exported and still
type-check; calling the factory throws AgentCorePolicyRetiredError (code
ERR_AGENTCORE_POLICY_RETIRED) carrying the whole explanation — the phantom
command, the Gateway, PermissionPolicy.fromRoles, .toolMiddleware(), and
where denials really arrive. Whether the symbol is removed is a decision for
10.0.
gatedTools is a different layer, and always was
gatedTools(...) decides what the model is shown; a permissionChecker
decides what actually runs. A tool the gate hides is never evaluated — the
model cannot call what it cannot see — and a tool the gate shows still has to
get past the checker. Only a local policy can double as a gatedTools
predicate: that predicate is synchronous.
Gateway tools: headers vended per request
An AgentCore Gateway exposes tools over MCP behind a token with an expiry.
The http transport takes headers once and reuses them for the life of the
connection, which is right for a static API key and wrong here: a standing
agent outlives its bearer token, and the failure is a burst of 401s an hour
into a session that tested perfectly.
gatewayTransport asks the credential provider on every
request:
import { agentCoreIdentity } from 'agentfootprint/security';
import { gatewayTransport, mcpClient, gatedTools, staticTools } from 'agentfootprint/providers';
const gateway = await mcpClient({
name: 'gateway',
transport: gatewayTransport({
url: process.env.GATEWAY_MCP_URL!,
credentials: agentCoreIdentity({ region: 'us-west-2' }),
service: 'gateway',
}),
});
const tools = gatedTools(staticTools(await gateway.tools()), (name) => allowed.has(name));
Agent.create({ provider, model }).toolProvider(tools).build();The token is used once and dropped
It is not cached between requests, not stored on the transport object, not put in an event, a log, or any error this transport throws — including the errors thrown while it is holding one. A test asserts exactly that: a hostile logger capturing every console channel, the serialized transport, and every thrown error never sees the header value.
Nothing about this is AgentCore-specific — it is the CredentialProvider port
on one side and Streamable HTTP on the other, so any MCP endpoint behind an
expiring token can use it. If consent is needed, it throws a
GatewayAuthorizationRequiredError carrying the authorization URL: a transport
cannot run a consent flow mid-request, so it says so instead of hanging.
stdio and http are untouched — gateway is a third member of the same
union.
Memory search: the one place a port had to give
AgentCoreStore.search() now wraps RetrieveMemoryRecords. Getting there
surfaced a genuine mismatch, and the fix was to say so rather than to paper
over it.
MemoryStore.search(identity, vector, options?) takes a vector, because the
reference backends rank locally by cosine. AgentCore embeds and ranks on its own
side, and its retrieval API takes text — so the vector is the one thing it
cannot use.
The port gained one optional field, SearchOptions.text, and the adapter
refuses without it:
const results = await store.search(identity, queryVector, {
text: 'where does Ada like to sit?', // ← what AgentCore actually needs
k: 5,
});Backends that rank locally ignore text and results are unchanged, so
passing both is always safe. Omit it against AgentCore and you get a corrective
error naming what is missing — not an empty array, which reads as "no matches"
when it really means "wrong query form".
Two more things worth knowing:
searchreads a different population thanlist.listreturns the events this store wrote;searchreturns the records AgentCore's extraction strategies derived from those events. Their ids belong to AgentCore, sostore.get(result.entry.id)will not find them. Results carrymetadata.source: 'agentcore-memory-record'so this is never a surprise.- Filters split across the boundary.
searchStrategyIdand the namespace reach AgentCore's side;k,minScoreandtiersare applied to what comes back. Atiersfilter excludes everything, because records carry no tier — silently ignoring a filter you asked for would be worse.
new AgentCoreStore({
memoryId,
region: 'us-west-2',
searchStrategyId: 'user-preference', // server-side filter
searchNamespace: ({ actorId }) => `/strategies/semantic/actors/${actorId}`,
});There is no stream(). AgentCore Memory has no streaming data-plane
operation and MemoryStore has no streaming method — inventing one for a single
backend is how a port stops being a port.
What this cost the ports
Nothing, which was the point of writing the suite first.
AgentHost, HostRequest, HostReply, SessionLifecycle, PermissionChecker
and CredentialProvider say exactly what they said before. Three seams moved,
and each one was in an adapter or an option bag, never in a port:
nodeHosthad no seam. It 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 inhttpHost({ name, wire, invokePath, healthPath })and both adapters are configurations of it — so two of them can never quietly drift apart on whatclose()drains.- MCP headers were connection-lifetime only. That is a real gap and it is not AgentCore's: any expiring token hits it. Fixed generically.
SearchOptionshad no text form. Added as one optional field that local-ranking stores ignore.
Type reference
Every adapter's options, its injectable client surface and its SDK-module shim
are exported, so you can type a wrapper, a fake or a mapping of your own without
reaching into dist.
Hosting (agentfootprint/hosting) —
AgentCoreRuntimeHostOptions is what agentCoreRuntimeHost() takes;
agentCoreRuntimeWire() returns the contract's HttpWire on its own, for
testing the body shapes without binding a socket.
AgentCoreSessionsOptions is the union of AgentCoreFileSessionsOptions
(the 'session-storage' mode, whose default path is
DEFAULT_SESSION_STORAGE_PATH) and AgentCoreMemorySessionsOptions (the
'memory' mode); AgentCoreSessionStore is just the two store names.
AgentCoreSessionClientLike is the two-method AgentCore Memory surface the
session store calls — implement it to inject a fake — and it yields
AgentCoreSessionEvent values. BedrockAgentCoreSessionSdkModule is the slice
of the AWS SDK the shim touches, injectable as _sdk to exercise the real
mapping against a fake module.
Policy (agentfootprint/security) — retired in 9.4.0 and kept exported so
existing imports still compile. AgentCorePolicyRetiredError is what the
factory throws (code ERR_AGENTCORE_POLICY_RETIRED). AgentCorePolicyOptions
still configures agentCorePolicy(), including AgentCorePolicyUnavailable,
the two-value type behind onUnavailable; AgentCorePolicyClientLike is the
one-method evaluate surface returning an AgentCorePolicyEvaluation, and
BedrockAgentCorePolicySdkModule is the SDK slice behind it. All four are
@deprecated.
Gateway (agentfootprint/providers) — GatewayTransportOptions is
what gatewayTransport() takes, and McpGatewayTransport is the descriptor it
returns: the third member of the McpTransport union.
Memory (agentfootprint/memory) — AgentCoreMemoryRecord is one
retrieved record as search() sees it, and AgentCoreEvent is one stored event
as AgentCoreLikeClient reports it.
Next steps
- Host it — the ports, the composer, and the conformance suite these adapters pass.
- AWS Bedrock AgentCore — the full capability map.
- AgentCore: step by step — build mock-first, then swap one adapter at a time.
- Ports & adapters — the pattern this page is one instance of.
AgentCore: step by step
A hands-on walkthrough — build the agent mock-first, then swap one adapter at a time (Bedrock, AgentCore Memory, Identity, Gateway, Observability), host the Runtime contract, and deploy. The agent code never changes; only the adapters do.
AgentCore Memory
AWS Bedrock AgentCore Memory adapter — session/event-based managed memory. Lazy-required @aws-sdk/client-bedrock-agentcore peer-dep.
