
Agents for Humans | Tutor - A Quiet AI Reading Coach
How I built an autonomous AI agent that runs an entire reading comprehension programme over email — generating worksheets, grading handwritten answers, and deciding what's next — with zero dashboard and zero manual triggers. Covers the architecture, the genuinely tricky bugs I traced to root cause (a silent token-generation hang, an AgentCore config field that never propagated, ARM container quirks), and what I'd do differently next time.
The idea
Helping a child practice reading shouldn't mean adding another app to check every day. Most tools for this ask a parent, or a busy tutor, to log in, pick an assignment, mark it, and remember to send the next one a daunting task competing for attention in an already full day.
I wanted to build something that just didn't need that. An agent that runs a reading comprehension program entirely over email: send a worksheet, read the student's photographed handwritten answers when they reply, grade them, and decide what happens next - all without a dashboard, a login, or a single manual step once a student is enrolled.
This became Tutor, my submission to the AWS "Agents for Humans" Hackathon, built with the Strands Agents SDK (TypeScript) and deployed on Amazon Bedrock AgentCore Runtime.
Architecture, in brief
Two deployed pieces work together:
TutorAgent- the Strands agent itself, a Container-build agent on AgentCore Runtime, with two flows off one system prompt: generate and send (new enrollment) and grade a submission (inbound reply).tutor-infra- a CDK stack providing the automatic triggers: a DynamoDB table with Streams for enrollment, an SES receipt rule and S3 bucket for inbound replies, and two Lambdas that invoke the agent.

Every arrow in that diagram apart from the initial DynamoDB write from an admin and the student sending out the email is a Strands tool the agent calls itself, nothing in the loop is scripted from outside.
I started with a local, no-AWS test harness before touching any infrastructure: plain scripts calling
generateWorksheet(), renderWorksheetPdf(), and assessSubmission() directly against Bedrock, with real photographed handwriting. That let me validate generation quality and grading accuracy in isolation — which turned out to matter a lot once I moved to deployment, because it meant every subsequent failure was clearly an infrastructure problem, not a logic one.The bug that looked like a hang: passing large content through a tool call
The first version of
render_worksheet_pdf returned the rendered PDF as base64, for send_assignment_email to consume next. Every real invocation would sit for two to three minutes with no error, then eventually succeed.The cause took a while to find, because nothing was actually broken — the code was correct. The problem was architectural: in an agentic tool-calling loop, the arguments for a tool call are generated by the model itself, as output tokens. When the result from
render_worksheet_pdf(a PDF-sized base64 string, tens of thousands of characters) became part of the conversation, the model had to reproduce that entire string as part of generating its next tool call: token by token. That's not a bug in Strands or Bedrock; it's just what happens when you hand the model something large and ask it to pass that same data along.The fix was straightforward once understood:
render_worksheet_pdf writes the PDF to disk and returns only the file path. send_assignment_email reads the bytes from disk itself, inside its own tool callback. The model only ever has to pass around a short string.1
2
3
4
5
6
// render_worksheet_pdf — returns a path, not content
callback: async ({ worksheet }) => {
const outputPath = `/tmp/worksheet-${Date.now()}.pdf`;
await renderWorksheetPdf(worksheet, outputPath);
return { pdfPath: outputPath };
},
The general rule I took away: anything a tool hands back that has to flow into a later tool call should be a reference, not a payload, if there's any chance it's large. It's a genuine performance trap that's easy to miss, because everything about the code looks correct in isolation.
Deploying to AgentCore: container quirks
Switching AgentCore's build type from
CodeZip to Container (needed because Puppeteer's bundled Chromium is too large for the zip size limit) surfaced a real platform quirk: Puppeteer's downloaded Chrome-for-Testing binary on Linux ARM is a shell wrapper script with bash-specific syntax, and AgentCore Runtime requires arm64. Under the container's default dash shell (Debian's default, not bash), the wrapper script fails to even parse, with an opaque Syntax error: word unexpected message that gives no hint about the real cause.The fix was to stop relying on Puppeteer's own download entirely:
1
2
3
ENV PUPPETEER_SKIP_DOWNLOAD=true
RUN apt-get install -y chromium
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
An apt-installed system Chromium is a real ELF binary, not a wrapper script, so it runs correctly regardless of the container's default shell.
The config field that silently did nothing
AgentCore's
agentcore.json supports an environmentVariables field on each runtime. I set it, redeployed, and querying the deployed runtime directly (get-agent-runtime --query "environmentVariables") kept returning null — no error anywhere, just silence.Root-causing this meant reading the actual code the
agentcore CLI generates, not just the documented schema. Tracing the deploy path — agentcore.json → the CLI's generated CDK stack → the @aws/agentcore-cdk L3 constructs — showed that the construct which correctly wires environment variables through (AgentCoreRuntime, which does read a top-level environmentVariables prop) is only ever instantiated for MCP server runtimes. The construct that builds an application agent's runtime (AgentCoreApplication) never references environmentVariables at all.Separately, the schema itself expects a different field for this:
envVars, an array of {name, value} pairs, not environmentVariables as an object map. The config validator accepts either silently, since Zod doesn't reject unknown extra keys — so the wrong field name produces no warning, it just quietly does nothing.1
2
3
"envVars": [
{ "name": "TUTOR_SENDER_EMAIL", "value": "tutor@yourdomain.com" }
]
Switching to the correct field name and shape fixed it immediately, confirmed both via the CLI query and in the console (Runtime → version → Advanced configurations → Environment variables).
State that survives across sessions
Since generation and grading happen as two entirely separate AgentCore invocations — potentially hours or days apart — the worksheet and its answer key have to live somewhere durable, not in any single session's memory. DynamoDB holds this:
studentId, email, yearLevel, passCount, completed, topicHistory, and currentWorksheet (the full generated worksheet including its answer key).The agent's own system prompt enforces the ordering that matters here: after generating a worksheet,
update_student_state is called before rendering or sending, so the answer key is safely persisted even if a later step fails.Closing the loop: the inbound email path
The harder half of the system is receiving a reply. SES's inbound receiving requires a domain you actually control — a Gmail-style address can never work, since you don't own its DNS. That meant setting up a real domain's MX record and a verified domain.
Once mail could actually arrive, a Lambda picks it up from S3 (SES stores the whole raw MIME email there, unparsed: it doesn't extract attachments itself), looks up the student by sender address via a DynamoDB GSI, and invokes the agent. Inside the agent,
get_submission_image parses the raw email with mailparser and extracts the photo to local disk, same file-path pattern as the PDF generation, for the same reason.What shipped
The full loop is genuinely automatic, end to end, tested with real email: writing one DynamoDB record is the only manual step. Everything after that from generating a worksheet, sending it, reading a photographed reply, grading real handwriting, deciding what's next, to eventually sending a congratulations email after five passes runs without anyone touching a keyboard again.
What I'd tell someone starting the same build
Refuse to guess. Every genuinely hard bug in this project was eventually solved with a real, verifiable signal: a raw CLI call to isolate CDK from AWS's actual behaviour, downloading and inspecting the actual deployed artifact, querying the deployed runtime directly instead of trusting what the config should have done. Theories about what's probably happening are cheap and often wrong; five minutes spent getting a direct answer usually beats an hour of plausible-sounding guesses.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article