AWS Builder Center
Host Claude Code on Bedrock AgentCore with the AgentCoreApplication CDK Construct

Host Claude Code on Bedrock AgentCore with the AgentCoreApplication CDK Construct

Package the Claude Code CLI as a container and deploy it as a Bedrock AgentCore Runtime with the AgentCoreApplication CDK construct — plus four gotchas.

Deploying Claude Code as a Bedrock AgentCore Runtime with the AgentCoreApplication CDK Construct

I've written before about  getting Claude Code off my laptop and onto managed infrastructure , and about  wiring  @agent  mentions on GitHub issues to a Bedrock AgentCore pipeline with no CI runner . This post is the piece underneath both of those: how the Claude Code CLI actually gets deployed as a first-class  Bedrock AgentCore Runtime , declared in infrastructure-as-code and provisioned by a CDK construct rather than a one-off  agentcore deploy  from a laptop.
If you've been running Claude Code as a  GitHub Action against Bedrock , this is the clean migration target: the same CLI, the same Bedrock model, minus the Actions-runner layer — running in a Firecracker microVM that AWS operates for you.

Why a Runtime container instead of  act  or Docker-in-Docker

The obvious way to "host the GitHub Action" is to emulate   anthropics/claude-code-action   with a tool like   act   — but  act  wants a Docker daemon, and  AgentCore Runtime executes each session in a Firecracker microVM  with no Docker daemon and no privileged mode. So there's nothing to run Docker-in-Docker inside.
The better answer is to skip the Action shell entirely and run the  Claude Code CLI  directly, headlessly, against  Amazon Bedrock  ( CLAUDE_CODE_USE_BEDROCK=1 ). This is the approach AWS documents in  It's safe to close your laptop now: Hosting coding agents on Amazon Bedrock AgentCore , and it maps cleanly onto the Runtime's  HTTP protocol contract .

The container is three small files

The Runtime contract is simple enough that the container is almost boring — which is the point. It needs a  Dockerfile , an HTTP server, and a  package.json .
 Dockerfile  —  node:22-bookworm-slim , built for  linux/arm64  (the  Runtime contract requires ARM64 ), installs  git , the GitHub CLI ( gh ), and the Claude Code CLI as a global npm package, then  CMD ["node", "server.js"] .
 server.js  — an Express server implementing exactly two endpoints from the  HTTP contract :
  •  GET /ping  — health. Returns  Healthy  when idle,  HealthyBusy  while a background job is running (more on why that matters below).
  •  POST /invocations  — the actual work. Given a prompt, it runs Claude Code headlessly:
Bash
claude -p "\<prompt\>" \
--model "$ANTHROPIC_MODEL" \
--output-format json \
--permission-mode acceptEdits \
--dangerously-skip-permissions
It parses the CLI's JSON result object and returns it. If a  repo  and a short-lived GitHub App token are in the payload, it first clones the repo and seeds  git / gh  credentials so Claude Code can open a PR when it's done.
 package.json  —  express  and nothing else. The CLI is baked into the image.
That's the whole runtime surface. Everything else in this post is about deploying it and the handful of production gotchas that aren't obvious from the happy path.

Gotcha #1: root +  --dangerously-skip-permissions 

The container runs as  root , and the Claude Code CLI refuses  --dangerously-skip-permissions  under root by default:
> cannot be used with root/sudo privileges for security reasons
...and exits 1, which surfaces as an HTTP 500 from the Runtime. The fix is to set  IS_SANDBOX=1  in the spawn environment. AgentCore Runtime already isolates each session in a Firecracker microVM, so declaring the sandbox is accurate — it's not a security bypass, it's telling the CLI the truth about where it's running. Don't drop the  --dangerously-skip-permissions  flag to work around it; that flag is required for non-interactive operation, not an optional security toggle.

Gotcha #2: the disk quota that ambushes  pnpm install 

The Runtime gives you a  session-storage mount  (I mount it at  /mnt/workspace ) that persists across stop/resume — great for reusing a git clone on a follow-up request. But it's small (I've observed ~1GB), and the CloudFormation resource spec for  AWS::BedrockAgentCore::Runtime  exposes only a  mountPath  on its session-storage config — no size or quota field — so you can't grow it from IaC.
The trap:  pnpm install 's virtual store ( node_modules/.pnpm  — the symlink farm that is the actual bulk of  node_modules ) defaults to living inside the project directory, i.e. right onto that 1GB mount. On any non-trivial repo it blows the quota with  ENOSPC  — even though the container's root filesystem has gigabytes free. The  content-addressable pnpm store  was never the problem; it lives under  $HOME  on the root fs. Only the virtual store needed to move:
js
// in the env for the spawned `claude` process
env.npm_config_virtual_store_dir = `${process.env.HOME}/.pnpm-virtual-store`;
Now  node_modules/.pnpm  lands on the roomy root fs and  /mnt/workspace  holds only the source clone. I also log  df -h /mnt/workspace  right after cloning, so a future regression shows up in CloudWatch as a number instead of as a cryptic  ENOSPC  deep inside a package manager.

Provisioning it with the  AgentCoreApplication  construct

Here's the part I actually want to show off. You can deploy a Runtime with the  agentcore  CLI, but in a real app you want the Runtime's ARN to flow into the rest of your stack — Lambda env vars, SSM parameters, IAM grants — without a post-deploy control-plane lookup. That's what the  AgentCoreApplication  L3 construct from  @aws/agentcore-cdk  gives you.
You declare the Runtime in  agentcore.json :
jsonc
{
"name": "ClaudeCode",
"build": "Container",
"codeLocation": "app/ClaudeCode",
"dockerfile": "Dockerfile",
"entrypoint": "server.js",
"protocol": "HTTP",
"networkMode": "PUBLIC",
"filesystemConfigurations": [
{ "sessionStorage": { "mountPath": "/mnt/workspace" } }
],
"envVars": [
{ "name": "ANTHROPIC_MODEL", "value": "us.anthropic.claude-sonnet-5" }
]
}
...and hand that array of runtime specs to the construct at CDK synth time. For each entry, the construct runs the whole build-and-register pipeline for you:
Plain text
agentcore.json runtimes[] ─▶ AgentCoreApplication (props.runtimes)
│ for each runtime:
│ CodeBuild builds the ARM64 image (codeLocation + Dockerfile)
│ └▶ pushes to ECR
│ └▶ creates AWS::BedrockAgentCore::Runtime
▼
app.environments.get("ClaudeCode").runtime.runtimeArn
Because all of that happens at synth time inside your stack, the Runtime's ARN is a same-stack  CDK token  — not something you look up after deploy. You read it with an accessor and thread it wherever it needs to go:
ts
const claudeArn = agentCoreApp.runtimeArn('ClaudeCode');

// into the invoke Lambda's env
invokeClaudeFn.addEnvironment('CLAUDE_CODE_RUNTIME_ARN', claudeArn);

// and/or into SSM for out-of-band tooling
new ssm.StringParameter(this, 'ClaudeRuntimeArn', {
parameterName: `/agentcore/${stackName}/claude_code_runtime_arn`,
stringValue: claudeArn,
});
Two things I'd flag from running this in anger:
  • Physical names are scoped per deployment. The construct names resources  ${projectName}_${name} ; if you make  projectName  unique per branch/sandbox, concurrent deployments don't collide (mine come out as  default_web_\<branch\>_ClaudeCode ).
  • Make the Runtime optional and tolerate an empty ARN. If  agentcore.json  has no  ClaudeCode  runtime on a given branch, the accessor resolves to  '' . Every consumer should tolerate that and fail at call time with a clear "runtime not deployed on this branch" — not fail synth. That keeps feature branches cheap.

Gotcha #3: IAM authorizes the endpoint, not the runtime

This one cost me a debugging session. To invoke the Runtime you call   bedrock-agentcore:InvokeAgentRuntime   — but that action authorizes against the runtime's endpoint sub-resource, not the bare runtime ARN. A policy that lists only:
Plain text
arn:aws:bedrock-agentcore:\<region\>:\<acct\>:runtime/\<id\>
...fails with  AccessDeniedException  — "no identity-based policy allows the bedrock-agentcore:InvokeAgentRuntime action" — naming the  .../runtime-endpoint/DEFAULT  resource it actually wanted. Grant both:
ts
principal.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock-agentcore:InvokeAgentRuntime'],
resources: [
claudeArn,
`${claudeArn}/runtime-endpoint/*`,
],
}));
Two smaller siblings of this same class of bug: the caller must send  contentType: 'application/json'  (the server's  express.json()  won't parse the body otherwise, and you 400 before your first log line), and if you're resuming a paused  Step Functions  task from inside the Runtime, it's the Runtime's execution role — not the state machine's — that needs  states:SendTaskSuccess / SendTaskFailure , because the Runtime is the thing calling back.

Gotcha #4: the background job dies unless  /ping  says  HealthyBusy 

Real coding jobs run long — mine routinely go over an hour. That's well past a synchronous HTTP reply, so the pattern is: accept the request, return  200 { started: true }  immediately, and run Claude Code detached in the background, calling back with the result when it's done (I use the  Step Functions callback pattern  with a task token, which can keep an execution paused for up to a year).
But once you've returned that  200 , there's no in-flight HTTP request — and AgentCore Runtime polls  GET /ping  to decide when a session is idle and can be  snapshotted, suspended, or reclaimed .  Healthy  means "safe to reclaim." So a long background job with a bare  Healthy  ping gets killed mid-run at the idle threshold (~13 minutes for me).
The fix is to track active jobs and report health accordingly:
js
let activeJobs = 0;

app.get('/ping', (_req, res) =\> {
res.json({ status: activeJobs \> 0 ? 'HealthyBusy' : 'Healthy' });
});

app.post('/invocations', async (req, res) =\> {
activeJobs++;
res.json({ started: true }); // ack immediately
runClaudeCodeJob(req.body) // detached
.finally(() =\> { activeJobs--; }); // only now is the session idle
});
Without the  HealthyBusy  state, the symptom is a flood of  Write failed: waiting to be backed up.  /  RMDIR failed  in the log group (the session-storage snapshot racing your live process), then silence — and no callback ever fires. This single line of health logic is genuinely load-bearing.

Verifying it

Once deployed, the cheapest smoke test bypasses everything upstream and hits the container directly with   InvokeAgentRuntime  :
Bash
aws bedrock-agentcore invoke-agent-runtime \
--agent-runtime-arn "$CLAUDE_CODE_RUNTIME_ARN" \
--content-type application/json \
--payload '{"prompt":"who and where are you?"}' \
/dev/stdout
The reply should describe Claude Code running inside an AgentCore Runtime, with its working directory somewhere under  /mnt/workspace . If you get that, the container, the image build, the ARM64 target, the IAM endpoint grant, and the model routing through Bedrock are all working — and you can wire it up to whatever invoke path you like.

Takeaways

  • The Runtime contract is tiny —  GET /ping  +  POST /invocations  on  0.0.0.0:8080 . Hosting a CLI agent is mostly a matter of wrapping it in a few dozen lines of Express.
  • Let a CDK construct own the Runtime.  AgentCoreApplication  from  @aws/agentcore-cdk  turns an  agentcore.json  entry into CodeBuild → ECR →  AWS::BedrockAgentCore::Runtime  at synth, so the ARN is a same-stack token that flows into the rest of your infra with no control-plane lookup.
  • The gotchas cluster in four places:  IS_SANDBOX=1  for root, the virtual-store env var for the small session mount, the  runtime-endpoint/*  IAM grant, and  HealthyBusy  to keep long jobs alive. None are in the happy-path docs; all four will bite.
If you want the layer above this — routing  @agent  mentions from GitHub straight into a Runtime like this one with no CI runner — I wrote that up separately in  @agent in Your GitHub Issues, No Runner Required , and the reliability lessons from running this harness in production are in  How I Used Claude Code to Make an Autonomous Coding Harness Reliable .

Related reading

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article