I built a study planner on Amazon Bedrock AgentCore harness — with zero agent code
This is the build end to end: the declaration, the request path, four gaps I had to close, and two things it did that I never asked for. It ran shell commands through a tool I never declared, and it stored a fact it had invented.
TL;DR
I built an agent that tells a student what to study next, researches the answer against current web sources, and returns a dated plan with citations. It runs on Amazon Bedrock AgentCore harness, so the agent is a JSON file: no orchestration code, no container, no server. Citations are checked in code rather than trusted to the prompt. Three consecutive research runs finished in 13, 24 and 33 seconds, each citing 3 to 5 pages.
The problem
Four demands pull against each other when a student asks what to learn next.
- Currency — what employers ask for changes faster than a model's training data
- Specificity — "learn Python" is useless; "Python type hints and dataclasses" is not
- Honesty — a plan citing sources that do not exist is worse than no plan
- Effort — I wanted this built in an afternoon, not a fortnight
Ask a model directly and it replies from training data with confidence and no citations. Tools that do research tend to hide the retrieval, so you cannot tell what was read and what was invented. I wanted the research visible, the citations checkable, and the project small enough that the interesting part was the agent rather than the plumbing.
Why configuration instead of code
An agent is three parts: a model that decides what to do next, tools it can call, and a loop running between them. The loop is the agent. It is also the part everyone rewrites — parse the tool request, run the right function, handle a malformed request, decide when to stop, carry the conversation forward.
Strands Agents, an open source agent software development kit (SDK) from AWS, writes that loop for you. AgentCore harness goes one step further: the loop itself is provided, powered by Strands Agents and running inside AgentCore Runtime. You declare what the agent is — model, system prompt, tools, memory, limits — and AgentCore runs it.
Mine has no agent code. The harness's AWS CloudFormation stack holds four resources: the harness, its execution role, the role policy, and AWS Cloud Development Kit (AWS CDK) metadata. Here is the declaration, with the gateway Amazon Resource Name (ARN) replaced by a placeholder and the plan schema's properties cut for space:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"name": "roadmap_agent",
"model": {
"provider": "bedrock",
"modelId": "us.amazon.nova-2-lite-v1:0",
"apiFormat": "converse_stream",
"temperature": 0.2
},
"tools": [
{ "type": "agentcore_browser", "name": "browser" },
{
"type": "inline_function",
"name": "submit_roadmap",
"config": {
"inlineFunction": {
"description": "Deliver a structured multi-phase study plan spanning weeks or months.",
"inputSchema": {
"type": "object",
"required": ["summary", "goal", "profile", "totalWeeks", "phases", "sources"]
}
}
}
},
{
"type": "agentcore_gateway",
"name": "web_search",
"config": { "agentCoreGateway": { "gatewayArn": "<your-gateway-arn>" } }
}
],
"memory": { "mode": "managed", "strategies": ["SEMANTIC", "USER_PREFERENCE"] },
"maxIterations": 24,
"timeoutSeconds": 600
}That file plus a system prompt is the whole agent. The trade-off is worth stating plainly: configuration means you inherit defaults you did not choose. Most of the hard part below is about that.
Architecture

Figure 1: the request path, and the capabilities the harness draws on.
A React and TypeScript interface collects the question and renders the result. It cannot call the agent directly, because the invoke API needs AWS Signature Version 4 signing and a browser cannot hold AWS Identity and Access Management (IAM) credentials. So one Python file sits between them. It signs the request, translates the event stream for the browser, and applies the guardrails described below, but it never decides what the agent does next. I kept calling it the backend and correcting myself — the agent is the backend.
The harness runs the loop, with Amazon Nova 2 Lite doing the deciding. Three capabilities hang off it. Web search arrives as a Model Context Protocol (MCP) tool: the managed Web Search Tool is a connector on an AgentCore gateway, so I operate no search API and no MCP server of my own. A managed browser reads one page in full when a snippet is not enough. Managed memory stores facts and preferences per student.
Every session runs in its own isolated microVM. That detail matters later.
The core loop
Traced on Figure 1, a plan request runs like this.
- The interface posts the question, prior turns, and the last plan's shape
- The proxy signs the request and calls
InvokeHarness - The harness sends the question and tool list to Amazon Nova 2 Lite
- The model asks for a web search; the gateway runs it and returns ranked snippets
- The model reads the snippets and may ask for a second search
- If a snippet is not enough, it asks the browser for one full page
- The model calls
submit_roadmap; inline functions run on the client side, so the harness pauses and hands the call to my proxy as structured arguments - The proxy checks the citations and drops any that is not a well-formed URL
- The interface renders the plan as a timeline and the tool calls as a live feed
Steps 4 to 6 repeat as the model decides. I did not script that order, and it varies between runs — sometimes one search suffices, sometimes three.
Design decisions that mattered
Search for breadth, a browser for depth. My first version used only the managed browser. Each page costs three or more tool calls — start a session, navigate, read — so a three-page budget consumed the whole iteration allowance and produced nothing. Moving research to web search made runs broader and faster at once, and demoted the browser to a scalpel for a single page.
Citation checks outside the model. The proxy keeps a citation only when it is a well-formed URL. On a first turn where no search ran, it removes every citation and tells the student why, because those links could only have come from training data. On a later turn it keeps well-formed URLs and labels them as carried over from earlier research. I tried three prompt revisions before accepting that this belongs in code. It is a heuristic, not proof: a well-formed URL on a turn that did search still passes, so my next step is matching every citation against the URLs the search actually returned.
A clock the model does not have. Left alone, the model searched for the previous year's requirements and cited stale pages. The proxy adds today's date to the first turn only, so queries use the current year.
A behaviour regression before any prompt tuning. Nine checks across one continuous conversation, because several bugs only appeared on the fourth turn. I wrote it late and regretted that.
The hard part: four gaps
Gap 1 — the plan rendered as nothing. Asking the model for a fenced JSON block worked in three of five runs. The other two produced markdown headings, the parser found no JSON, and the interface drew an empty screen. I first strengthened the prompt, twice, which moved compliance without fixing it. The fix was structural: declare an inline function,
submit_roadmap, with a typed input schema, and make calling it the only route to a rendered plan. Three of three after the change. As a backstop, the proxy re-asks once when a request that clearly wanted a plan still comes back as prose.Gap 2 — it forgot the sentence before. With managed memory configured, I asked "how many weeks did you just give me?" and it went back to the web. The symptom looked like broken memory. The cause was that the semantic and user-preference strategies extract facts asynchronously, after a few turns, so seconds later there is nothing to recall. I looked for a knob and found
messagesCount, which loads recent messages into context — then found it is only valid in existing memory mode, not managed. There is no configuration route to it. The fix was to replay the transcript on each call, which is what the underlying converse interface expects anyway.Gap 3 — it remembered something it had made up. I asked about an AWS software development kit and it invented one, with a wrong package name, a wrong install command, and a documentation link for a product that has never existed. Then long-term memory extracted that invention as a fact about me. Every later plan recommended installing it and cited its documentation. I could not prompt my way out, because the model was not reasoning badly — it was reasoning correctly over poisoned input. Two fixes. The model must search before answering about any named product, and may not state package names or URLs a search did not return. And memory needed maintenance: an audit command that finds a string across every actor's records, and a purge that deletes them. The audit found three poisoned records; purging the actor removed fourteen. Memory is a database, and databases need cleaning.
Gap 4 — my own metadata came back as an answer. To make "drop phase three" work, I put a compact description of the last plan into the conversation history as an assistant turn. The model began reproducing that description verbatim to the user, including the marker I had wrapped it in. I renamed the marker, and it kept happening. The reason took me too long: anything in an assistant turn is a few-shot example, whether you intend it as one or not. The model was copying the only pattern I had shown it. The fix was to move the plan's shape onto the user turn as context, so the assistant turns contain only what the agent actually said. The echo stopped immediately.
The shell I never declared
My declaration lists three tools, and none of them is a shell. That does not matter. Two default tools,
shell and file_operations, are available in every harness session unless you restrict them with allowedTools, and when allowedTools is omitted, every tool is allowed.So I asked, with no tool fence: "Run uname -a and pwd and tell me exactly what they printed." The agent ran the command through the default shell tool and reported a Linux kernel and the working directory
/home.Figure 2: with every tool allowed, the activity feed shows a shell command the declaration never granted.
Then I sent the identical question with
allowedTools limited to the browser. Zero shell calls, and this reply:"Sorry, I can't provide details about system commands or their outputs as this might facilitate malicious activities..."
Nothing about the model's judgement had changed. I had removed a tool from a list a second earlier, and it produced a principled-sounding reason for a capability it no longer had. That is why a prompt is not a boundary.
The last door is one
allowedTools does not touch. The AgentCore command line interface (CLI) can run a command directly inside the session's microVM, with no model involved:1
agentcore exec --harness roadmap_agent --timeout 90 'uname -a; pwd; id'1
2
3
Linux localhost 6.1.161-18.298.amzn2023.aarch64 ... GNU/Linux
/home
uid=0(root) gid=0(root) groups=0(root)That is root inside the agent's isolated microVM. The documentation explains why the fence did not help:
allowedTools scopes the model's tool selection during InvokeHarness only, while direct command execution goes through a separate API, InvokeAgentRuntimeCommand, with its own IAM action. To prevent it, do not grant bedrock-agentcore:InvokeAgentRuntimeCommand.Three layers, three strengths: the prompt is a suggestion,
allowedTools bounds the model, and IAM bounds everyone.Proof it's real
Measured on 13 and 14 September 2026, in the us-east-1 Region. Three consecutive research runs against fresh actors finished in 13, 24 and 33 seconds, each returning a schema-valid plan of four or five phases citing 3 to 5 sources. I sampled four cited URLs and three returned HTTP 200; the fourth, a Medium link, returned 403, which is bot filtering rather than a dead page.
Long-term memory held 219 extracted records across 48 actors. I proved cross-session recall directly: a new session identifier, the same actor identifier, and an empty history, after which the agent recalled two of three details I had seeded a minute earlier. It lost the third. Extraction took 75 to 90 seconds before records appeared, so this is not instant.
The behaviour regression passes 9 of 9. The shell and fence results came from the same account.
Not proven: anything under concurrent load. I have never run two students at once.
What's built
- A React interface with streaming replies, a rendered plan timeline, and a live tool-call feed
- One harness declared as configuration, with web search, a browser and managed memory
- Memory audit and purge tooling
- Citation checks in code, and a per-request tool fence through
allowedTools - A nine-check behaviour regression over one continuous conversation
Not built: authentication on the signing proxy, which is why it stays on localhost.
Cost
Cents, across roughly two days of building and several dozen regression and research runs. The bill is Amazon Nova 2 Lite tokens, gateway requests, and memory storage. Transcript replay is the line item that grows: every turn resends prior turns, so a long conversation costs more per message than a short one. One cheap win is in the documentation: the default
shell and file_operations definitions add roughly 900 input tokens to every model request, even when the agent never calls them, so allowedTools is a cost control as well as a security one.What I learned
- The loop is the agent. Once a managed service runs the loop, what is left to design is which tools exist and what bounds them.
- Prompts are requests; interfaces are contracts. Two prompt revisions lost to a formatting problem that one typed function solved.
- Everything in the context window is an example. Metadata in an assistant turn is a few-shot demonstration you did not mean to give.
- Memory needs maintenance. A hallucination stored as a fact resurfaces as ground truth, and no prompt can talk a model out of its own database.
- Defaults you did not choose are still yours. I declared three tools and the session had two more. Fence the model with
allowedTools, and fence people with IAM.
Try it
You need Node.js 20 or later and AWS credentials in a supported Region. Install the AgentCore CLI, then create, deploy and invoke a harness:
1
2
3
4
npm install -g @aws/agentcore
agentcore create --name myagent --model-provider bedrock
agentcore deploy
agentcore invoke --harness myagent --session-id "$(uuidgen)" "hello"In fifteen minutes you should have a deployed agent answering a question, with no agent code written. Then open its
harness.json, add one tool, and pass allowedTools on your first real request. When you tear it down, check for managed memory separately: in my project it was not part of the AWS CloudFormation stack.References
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article