Every country sends people letters they can't read. Many answer it the same way: a free help desk, usually in a library, where a volunteer sits down with you and the envelope. 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 my entry for the Good Neighbor track. It's the agent behind that desk.
What it does
A volunteer photographs or uploads the letter. Plainletter names the sender and the kind of letter, pulls out every amount, reference number and deadline, and marks each one on the letter with a numeral in the margin. It explains the letter side by side in Dutch and in the visitor's own language, counts the days left, lays out the next steps with official routes, drafts the reply when that's the right move, and prints a desk card with a calendar reminder.
One rule: nothing that isn't in the letter
Five stages are a model speaking. Everything between them is plain Python, including the verifier that decides whether a reading may reach a person. For every date, amount and reference, it checks that the cited passage really stands in the letter and that the value follows from it.
Strands Agents made the enforcement cheap to build. Every structured answer is a tool call, so I put an intervention on that boundary. A date the verifier didn't ground is refused before it exists, and the refusal goes back to the model as the tool's result, so it writes again. Official routes come from a knowledge base through a tool, and each entry carries the page it was read on. A photographed letter is transcribed by one turn and read by a separate one, which means an invented fact would also have to appear in a transcript written without it.
What running it for real taught me
The first live run on a photographed page did the thing I built it for, and I only believed it once I watched it happen. A line was clipped by the fold. The transcription turn wrote an unreadable marker instead of guessing. The extractor quietly filled the gap in anyway, and the verifier sided with the transcript, refused the fact, and handed the letter to a person.
Then I folded a health insurer letter, put it in shadow and photographed it in a hurry. Four facts were refused. One was EUR 149,38 where the page says EUR 149,35. Nobody at a desk would catch that by eye.
It isn't finished. On a letter where part of the reference number can't be read, the desk asks the visitor for it, but the payment step still quotes the number with its gap. A volunteer catches that at the counter today. Holding that step until the number is confirmed is next on my list.
The guard only knew Dutch month names at first, so a wrong date written in Ukrainian would have passed unread. It reads five languages now.
My first deployed run put all sixteen lines of a letter into the CloudWatch traces: name, street, reference, amounts. That was fixed and re-checked, and the prompts in the traces read REDACTED.
Money was the last surprise. One reading is six Bedrock calls and costs about USD 0.107. The daily ceiling I had first set, 200 readings, meant one bad day could cost more than my whole monthly budget alarm of 20 dollars. It's 50 now.
Where it runs
Strands Agents SDK on Amazon Bedrock with EU inference, AgentCore Runtime and Memory in Frankfurt, and a Next.js console on Vercel. Memory only happens with the visitor's consent, and then only checked, masked values for thirty days.
A second country would be one locale file, a knowledge base for its senders, and sample letters to prove it. A test fails if a Dutch word leaks outside that boundary.
Try the desk: https://plainletter-web.vercel.app/desk
Code, with two real readings you can open without installing anything: https://github.com/voyagi/plainletter
Demo video: https://youtu.be/sXkQvS_dTos
Top comments (2)
The bit about putting the intervention on the tool-call boundary instead of hoping the model self-checks is the part I'd steal. Refusing a date before it exists, then feeding the refusal back as the tool result so the model rewrites, is a much better loop than a post-hoc validation pass — and "an invented fact would also have to appear in a transcript written by a different turn" is the kind of argument that actually holds up under review.
The all-sixteen-lines-into-CloudWatch story is the trap most of us hit on the first deploy: the traces feel like your own telemetry, so nobody thinks of them as a data store with a retention policy. Two things I'd be curious about: is the verifier turn served by a different model family than the transcriber, and how often has the same letter produced two different readings across the five languages now that the guard is multilingual?
The "refuses to guess" rule is the whole design. For a letter that can cost someone money or status, a confident wrong answer is worse than no answer - marking what it could not verify instead of smoothing it over is what makes it desk-worthy. Extracting amounts and deadlines into margin numerals is a nice touch too: it turns prose into a checklist the volunteer can confirm against the paper in front of them.