-
-
Plainletter. Every date and amount is checked against the letter itself, and what does not check out never reaches the card.
-
Each fact the check found is marked on the letter and numbered, and the same number sits beside it in Dutch and Ukrainian.
-
15 September, two days left, and the letter explained in Dutch for the volunteer and in Ukrainian for the visitor.
-
The next steps, each with its official route, and the appeal letter drafted from the checked facts.
-
The desk card on paper: Dutch and Ukrainian side by side, in the black and white a library printer has.
-
A photographed letter read back: 5 facts checked, 7 not, and the two unreadable lines get no number in the margin.
-
Five stages are a model speaking. Everything else is plain Python, including the check that decides what reaches a person.
Inspiration
Every country sends its residents letters they can't read. The one in my video is a traffic fine: EUR 174 by 15 September, and 50 percent more if it's late. If you moved here last year, or you're eighty, or you read the language slowly, that envelope is a closed door with a clock running behind it.
Many countries answer this the same way: a free help desk, usually in a library, where somebody sits down with you and your letter. The Netherlands runs 861 of them. I live here, these letters come to me too, and I don't read Dutch. The first ten minutes at that desk decide everything: what is this, how bad is it, what happens if I ignore it, what do I do, and by when. Plainletter is the agent behind that desk.
What it does
Plainletter is an AI agent built with the Strands Agents SDK on Amazon Bedrock, deployed on Amazon Bedrock AgentCore Runtime.
A volunteer, or the person holding the letter, photographs or uploads it. Plainletter reads it, names the sender and the kind of letter, pulls out every amount, reference number and deadline, marks each one on the letter itself with a numeral in the margin, explains the letter in plain language in two languages side by side, works out how many days are left and the last day a posted reply still arrives, lays out the next steps with official routes, drafts the reply or objection when that is the right move, says clearly when a professional must take over, and prints a one page bilingual desk card with a calendar reminder for the deadline.
The one thing it's built never to do is tell the visitor something that isn't in their letter.
Why a translation app isn't enough
Point a translation app at an official letter and you get fluent Ukrainian for every line, including a line the camera misread. Nothing in that loop knows the amount it just translated differs from the amount on the page. On one of my test photos the model read EUR 149,38 where the letter says EUR 149,35, and nobody at a desk catches that by eye. Plainletter checks each date, amount and reference against the letter before anyone sees it, and what the check can't find gets no number, no mark and no line on the card.
Try it
- Live desk: https://plainletter-web.vercel.app/desk runs against the deployed agent. Pick one of the stored sample letters, or upload a synthetic one.
- No cloud account needed: clone the repo, then run
uv sync --group devanduv run plainletter demo --language uk --out out. That reads a synthetic traffic fine end to end with a scripted reading and writes the desk card and the calendar reminder. - Two real readings, nothing to install: https://github.com/voyagi/plainletter/tree/main/examples holds two letters read by the deployed agent on 14 September, each with the answer as it came back, the printed card (open it in a browser) and the calendar reminder. In the second, part of the reference number and the number plate can't be read, and the desk asks the visitor instead of filling them in.
- The video: https://youtu.be/sXkQvS_dTos. The photo goes in at 0:49, the marks and their numbers appear at 1:34, the steps and the drafted appeal at 1:50, the printed card at 2:05, the refusal at 2:19, and how the check holds at 2:58.
What is real
Every letter in the video and in the repository's samples is made up, so no real person, address or case appears anywhere. The two readings in the video are real: both ran on the deployed agent on 13 September 2026, and the second is a phone photo of a printed letter. The live desk rations readings (6 a minute and 25 a day per visitor, 50 a day for the whole service), so if it turns a reading away, the no-account demo above still runs.
It hasn't been tried at a real help desk yet, and that's the next step, with a library that runs one. What I can say is what it costs: one reading is six model calls and about 11 US cents in model charges, measured on a day that carried exactly one reading. More than a thousand Python tests and 40 web tests run and pass before every push.
How I built it
A Strands Agents pipeline on Amazon Bedrock (Claude Sonnet 4.6 through the EU inference profile), deployed on AgentCore Runtime in Frankfurt. The desk console is Next.js on Vercel and signs its calls to the runtime on the server, so the browser never holds a cloud credential.
It's an agent rather than one long prompt because the work branches. A photo needs a transcription turn before anything else, a sender the knowledge base doesn't carry goes straight to a person, and a draft is only written when the check passed.
Five stages are a model speaking. Everything between them is ordinary Python that never calls a model, including the verifier that decides whether a reading is allowed to reach a person: for every date, amount and reference it checks that the cited passage really stands in the letter and that the claimed value follows from it.
Three things hold the model to the letter. Every structured answer is a Strands tool call, and every stage after the verifier runs with an intervention on that boundary, so a date or amount the check did not ground is refused before it exists and the refusal goes back to the model as the tool's result. The planner and the drafter fetch official routes through a tool backed by a curated knowledge base where every route carries the official page it was read on and the date. And the pipeline re-reads everything that came through and refuses the whole reading if a stray value survived anyway.
A photographed letter is transcribed by one turn and read by a separate one, so a fact the extractor invents has to also appear in a transcription written without it. A born-digital PDF is read from its own text layer locally, before any model sees it.
Nothing is remembered unless the visitor asks. Even then, only checked and masked values are kept, for thirty days, in AgentCore Memory.
The crooked photo in the video
The second reading in the video (from 2:19) is a real phone photo of a printed letter: folded, in shadow, taken in a hurry, with the right edge running off the frame. Two lines are folded and blurry, so the desk types them as unreadable instead of guessing, and it cannot read what the cut-off edge says. Five facts check out. Seven do not, so they never become facts on the card, and there is no number in the margin next to those blurry lines.
That is the whole design. A reading that says it is not sure can be fixed at the desk. A confident wrong amount cannot.
Challenges I ran into
Getting a photo that was actually hard to read took three tries. The first two phone shots were still too clean to test anything, so the one in the video is the third.
An earlier test run produced a wrong translated date that passed unread, because the guard only knew month names in one alphabet. It now reads them in five.
Accomplishments that I'm proud of
The refusal is visible rather than asserted. When the check cannot ground something there is no numeral in the margin and no wash on the page, so the absence of a fact cannot be mistaken for a fact. That is a design choice a colour-coded warning cannot make.
What I learned
Grounding is a property of what you check against, not of how carefully you prompt. Half the work was making sure there was a trustworthy text to check against at all.
What's next for Plainletter
In code, a second country is one file next to locales/nl.py, a knowledge base for its senders, and samples to prove it. The reading, the check, the marks, the guard and the renderer don't move, and a test fails if a Dutch word leaks past that boundary. The real work is the research behind those files: every route has to be read on an official page, and someone who reads that country's letters has to check the result. Before any of that, the Dutch version needs its first trial at a real desk.
Disclosures
I used AI assistance for research, drafting, code navigation, and implementation support. I reviewed the final work myself and take responsibility for the submission.
Pre-existing code: the repository check scripts in scripts/ (check-ship-artifacts.mjs, check-client-secrets.mjs and their mutation tests) and the console's lint, bundle-size and dependency-rule configuration started from my own templates from earlier projects and were adapted here. Everything else was built during the submission period.
Built With
- agentcore-memory
- agentcore-runtime
- amazon-bedrock
- amazon-bedrock-agentcore
- amazon-cloudwatch
- aws-cdk
- claude-sonnet-4.6
- icalendar
- next.js
- opentelemetry
- pillow
- pydantic
- pypdfium2
- python
- react
- strands-agents-sdk
- strands-agents-tools
- tailwind-css
- typescript
- vercel
Log in or sign up for Devpost to join the conversation.