InfrastructureAWS

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

AdapterPort it fillsSubpath
agentCoreRuntimeHost()AgentHost and ConversationHost — the container contract's two doors, /invocations and /ws, on one socketagentfootprint/hosting
agentCoreSessions({ store })SessionLifecycle — where a conversation lives between requestsagentfootprint/hosting
agentCorePolicy({ policyStoreId })Retired in 9.4.0 — AgentCore enforces policy at the Gateway; there was never a data-plane call to makeagentfootprint/security
gatewayTransport({ url, credentials })McpTransport — MCP auth headers vended per requestagentfootprint/providers
AgentCoreStore.search()MemoryStore.search — server-side semantic retrievalagentfootprint/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:

EndpointContract
POST /invocations{ "prompt": "..." } in → { "response", "status" } out
GET /ping{ "status": "Healthy" | "HealthyBusy", "time_of_last_update": <unix seconds> }
SessionX-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.ts

It 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 working

The 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 factHow 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 affinitythe 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 bearerthe 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:

  • runtimeSessionId must 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 the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header and becomes HostRequest.sessionId; agentCoreSessions then 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 /ws fine. The vendor documentation describes WebSocket support for container deployments only, which reads as a restriction; it is not one in practice. Since 7.25.0 serveConversations() 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 alternatives

Why. 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:

  • search reads a different population than list. list returns the events this store wrote; search returns the records AgentCore's extraction strategies derived from those events. Their ids belong to AgentCore, so store.get(result.entry.id) will not find them. Results carry metadata.source: 'agentcore-memory-record' so this is never a surprise.
  • Filters split across the boundary. searchStrategyId and the namespace reach AgentCore's side; k, minScore and tiers are applied to what comes back. A tiers filter 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:

  1. nodeHost had 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 in httpHost({ name, wire, invokePath, healthPath }) and both adapters are configurations of it — so two of them can never quietly drift apart on what close() drains.
  2. MCP headers were connection-lifetime only. That is a real gap and it is not AgentCore's: any expiring token hits it. Fixed generically.
  3. SearchOptions had 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

On this page