AWS Builder Center
Hands On With AgentCore Harness: Same Agent, Config vs. Container

Hands On With AgentCore Harness: Same Agent, Config vs. Container

Deploying an AgentCore Harness and a Strands Runtime agent side by side with CDK: six construct signatures that had drifted, an authorizer that couldn't be changed in place, an observability script that was silently inert, and what Gateway actually costs in latency and tokens.

AgentCore's managed harness lets you define an agent as configuration — model, system prompt, tools, memory, truncation policy — with no orchestration code. That claim is true. I deployed one.Valid config, it turns out, was never the hard part.
I built the same agent twice: once as a managed harness, once as a Strands agent in a container on AgentCore Runtime. Four CDK stacks, one live account, both tracks verified end to end. This is what the declarative path actually removes, what it costs, and the five separate validation layers between a clean cdk synth and an agent that answers a question correctly.

The short version

Gateway added measurable per-tool-call latency and, in one paired run, about five times the token count. In exchange, the Gateway-backed managed path moved tool deployment, credentials, and access control out of the agent process. The problems I expected configuration to remove — identity, tool contracts, observability, and verifying what the agent actually did — didn't disappear.
The rest of this is how I got there, including the parts I got wrong.

The setup

Track A, the managed harness. This is the entire agent:
1
2
3
4
5
6
7
8
9
10
11
{
"HarnessName": "platform_ops_harness",
"Model": { "BedrockModelConfig": { "ModelId": "...", "Temperature": 0.2 } },
"SystemPrompt": [{ "Text": "You are a data platform operations assistant..." }],
"Tools": [
{ "Type": "AGENTCORE_GATEWAY", "Config": { "AgentCoreGateway": { "GatewayArn": "..." } } },
{ "Type": "AGENTCORE_CODE_INTERPRETER" }
],
"Memory": { "AgentCoreMemoryConfiguration": { "Arn": "..." } },
"Truncation": { "Config": { "SlidingWindow": { "MessagesCount": 24 } } }
}
Track B, hand-wired: the same agent as a Strands app in a container on AgentCore Runtime. About 120 lines of Python plus a Dockerfile.
Both tracks use the same model, the same task, the same backing data, and the same AgentCore Memory resource. One difference is deliberate and matters for the results: Track A reaches its tools through Gateway over MCP; Track B loads equivalent @tool-decorated functions into the Strands process. Same three tool signatures, same return shapes, different transport.
That is not a laboratory-isolated comparison of orchestration loops, and I should be clear about it. It is a comparison of the managed path I actually tested, as a package: harness with application tools exposed through Gateway, versus equivalent tools loaded into the Strands process. A harness can also take inline functions, remote MCP servers, Browser, or Code Interpreter — Gateway was my choice for Track A, not a harness requirement.
The agent answers questions about batch pipeline runs: which failed, why, whether an SLA was breached. Three tools, one of which returns a structured error for unknown pipelines so a real tool call is distinguishable from a fluent guess.
Everything is CDK Python. Four stacks: Foundation (memory, execution role, telemetry), Gateway (MCP tool surface), Harness, Runtime.
Harness playground
AWS console showing the AgentCore Harness playground. An expanded agent trace lists two Gateway tool calls, Platform-Ops List Pipelines and Platform-Ops Get Run Detail, each with truncated JSON responses. Below the trace, a pipeline status summary reports ingest_customer_master as failed at the schema_validation stage with error class SchemaEvolutionError.
Track A running. The trace shows Platform-Ops List Pipelines, then Platform-Ops Get Run Detail on the failed pipeline it found — the answer below is grounded in those calls, not generated from the system prompt.
That screenshot matters more than it looks. An agent with a broken tool path still produces fluent, plausible answers. The only verification I trusted was reading the tool Lambda's CloudWatch logs and confirming list_pipelines then get_run_detail(pipeline_name="ingest_customer_master") with timestamps matching the invocation. I hit exactly that failure mode later, twice.

Five gates, each catching what the last couldn't

Gate 1: the constructs had moved

I wrote the CDK from documentation. Six calls were wrong against installed aws-cdk-lib 2.265.0:
What I wroteWhat exists
ManagedMemoryStrategy.user_preference()ManagedMemoryStrategy (MemoryStrategyType.USER_PREFERENCE, strategy_name=...)
event_expiry_duration=expiration_duration=
ToolSchema.from_inline([{dict}])typed ToolDefinition / SchemaDefinition
Gateway(protocol=, authorizer=)protocol_configuration=, authorizer_configuration=
add_lambda_target(name=)gateway_target_name=
LifecycleConfiguration(idle_session_timeout=)idle_runtime_session_timeout=
Plus one that isn't a rename: Memory's L2 doesn't register its CfnMemory as CDK's default child, so apply_removal_policy() raises CannotApplyRemovalPolicy. You have to reach the underlying construct.
The fix that generalizes: read site-packages/aws_cdk/aws_bedrockagentcore/, not the docs. That's ground truth for the version you actually have.
Worth contrasting — the Python SDKs hadn't drifted at all. bedrock-agentcore 1.22.0 and strands-agents 1.52.0 resolved cleanly and behaved as documented. Same service, same era, and the runtime SDKs held their surface while the CDK L2s moved. If you're building on AgentCore, budget your reconciliation time for the CDK layer.

Gate 2: the harness schema was mostly wrong

cdk synth runs local CloudFormation registry validation. It rejected seven property paths at once — and the CLI collapsed them into one warning marked [repeated 5×], which reads like one problem repeated rather than five distinct ones. I nearly fixed one thing and moved on.
What I wroteWhat the schema wants
Description (top level)not a valid property at all
ModelConfiguration.BedrockModelModel.BedrockModelConfig
ToolConfiguration.Tools[]flat Tools: [{Type, Name, Config}]
AgentCoreGateway.GatewayUrlGatewayArn — an ARN, not a URL
MemoryConfiguration.AgentCoreMemoryMemory.AgentCoreMemoryConfiguration.Arn
SlidingWindow.MaxTurnsSlidingWindow.MessagesCount
HarnessName: "platform-ops-harness"pattern ^[a-zA-Z][a-zA-Z0-9_]{0,39}$ — no hyphens
Also get_att("HarnessArn") is wrong. The attribute is Arn.
The useful discovery here: the CDK CLI bundles the CloudFormation registry locally, in db.json.gz inside the aws-cdk npm package. Extracting the full harness schema from it needs no AWS credentials — and when I later diffed that against the live registry in my account, it matched exactly. So cdk synth catches harness schema drift offline, before you spend a deploy cycle on it.

Gate 3: the control plane rejects what CloudFormation accepts

Template validated. CloudFormation accepted it. Then:
1
2
MCP configuration cannot be empty. Add supportedVersions, searchType or instructions.
(Service: BedrockAgentCoreControl, Status Code: 400)
GatewayProtocol.mcp() with no arguments synthesizes a structurally valid but semantically empty block. Template-schema validation and API-semantic validation are different layers, and nothing local catches the second.
One thing I did verify while fixing this: the CDK enum lags the spec. MCPProtocolVersion in aws-cdk-lib 2.265.0 exposes only MCP_2025_03_26 and MCP_2025_06_18, two published versions behind, while the mcp SDK that strands-agents pulls in may negotiate a newer one. A gateway advertising only what the enum offers can fail a client handshake for reasons that look nothing like a version mismatch.
Whether you can widen supportedVersions later, I can't tell you from experience — I didn't test an update. AWS documents UpdateGateway as able to change the list, and I've seen a bug report claiming the opposite in a specific case. I sidestepped the question by declaring generously at creation, through the L1 escape hatch:
1
2
3
4
5
cfn_gw = gateway.node.default_child
cfn_gw.add_property_override(
"ProtocolConfiguration.Mcp.SupportedVersions",
["2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"],
)

Gate 4: the auth model was architecturally wrong

Gateway deployed clean. Every invocation died with 401 Unauthorized on the MCP handshake.
I'd configured a Cognito JWT authorizer, which is a reasonable pattern for an external web client. But the harness is an AWS service principal. It authenticates with SigV4 using its execution role, and has no mechanism to run a Cognito client-credentials flow and present a JWT.
Not a misconfiguration — a category error. Browser-app auth in front of a service-to-service caller. IAM auth was correct here, and Cognito would be correct again for a genuinely external caller.
Then the part that cost me a teardown. On the gateway I'd created, changing AuthorizerType from JWT to IAM failed with "Authorizer type cannot be updated for an existing gateway." CloudFormation attempted the update in place anyway — the property isn't in createOnlyProperties, so CDK had no reason to force a replacement. I had to delete and recreate the gateway, and because the Harness stack imports its ARN, that stack had to go first and come back after.
The CloudFormation reference marks AuthorizerType as updatable with no interruption, and UpdateGateway accepts an authorizerType parameter. That's not what my account did in August 2026. I'm recording the discrepancy rather than claiming which side is authoritative — but if you're planning to change a gateway's authorizer in place, test it on a throwaway stack first.
The diagnosis is worth copying. InboundAuthorizationFailure = 4, Invocations = 4, exact 1:1, tool Lambda log group completely empty. And zero traces — because the failure happened before anything could emit a span. The observability stack I'd deployed specifically to debug this produced nothing at all. Metrics don't depend on span export, which is what made them the right instrument.

Gate 5: model entitlement is invisible to list APIs

Auth fixed. New error: AccessDeniedException: ... is not available for this account.
aws bedrock list-inference-profiles had shown the model. Listing tells you what exists, not what you're entitled to invoke.
Reproducing the same error from a different IAM principal with much broader permissions immediately ruled out the execution role's policy. Then:
1
aws bedrock get-foundation-model-availability --model-id <model> --region us-west-2
1
2
3
4
5
6
{
"agreementAvailability": { "status": "NOT_AVAILABLE" },
"authorizationStatus": "AUTHORIZED",
"entitlementAvailability": "AVAILABLE",
"regionAvailability": "AVAILABLE"
}
Three fields say available. One doesn't. agreementAvailability is a per-model agreement gate, separate from IAM, and it surfaces at invoke time as AccessDenied with nothing pointing at an agreement.
I briefly concluded that API was unreliable, because a model it reported as NOT_AVAILABLE invoked successfully a few minutes later. That was wrong — I'd enabled it in the console in between and forgotten. The API was accurate throughout.
Also worth knowing: the Bedrock console's Model access page has been retired. Access to foundation models is enabled by default now, though first-time Anthropic users may still need to submit use-case details. Any 2025-era tutorial referencing that page sends you somewhere that no longer exists.

The observability tooling was itself broken

Both tracks working, and still zero traces. Ten invocations at 100% sampling over ten minutes produced nothing, which is what made it conclusive rather than a sampling artifact.
1
2
aws xray get-trace-segment-destination
# "Destination": "XRay"
Transaction Search, which powers GenAI Observability, requires the CloudWatchLogs destination. My setup script granted the resource policy and set the indexing rule but never called update-trace-segment-destination. Without it, the other two steps were inert. Every conclusion I'd drawn from missing traces rested on a prerequisite that was never met.
Two more discoveries in fixing it:
Ordering is load-bearing. I put the destination switch first. It failed with AccessDeniedException, because X-Ray didn't yet have permission to write the spans — the resource policy has to exist first. The order is documented; what isn't obvious is that getting it wrong surfaces as an error that looks like a problem with your credentials.
The resource policy was incomplete. Transaction Search needs logs:PutLogEvents on two log groups, not one — aws/spans and the Application Signals data group. I'd granted only the first, so even correct ordering failed identically. <!-- VERIFY: match this exactly to the ARN in scripts/enable_transaction_search.sh -->
After the fix, six traces appeared within about 90 seconds.

What the traces showed

Everything below is from one paired invocation per track. Single runs, three tools, one workload shape. Directionally useful; not a benchmark.
Track A span tree
CloudWatch trace timeline for the AgentCore harness. An execute_tool span for platform-ops___get_run_detail has a child span, mcp tools/call platform-ops___get_run_detail, indented beneath it. Below, chat spans render as substantially wider bars than the tool spans.
Track A. The execute_tool span has an mcp tools/call child — the network hop to Gateway. Note the chat spans beneath it: inference bars dwarf the tool bars.
Track B span tree
CloudWatch trace timeline for the Strands container agent. An execute_tool span for get_run_detail appears as a leaf with no child span and a nearly invisible duration bar. Chat spans above and below render as much wider bars. Trace metrics show 26 spans and 3,325 tokens.
Track B. The same execute_tool span is a leaf — no mcp tools/call child, no hop. Note the trace totals against Track A's: 3,325 tokens and $0.005 versus 18,656 and $0.021.

Latency

Observed in one paired invocationTrack A (Gateway/MCP)Track B (in-process)
list_pipelines0.360s (child hop = 99%)0.001s (no child)
get_run_detail0.077s (child hop = 98%)0.001s (no child)
Total end-to-end7.381s5.063s
The 360× figure is real at the individual tool-call level, and it is not the end-to-end result. The measured MCP hops account for about 0.44s of the 2.3s difference. I can't attribute the remaining 1.9s from a single paired run — it could be inference variance, container scheduling, serialization, harness overhead, or some mix. From these spans, I can only say that the hops explain 0.44s and the rest is unaccounted for.

Tokens and cost

This is the larger effect, and I didn't anticipate it.
Observed in one paired invocationTrack ATrack B
Spans3426
Input tokens18,4693,107
Output tokens187218
Reported cost$0.021$0.005
Roughly six times the input tokens for the same question against the same model (Claude Haiku 4.5 via Bedrock; the dollar figures are what the GenAI Observability span metrics reported).
The trace suggests MCP discovery and Gateway-related context contribute to that difference — there's a mcp tools/list span in the Track A trace that has no Track B counterpart. But this experiment doesn't isolate which parts of the prompt account for the additional tokens. I did not inspect the model request payload, and I would not claim in-process tool schemas are sent to the model less often; Strands generates a JSON schema per tool that corresponds to what the model receives either way. What I can say is that the two paths produced very different input token counts for identical work, and that the gap is worth measuring on your own workload before assuming it's negligible.

Gateway overhead buys centralized governance

It would be easy to read those tables as "Track A is inefficient." I don't think that's the right conclusion.
Track B's tools are fast because they're local to the agent process. Change a tool and you rebuild the container. Add a second agent that needs the same tool and you implement it twice, or extract a library and version it yourself.
Track A's tools live behind Gateway, which is why they cost a hop and context tokens — and also why they're shared, independently deployable, and governed in one place. Gateway splits auth in two: inbound (who may call the tool surface) and outbound (what credential the tool uses downstream, held by Gateway rather than the agent). That separation is what makes a declarative agent definition safe to commit to Git. It's the reason Track A can be a JSON file at all.
The trade isn't efficiency versus inefficiency. It's governance versus locality.
If I were running several agents against the same tool surface, I'd accept that overhead without much thought — the alternative is duplicated tool implementations and outbound credentials scattered across agent definitions. For one agent whose tools ship with the agent and change with it, I'd have a harder time justifying Gateway purely for reuse I don't need yet. And if I were making dozens of tool calls per turn, or running at volume where a large input-token multiplier is a real line item, I'd want to measure the context cost before committing.

When I'd switch to Runtime

Reach for the container when you need control flow config can't express: multi-agent handoff you orchestrate yourself, a framework the harness doesn't host, deterministic tool sequencing, per-turn interception, custom retry or circuit-breaking inside the loop.
Otherwise the harness. It runs on Strands underneath and can be exported to Strands code, so moving from A to B is a change of control surface rather than a rewrite — agent/app.py in the repo is still a Strands agent, just one where I own the loop. That exit path is most of why I'd start with the harness.

What the harness removes, and what it doesn't

I expected declarative to remove work. It removed the orchestration loop, and not much else.
Both tracks needed the same execution role with the same Bedrock and Memory permissions. Both needed the same tool contracts — and those contracts are prompt engineering, not documentation. A Lambda has no introspectable interface, so the descriptions in TOOL_SCHEMAS are the entire basis on which the model chooses a tool. "Call this first for broad questions" measurably changes behaviour. Both needed the same observability setup, with the same account-level prerequisite that fails silently.
Configuration removed the orchestration code. It didn't remove the surrounding architecture decisions.
That's worth knowing before adopting a managed harness expecting a shortcut past the hard parts. It's a shortcut past the tedious part.

The recurring pattern: validation happens at different layers

Nine of ten failures produced no error at the layer where I could see them. Empty MCP config: valid template, rejected only by the control plane. Wrong authorizer: deployed successfully, failed at every invocation. AuthorizerType immutability: absent from createOnlyProperties. Model entitlement: listed fine, denied at invoke. Trace destination: zero spans, no error anywhere.
The debugging habit that follows is simple. list-* APIs tell you what exists; get-* and describe-* APIs tell you the current state. Almost every diagnosis above came from a get- call after a list- call had said everything was fine.

What I'd do differently

  • Extract the resource schema from the bundled CDK registry before writing any config
  • Read installed construct signatures instead of documentation
  • Decide inbound auth by asking what kind of principal calls this — service principal means IAM, external client means JWT
  • Over-declare create-time configuration when I'm unsure whether it can be widened later
  • Prove the observability pipeline works on a known-good invocation before trusting its silence as evidence
  • Verify tool execution from tool-side logs, never from the answer's plausibility
None of that is specific to AgentCore. It's specific to building on a service young enough that its schema and its behaviour haven't fully converged.
Accurate as of August 2026, against aws-cdk-lib 2.265.0, CDK CLI 2.1137.0, bedrock-agentcore 1.22.0, strands-agents 1.52.0. This surface moves fast — re-verify before relying on any of it.
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