Most AI agents sit in a chat box waiting for someone to type. Ambient agents on AgentCore flip that: a file lands in S3, a row changes, a schedule ticks, and the agent goes to work on its own, pausing only when it needs a human. AWS just published a full reference build of this pattern, and the interesting part is not the LLM. It is the boring, beautiful serverless plumbing that gets an event to the agent and the result back to a table.
The shape of ambient agents on AgentCore
The AWS Machine Learning blog post describes the flow as Event, Signal, Agent, optional human, Action. Mapped to services, the hot path looks like this:
- An S3
s3:ObjectCreated:*event notification fires. - A Signal Processor Lambda figures out which configured signals care about that object.
- For each match it writes a
signal_triggeredjob row to DynamoDB. - Jobs that should run get pushed onto an SQS job-execution queue (with a DLQ behind it).
- A Job Execution Lambda consumes the queue and calls Amazon Bedrock AgentCore Runtime.
- The containerized agent runs, and the worker writes the outcome and conversation back to DynamoDB.
A third Lambda, the Scheduler, runs on a one-minute cron and enqueues due scheduled jobs onto the same queue. So S3 signals, API-initiated jobs and cron jobs all converge on one execution path. That convergence is the whole design trick: one worker, one envelope, one place to add guardrails.
Signal matching: a GSI, not a scan
The Signal Processor does not scan a signals table on every upload (that would be a fun bill). Signals are stored with their configuration, and the platform mirrors configuration.bucketName up to a top-level bucketName attribute so it can be the partition key of a bucketName-signalId-index GSI. The Lambda queries that index with the bucket from the event, then applies prefix and suffix filters in code.
import boto3
from boto3.dynamodb.conditions import Key
signals = boto3.resource("dynamodb").Table(SIGNALS_TABLE)
def matching_signals(bucket, key):
resp = signals.query(
IndexName="bucketName-signalId-index",
KeyConditionExpression=Key("bucketName").eq(bucket),
)
for s in resp["Items"]:
cfg = s["configuration"]
if not s.get("enabled", True):
continue
if key.startswith(cfg.get("prefix", "")) and key.endswith(cfg.get("suffix", "")):
yield s
Why not just lean on S3 notification filters? Because those live on the bucket and you get a limited number of non-overlapping rules. Moving matching into a table means users can create, toggle and delete signals from a UI without anyone touching bucket configuration or redeploying a stack.
Each signal also carries an autoExecute flag. When it is false, the job row lands as idle and waits for a human to click Execute. When true, it goes straight to the queue. Same pipeline, different trust level.
Why SQS sits between the API and the agent
You could invoke AgentCore directly from the Signal Processor or from API Gateway. Don’t. Agent turns are slow, spiky and expensive, and the reference build puts SQS in the middle for three reasons:
- Latency decoupling. The API path of the Job Execution Lambda enqueues and returns a 202 immediately. API Gateway never waits on a model.
- Backpressure. A bulk upload of 5,000 invoices becomes 5,000 messages, drained at whatever rate the worker’s concurrency allows, instead of 5,000 simultaneous Bedrock calls.
- Failure isolation. Messages that fail repeatedly (
maxReceiveCount: 3in the sample) move to a dead-letter queue instead of vanishing.
The worker uses the standard Lambda SQS event source mapping, so the queue scales consumers for you.

The worker and invoke_agent_runtime
The Job Execution worker loads the job, rebuilds context from the conversation store, folds in any human answer as a continuation prompt, and calls the runtime. The core call from the sample is refreshingly short:
response = bedrock_agentcore.invoke_agent_runtime(
agentRuntimeArn=job["agentArn"],
runtimeSessionId=session_id,
payload=json.dumps(
{"prompt": prompt, "metadata": job.get("metadata", {})}
),
)
result = json.loads(response["response"].read())
The runtimeSessionId is what ties a job to a conversation. Reuse it on resume and the runtime keeps context; generate a fresh UUID and you get a new session. The InvokeAgentRuntime docs note payloads up to 100 MB and a streaming response, so if you switch your agent to server-sent events, read iter_lines() instead of read().
The agent itself is a containerized LangGraph app (default model Claude Sonnet 4.5, max_iterations: 10) whose tools are toggled in config.yaml. It returns a small envelope: status of completed, interrupted or error, plus session_id and job_id. The worker writes that status straight onto the job row.
The DynamoDB data model
Six tables, all pay-per-request with point-in-time recovery. The ones that matter for the pipeline:
- Agents:
agentIdPK, name,agentArn, status. The registry that maps a logical agent to a runtime ARN. - Jobs:
jobIdPK withagentId,sessionId,jobType(signal_triggered,user_initiated,scheduled),status,requiresAction, metadata. AjobType-nextRun-indexGSI lets the Scheduler find due jobs without scanning. - Conversations:
sessionIdPK, amessageslist and attl(30 days by default). - Signals:
signalIdPK, user, agent,signalType,enabled,autoExecute, configuration.
Chat threads and an idempotency table round out the six. The conversation write is the detail worth stealing: messages are appended with a single atomic UpdateItem using list_append, so concurrent writers never clobber each other with read-modify-write races.
aws dynamodb update-item \
--table-name "$CONVERSATIONS_TABLE" \
--key '{"sessionId": {"S": "job-7f3c...-session"}}' \
--update-expression "SET messages = list_append(if_not_exists(messages, :empty), :m), #t = :ttl" \
--expression-attribute-names '{"#t": "ttl"}' \
--expression-attribute-values '{
":m": {"L": [{"M": {"role": {"S": "assistant"}, "content": {"S": "Invoice categorized as Services."}}}]},
":empty": {"L": []},
":ttl": {"N": "1768003200"}
}'
One caveat: a DynamoDB item caps at 400 KB. Long-running sessions with big tool outputs will hit it, so store bulky artifacts in S3 and keep pointers in the message list.
Scaling knobs that matter
Everything here scales to zero, which is the point. When nothing is uploaded, you pay for storage and not much else. The knobs to actually turn:
- Reserved concurrency on the worker. This is your throttle on Bedrock. Set it below your model quota and the queue absorbs the rest.
- SQS batch size. Keep it at 1 for agent work. A batch of ten agent turns in one invocation is ten chances to blow the 15-minute Lambda limit.
- Lambda timeout. 15 minutes is the hard ceiling per agent turn. Human pauses do not count against it because an interrupted job simply ends the invocation.
- DynamoDB capacity mode. Stay on on-demand until traffic is predictable, then consider provisioned with auto scaling.
The full stack ships as CDK in the aws-samples/sample-ambient-agent repo: eight Lambdas, the tables, an ECR repository, API Gateway with Cognito, and a React UI on CloudFront. Read the multi_agent_stack.py before you deploy. It is the best map of the pipeline you will find.
When this is the wrong tool
If your workflow is deterministic (same steps every time, no judgment), use AWS Step Functions and skip the model bill. If you need sub-second chat, this polling-based design will feel sluggish. Ambient agents earn their keep on judgment calls triggered by events nobody is watching for in real time.
At Enkompass (enkompass.net), this is our home turf. We build event-driven serverless systems on S3, Lambda, SQS and DynamoDB that scale to zero and bill like it, and we wire AgentCore agents into those same pipelines. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.
