AWS & Bedrock AgentCore
The first worked provider. Every AWS service agentfootprint adapts — Memory, Identity, Runtime hosting, Gateway MCP, Observability, Policy, S3 Vectors, Bedrock models and embedders — with its adapter, its door, its peer dependency, the SDK commands it dispatches, and its honest status.
Amazon Bedrock AgentCore is AWS's managed runtime + primitives for agents (Memory, Identity, Gateway, Observability, Code Interpreter, Browser). agentfootprint is framework-side — it ships adapters for the data-plane primitives and consumes a Gateway over MCP. You bring the control plane (provisioning) and the runtime host; everything else is a port you fill with an AgentCore adapter.
Prefer hands-on?
Follow AgentCore: step by step — build mock-first, then swap to AgentCore one adapter (one line) at a time. This page is the reference map behind it.
Two AgentCore surfaces
Control plane (bedrock-agentcore-control) creates resources (CreateMemory,
CreateGateway, CreateWorkloadIdentity, CreateAgentRuntime, …) — do this with the AWS
SDK / CDK. Data plane (bedrock-agentcore) uses them at runtime — this is what
agentfootprint's adapters wrap.
Service → adapter map
Every row: what the adapter is called, which door exports it, which peer dependency it lazily requires, which SDK commands it dispatches, and what its status honestly is.
| AWS service | Adapter | Door | Peer dep | Dispatches | Status | Capability page |
|---|---|---|---|---|---|---|
| AgentCore Memory | AgentCoreStore | agentfootprint/memory | @aws-sdk/client-bedrock-agentcore | CreateEvent, ListEvents, DeleteEvent, RetrieveMemoryRecords | Shipped; contract-mapped and injection-tested | Memory & stores |
| Bedrock Agents memory (prior generation) | BedrockAgentMemory — a reader, not a store | agentfootprint/memory | @aws-sdk/client-bedrock-agent-runtime | GetAgentMemory, DeleteAgentMemory | Shipped; contract-mapped and injection-tested | Memory & stores |
| AgentCore Identity | agentCoreIdentity | agentfootprint/security | @aws-sdk/client-bedrock-agentcore | GetWorkloadAccessTokenForJWT, GetWorkloadAccessTokenForUserId, GetResourceOauth2Token — 3 of the 6 Identity data-plane operations | Shipped; contract-mapped and injection-tested. The JWT exchange (9.12.0) is contract-shaped and tested; awaiting field use | Identity & credentials |
| AgentCore Runtime (hosting) | agentCoreRuntimeHost, agentCoreRuntimeWire | agentfootprint/hosting | none — plain HTTP | — | Shipped — really verified: passes the same host conformance suite as nodeHost over a real socket | Hosting & runtime |
| AgentCore Runtime (per-user identity) | agentCoreRuntimeWire reads X-Amzn-Bedrock-AgentCore-Runtime-User-Id → HostRequest.userId → the run's identity.principal | agentfootprint/hosting | none — a header | — | Shipped (9.12.0) — really verified: the header mapping is asserted over a real socket, and the generic wire is asserted NOT to read it | Identity & credentials |
| AgentCore Runtime (sessions) | agentCoreSessions({ store }) | agentfootprint/hosting | @aws-sdk/client-bedrock-agentcore — only for store: 'memory' | CreateEvent, ListEvents | Shipped; the file mode uses node:fs only | Hosting & runtime |
| AgentCore Gateway | gatewayTransport + mcpClient | agentfootprint/providers | @modelcontextprotocol/sdk | — (MCP over Streamable HTTP) | Shipped | Tools & gateways |
| AgentCore Observability | agentcoreObservability | agentfootprint/observe | @aws-sdk/client-cloudwatch-logs | CreateLogStream, PutLogEvents | Shipped; contract-mapped and injection-tested | Observability sinks |
| CloudWatch Logs (generic) | cloudwatchObservability | agentfootprint/observe | @aws-sdk/client-cloudwatch-logs | CreateLogStream, PutLogEvents | Shipped; contract-mapped and injection-tested | Observability sinks |
| X-Ray | xrayObservability | agentfootprint/observe | @aws-sdk/client-xray | PutTraceSegments | Shipped; contract-mapped and injection-tested | Observability sinks |
| AgentCore Policy | agentCorePolicy | agentfootprint/security | — | nothing | Retired 9.4.0 — refuses at construction | Governance & policy |
| S3 Vectors | s3VectorsStore | agentfootprint/memory | @aws-sdk/client-s3vectors | GetIndex, PutVectors, QueryVectors, GetVectors, ListVectors, DeleteVectors | Shipped (9.3.0); contract-mapped and injection-tested | Memory & stores |
| S3 (artifact store) | s3Artifacts | agentfootprint | @aws-sdk/client-s3 | PutObject, GetObject, HeadObject, DeleteObject, ListObjectsV2 — plus native putStream / getStream | 9.25.0 — contract-shaped and tested; awaiting field use | Artifacts |
| Bedrock models | bedrock() — Converse, model-agnostic | agentfootprint/providers | @aws-sdk/client-bedrock-runtime | Converse, ConverseStream | Shipped | AWS Bedrock |
| Bedrock embeddings | bedrockEmbedder() | agentfootprint/providers | @aws-sdk/client-bedrock-runtime | InvokeModel | Shipped | Embedders |
Actor on every event (EventMeta.principal / .tenant) | run({ identity }) — no adapter | agentfootprint (main) | none | — | Shipped (9.11.0); identical on both provider columns | Observability sinks |
Control plane (all Create*) | — | — | — | — | 🔴 Bridge it yourself — AWS SDK or CDK | — |
| Code Interpreter / Browser | — | — | — | — | 🔴 Bridge it — wrap as a defineTool or an MCP tool | Tools |
What each status label means, exactly
Really verified is used for one row only: agentCoreRuntimeHost is plain
HTTP with no AWS SDK on its path, and it passes the same host conformance suite
as nodeHost over a real socket.
Contract-mapped and injection-tested means the adapter's AWS calls are
exercised through its _client / _sdk seams — never against AWS. The command
names are pinned by test (below); the field semantics are read from the API
docs. Confirm them against your installed SDK. No adapter on this page — and
no adapter anywhere on this site — is described as verified in a production
field deployment. That phrase appears in one place only,
On-premises & self-hosted, where it describes a deployment
shape rather than an adapter.
The command-name pin
Every AWS adapter's dispatched SDK command constructors are pinned by test
in test/adapters/aws/awsCommandPin.ts, and a completeness assertion fails the
build for any src/** file that loads an @aws-sdk/* package without a row.
Adding or renaming a dispatched command means editing that row — it cannot be
done quietly.
The rule the registry exists to enforce is one line: a bare
@aws-sdk/client-* client is command-based. Its prototype is exactly
[constructor, destroy] plus send — per-operation method shortcuts live on the
aggregated client, so client.someOperation(...) is always wrong here.
Three adapters shipped violating it, which is why the registry exists:
| Version | The defect |
|---|---|
| 6.42.0 | An adapter dispatched commands belonging to a different service |
| 9.4.0 | agentCorePolicy dispatched EvaluatePolicyCommand, which does not exist in that package |
| 9.4.0 | agentCoreIdentity called client.getResourceOauth2Token(...) — a method that is never there, so the documented path failed 100% of the time on the first call |
Each compiled, and each passed tests that injected _client past the SDK. The
AWS SDKs are deliberately not devDependencies — six adapters prove their
missing-peer-dep refusals by real absence — so the real-module half of the suite
runs only where the SDKs are installed.
Memory — AgentCoreStore
The store is just the Memory-store port pointed at an AgentCore Memory resource. Its methods map 1:1 to data-plane events; the agent code is identical to the in-memory version.
import { defineMemory, MEMORY_TYPES, MEMORY_STRATEGIES } from 'agentfootprint/memory';
import { AgentCoreStore } from 'agentfootprint/memory';
const store = new AgentCoreStore({
memoryId: process.env.AGENTCORE_MEMORY_ID!,
region: 'us-west-2',
});
const memory = defineMemory({
id: 'conversation',
type: MEMORY_TYPES.EPISODIC,
strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
store,
});
Agent.create({ provider }).memory(memory).build();put/putMany → CreateEvent,list → ListEvents,delete → DeleteEvent.getanddeleteby id are list-then-find — AgentCore assigns event ids on write, so there is nothing to fetch by — andforgetlists then deletes each event, because AgentCore has noDeleteSession. The memoryidentity(conversationId) maps to the AgentCore sessionId.putIfVersionis emulated client-side;seen/recordSignature/feedbackare in-process shadow state and do not survive a restart. Pair withRedisStorefor durable recognition.- The full memory deep-dive lives in AgentCore Memory; the port and the other six stores are on Memory & stores.
search()wrapsRetrieveMemoryRecords. AgentCore ranks server-side and takes a text query, so passoptions.textalongside the vector — details and the reason in AgentCore adapters.
Identity — agentCoreIdentity
import { agentCoreIdentity } from 'agentfootprint/security';
const credentials = agentCoreIdentity({
region: 'us-west-2',
workloadName: 'workflow_assistant_agent',
userIdFor: ({ principal }) => principal, // per-(workload, user) token vault
});
Agent.create({ provider, model, credentials }).build();Resolves GetWorkloadAccessTokenForUserId / GetResourceOauth2Token at runtime. Swapping
from dev static credentials to managed AgentCore identity is this one line — no tool-code
change. Provisioning the providers (CreateWorkloadIdentity, CreateOauth2CredentialProvider)
is control-plane → SDK / CDK.
Since 9.12.0 a third operation joins them: pass the caller's own IdP token on the
request — getCredential({ service, mode: 'user', userToken }) — and the adapter
exchanges it through GetWorkloadAccessTokenForJWT before vending, so the vault
entry belongs to the person rather than to the agent. That is the outbound half
of per-user identity; the inbound half is
X-Amzn-Bedrock-AgentCore-Runtime-User-Id, which agentCoreRuntimeWire reads
and standingAgent turns into the run's principal. Both halves, with the flow
diagram: Identity & credentials.
Two facts worth carrying into a deployment: this adapter reports no expiresAt
(GetResourceOauth2TokenResponse has no expiry field, so there is nothing honest to report),
and until 9.4.0 it called a method that is never present on a command-based client — the
defect that bought the command-name pin above. The whole story, plus the port it implements:
Identity & credentials.
Gateway — tools over MCP
An AgentCore Gateway turns a Lambda / OpenAPI / Smithy / MCP target into a single MCP endpoint with Identity-brokered egress auth. agentfootprint consumes it through the Tools/Gateway port:
import { agentCoreIdentity } from 'agentfootprint/security';
import { gatewayTransport, mcpClient, staticTools, gatedTools } from 'agentfootprint/providers';
const gateway = await mcpClient({
name: 'gateway',
// Auth headers vended PER REQUEST, so a standing agent never outlives its token.
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 }).toolProvider(tools).build();agentfootprint does not create Gateways (no control-plane client) — CreateGateway /
CreateGatewayTarget are SDK / CDK.
Policy — enforced at the Gateway, not here
There is nothing to attach. AgentCore enforces policy at the Gateway, in front of the tool,
before a request reaches your process; a denial comes back as an MCP error on the tool call
made through mcpClient(...) and lands in the loop as that tool's result, which the model reads
and adapts to.
agentCorePolicy() tried to pre-evaluate the same rule in-process and is retired in 9.4.0:
it dispatched EvaluatePolicyCommand, which does not exist in
@aws-sdk/client-bedrock-agentcore — AgentCore has no data-plane authorization call at all. The
export remains and refuses at construction with the whole explanation.
For rules you own, use PermissionPolicy.fromRoles(...) as the permissionChecker, or the
.toolMiddleware() chain for conditional ones. See
AgentCore adapters.
Observability — agentcoreObservability
import { agentcoreObservability } from 'agentfootprint/observe';
import { microtaskBatchDriver } from 'footprintjs/detach';
agent.enable.observability({
strategy: agentcoreObservability({ region: 'us-west-2', logGroupName: '/agentfootprint/assistant' }),
detach: { driver: microtaskBatchDriver, mode: 'forget' }, // keep the loop unblocked
});Ships the typed AgentfootprintEvent stream to CloudWatch in AgentCore's GenAI-Observability
schema. otelObservability() / cloudwatchObservability() / xrayObservability() are the
other adapters behind the same port. Full step-by-step (AgentCore Observability and OTEL,
multi-exporter, detach): Exporters: AgentCore & OTEL.
Who the run was for (9.11.0)
run({ identity }) puts the caller's principal and tenant on every event
of the run (EventMeta.principal / EventMeta.tenant), which is what turns the
stream into a who → what → when audit record.
await agent.run(message, {
sessionId,
identity: { tenant: 'acme', principal: 'alice@acme.test', conversationId },
});This is column-independent — the same field, the same rule, the same code
path on AWS and on your own machines. The only difference between the columns is
which sink is reading it, and the envelope-serializing sinks
(agentcoreObservability, cloudwatchObservability, fileObservability,
auditExport) all carry it without knowing it exists. otelObservability places
it deliberately as two agentfootprint.* span attributes (principal.id and
tenant.id) on the run span; xrayObservability does not map it. Stamped only from an identity
a caller NAMED — never from a sessionId, never invented. Detail:
Observability sinks.
S3 Vectors — a corpus you can add to without a redeploy
Not an AgentCore primitive, but the AWS row a RAG deployment usually needs: object storage with a native vector index and a query API, priced like storage rather than like a database cluster.
import { s3VectorsStore } from 'agentfootprint/memory';
import { bedrockEmbedder } from 'agentfootprint/providers';
const store = s3VectorsStore({ bucket: 'my-corpus', index: 'docs', region: 'us-east-1' });
const embedder = bedrockEmbedder({ region: 'us-east-1' });search() maps onto QueryVectors, put/putMany onto PutVectors. Since
9.4.0 the store issues one memoized GetIndex before it trusts the index, and
refuses a metric, a metadata layout or a dimension it cannot serve honestly —
which needs the s3vectors:GetIndex IAM permission alongside the others. Create
the index yourself; this store never does. Full story, including the
nonFilterableMetadataKeys: ["af"] requirement:
Memory & stores.
S3 — the artifact store (s3Artifacts, 9.25.0)
The claim-check store in an S3 bucket: tools check big results in
and the model routes ~26-char tickets. Same five-verb port as every other adapter,
so this is a one-line swap from fileArtifacts.
import { s3Artifacts } from 'agentfootprint';
const artifacts = s3Artifacts({
bucket: 'my-agent-artifacts', // must already exist — this library never creates one
prefix: 'artifacts', // optional, so a bucket can be shared
region: 'us-east-1',
});
const agent = Agent.create({ provider, artifacts });S3ArtifactsOptions also takes client (your own pre-built S3Client, so
configuration and credentials stay yours) and retention. That same client
option is how an S3-compatible on-prem store (MinIO and the like) plugs in —
build the S3Client against your own endpoint and path style and pass it in;
the adapter dispatches the same five commands either way.
The object key is [<prefix>/]<tenant>/<principal>/<conversation>/<ref> — the
scope partitioned into key segments with the same percent-encoding law the
directory adapter uses, so a tenant of literally .. is a name, not a
navigation hop. A ref alone opens nothing: a wrong scope computes a different key,
S3 answers 404, and the caller reads null — one indistinguishable miss, never a
cross-tenant read.
The ticket rides as object metadata (x-amz-meta-af-artifact, one entry, ASCII
JSON), not a sidecar object. That makes head() exactly one HeadObject and
get() one GetObject returning ticket and bytes — a sidecar would double
every read and open a window where one exists and the other does not. The cost is
S3's 2 KB user-metadata cap, checked at put and refused by name (a ticket only
reaches it if the label is prose or parentRefs is a bibliography — the refusal
says which). The payload is the object body as canonical bytes, so a stored
report is downloadable from the console and is the report.
What a listing costs, plainly: ListObjectsV2 returns keys, sizes and
LastModified but never user metadata — so list() pages the keys, sorts
newest-first, and issues one HeadObject per row it returns, not for the whole
scope. An expired object inside a page is dropped from that page rather than
backfilled, so a page can come back short while more pages remain. That is the
honest trade; an index object listing every ticket would be cheaper and would be a
lost-update race between two writers.
Retention, and the operator's bulk tool. ttlMs stamps expiresAt at mint
(stated, never sprung) and expiry is enforced on read — an expired object
answers null and is swept on the way past, so an expired ticket can never be
redeemed. Budgets evict oldest-first (S3 has no cheap read-recency). A put with
no budget dials configured is exactly one PutObject — no scope scan, because
paying for a full listing on every write would be a real bill for no answer.
Reclaiming storage nobody reads again is what S3 Lifecycle rules are for. They
are the operator's bulk tool; this adapter never creates one (a lifecycle rule is a
cost and compliance decision that belongs to your infrastructure). Align them so the
rule is longer than the store's ttlMs, never shorter — the store's expiresAt
is the promise consumers read on the ticket, and a lifecycle rule that deletes first
makes a live ticket resolve to null before the time it printed:
{ "Rules": [{ "ID": "agentfootprint-artifacts", "Status": "Enabled",
"Filter": { "Prefix": "artifacts/" },
"Expiration": { "Days": 7 } }] }Streaming is native here: putStream / getStream ride the SDK's own streaming
upload and its response-stream mixin. Feature-detect before calling — see
Artifacts.
Failures never leak the key. An SDK error echoes the bucket and the object key —
and the key carries this run's tenant, principal and conversation. So the SDK's text
is withheld; what comes through is the operation, the exception's name and the HTTP
status. Every failure, including a 404: the only 404 that comes back raw is one
from a call that named one object (head, get, delete), and that one never
leaves the adapter, because the line that asked converts it to null on the spot.
A 404 is not automatically "no data". S3 says 404 for two different sentences,
and they are the same status: NoSuchKey (that object is not there) and
NoSuchBucket (this store is pointed at a bucket that does not exist). So the
adapter reads the code, not the status — a missing bucket raises, on every verb,
rather than reporting an empty scope over a configuration mistake. A 404 from a
write or a listing is an error for the same reason: those calls never asked
whether one object was there, so nothing is standing by to read the answer as null.
IAM: s3:PutObject, s3:GetObject, s3:DeleteObject and s3:ListBucket on the
prefix. Peer dependency @aws-sdk/client-s3, loaded lazily at construction — a
missing install refuses where the config was written, and a browser bundle never
sees it.
Bedrock — models and embeddings
bedrock() is model-agnostic through the Converse API: one adapter covers
Claude, Llama, Mistral, Titan and Mixtral. Tool calling works on both paths —
complete() reads toolUse blocks off the response, and stream() accumulates
the ConverseStream contentBlockStart / contentBlockDelta / contentBlockStop
events keyed by contentBlockIndex, so parallel tool calls that interleave are
reassembled correctly. A parity test pins the two paths together.
Honest by construction
A response with stopReason: 'tool_use' and zero parsed tool calls is never
returned quietly: the provider throws a BedrockProviderError with
code: 'BEDROCK_STREAM_TOOLUSE_LOST' so a reliability rule can act, instead of
the agent confidently answering without running its tools. Applied on both
exits, so the two paths cannot drift apart again. (Streamed tool calls were
genuinely dropped before 7.6.1 — that is history now, not current behaviour.)
Documented limits: multi-modal is not exposed (text content only), and Bedrock Guardrails are not wrapped — pass them via the SDK client directly. Details: AWS Bedrock.
bedrockEmbedder() speaks per-model body shapes (Titan V2/V1, Cohere Embed
English / Multilingual v3) and declares maxInputChars — 32,000 for Titan's
8,192-token window, 2,000 for Cohere, which batches 96 texts per call. See
Embedders.
Hosting — where the parallelism comes from
From the platform. AgentCore Runtime is session-oriented: the runtime gives
a session its own container and keeps that container's storage for the life of
the session, so two sessions are two containers and never meet. That is the
first row of the concurrency table —
platform-per-session — and it is why the ordinary AgentCore deployment is
standingAgent({ agent }) with one agent inside one container:
const handle = await standingAgent({
agent,
host: agentCoreRuntimeHost(), // 0.0.0.0:8080
sessions: agentCoreSessions({ store: 'session-storage' }),
});The global serialization that shape carries costs nothing here, because there is
only ever one session in the container to serialize. { agentFactory } — the
per-session agent pool — is for the other shape: one process serving many
sessions, which is what you have on your own infrastructure or behind your own
load balancer, not what AgentCore hands you. Using it inside an AgentCore
container is not wrong, just unnecessary: the pool would hold one entry.
What you bridge yourself
agentfootprint is runtime-only, so these are yours:
- Control plane — every
Create*(Runtime, Memory, Gateway, WorkloadIdentity, policy store) via SDK / CDK. - Code Interpreter, Browser — wrap as tools (
defineToolor an MCP server) for now.
The Runtime host used to be on this list. It is not any more —
agentCoreRuntimeHost() + agentCoreSessions() are the :8080 /ping +
/invocations contract, including the session header. See
AgentCore adapters.
Other providers
AWS is one column, not the pattern. Every port on this page is filled by
something else somewhere else, and the agent code does not change between them:
On-premises & self-hosted is the same map for a
deployment that owns its own machines — a local model instead of Bedrock,
sqliteVectorStore or pgVectorStore instead of S3 Vectors, sqliteSessions
instead of AgentCore sessions, fileObservability instead of CloudWatch,
vaultCredentials instead of AgentCore Identity.
Google Cloud & Gemini is the third column: gemini()
instead of bedrock(), geminiEmbedder() instead of bedrockEmbedder(), and
otelObservability() over standard OTLP instead of CloudWatch. It is a younger
column and says so — two adapters ship, and every other boundary names the port
that fills it plus the reason it is not an adapter yet.
Next steps
- AgentCore: step by step — mock-first, then one adapter at a time.
- On-premises & self-hosted — the same services, filled without a cloud account.
- Google Cloud & Gemini — the third column, filled the same way.
- AgentCore adapters — runtime, sessions, policy, gateway.
- AgentCore Memory — the memory-store deep dive.
- Infrastructure: ports & adapters — the pattern this page is one instance of.
- AWS Bedrock (the LLM provider) · Observability sinks · Tools & gateways.
