2 Jun 2026 · 6 min read
The hard part of an agent isn't the model. It's the permissions.
- Google ADK
- Agents
- MCP
Evy started as a reminder bot for GDG Hanoi's organising team and turned into a lesson about tool design: deciding what an agent is allowed to touch, and how it asks before touching it.
GDG Hanoi's organising team runs on Google Chat and a very large spreadsheet. Tasks live in the sheet. Conversations about the tasks live in Chat. The gap between the two is where things fall: someone agrees to book the venue in a thread, nobody moves it to the sheet, and two weeks later everyone assumes someone else did it.

Evy was supposed to close that gap. It's an agent built on the Google Agent Development Kit that lives in our Chat space, reads the sheet, and can update it. Ask "what's still open for DevFest?" and it answers. Say "assign the badge printing to me, due Friday" and it does.
I expected the hard part to be getting the model to understand messy human requests. It wasn't. Gemini handled "put me on badges for fri" on the first try. The hard part was answering a question I hadn't thought to ask: what should this thing be allowed to do without asking?
Version 1: one tool, full access
My first design had one tool, sheet_update(range, values), with the sheet's full range. It worked in the demo. Then in week one, an organiser asked Evy to "clear my tasks for this week" meaning mark them done, and Evy interpreted "clear" as delete the rows. Nothing was lost — Sheets has version history — but the team's trust in the bot went to zero in one message, and trust is the entire product for a tool like this.
The fix wasn't a better prompt. Prompts leak. The fix was structural.
Version 2: split by blast radius
I rebuilt the tools around one question: if this call goes wrong, what's the worst case?
| Tool | Blast radius | Policy |
|---|---|---|
tasks_list(filter) | none | always allowed |
tasks_search(query) | none | always allowed |
docs_search(query) | none | always allowed (LightRAG over event docs) |
task_add(...) | one new row | allowed, echoed back |
task_update_status(id, status) | one cell | allowed, echoed back |
task_reassign(id, owner) | one cell + notification | allowed, echoed back |
task_delete(id) | one row, gone | requires confirmation |
email_send(to, subject, body) | leaves the building | requires confirmation + shows full draft |
Three things this does:
Read and write are different tools, not different arguments. The model can't accidentally delete through a read path, because the read path has no delete. Every MCP tool description tells the model exactly what the tool can and can't change, and the descriptions became the most-edited files in the repo — they're the agent's UI.
Small writes are self-describing. After any write, Evy replies with what it changed, in the sheet's own words: "Set Print badges → In progress (was: Open)". If that's wrong, the human sees it immediately. This costs one extra line per action and buys back most of the trust the delete incident spent.
Irreversible things require a second message. task_delete and email_send return a draft, not a result. The agent has to ask, the human has to say yes, and only then does the call actually run. In ADK this is a two-turn flow: the tool returns {status: "needs_confirmation", preview: ...}, the agent surfaces the preview, and the confirmation triggers the real call with a token that expires in five minutes.
The idempotency problem nobody warns you about
Chat bots get duplicate messages. Networks retry, users double-tap, Google Chat occasionally redelivers. With a read-only bot this is harmless. With a bot that can task_add, every duplicate is a duplicate task.
The fix is old and boring: every write carries an idempotency key derived from (message_id, tool, arguments), and the Postgres side has a unique index on it. A retried "add badge printing" hits the index and returns the original row instead of a second one. I put this in after the third duplicate task in production, which is two later than I should have.
Retrieval as a permission problem too
Evy also answers questions from our event docs — venue contacts, past run-of-shows, sponsor requirements — using LightRAG over a small corpus. The obvious permission question there is which docs. Some contain personal phone numbers; some contain sponsor pricing. The corpus Evy indexes is a curated folder, not "everything in the shared drive", and the ingestion script strips phone numbers and emails before chunking. The agent can tell you the venue's name and the person to ask; it cannot tell you their number. That's a person's job.
How I evaluated it before letting it near real data
I'd just finished the Databricks GenAI Engineer cert and the one habit that stuck was: write the eval set first. Before Evy touched the real sheet, I wrote 40 messages the team had actually sent in Chat (anonymised), tagged each with the tool call I'd expect, and ran the agent against a copy of the sheet. The eval caught:
- 6 messages where the model called a write tool when a read was intended ("can you clear up what's on my plate?" → not a delete)
- 3 where it invented a task ID instead of searching first
- the "clear my tasks" ambiguity, which is how I knew to add the confirmation gate before shipping
After the tool split, the first category went to zero, because the ambiguous phrasings now hit read tools by default and the model only reaches for writes when the verb is unambiguous.
Where it runs
Cloud Run, one container, scales to zero between events and to a few instances the week of one. Logs go to Cloud Logging with the tool name and the decision ("needs_confirmation", "executed", "rejected") as structured fields, so I can answer "what did Evy change last week" in one query — which is also how I check, after every event, whether the confirmation gates were doing anything or just adding a step.
What I'd tell someone building their first agent
Spend the first day on the tool table, not the prompt. For every tool, write down the blast radius and what happens on a duplicate call. If you can't answer both, the tool isn't ready. The model is the easy part; it will do what your tools let it do, and so will its mistakes.
Keep reading
What a builder club at HUST should look like
I've spent close to three years helping run rooms of 500+ developers and a year building agents with Claude Code. Here's the club I'd build on my own campus — one semester, four rituals, and a rule that nothing is finished until someone else can run it.
- Community
- Campus
- AI fluency
1,100 RSVPs and one spreadsheet: event logistics is a systems problem
What running check-in for Google I/O Extended Hanoi taught me about queues, backpressure and graceful degradation — the same words I use when building software, only louder and with more badges.
- Community
- Operations
- GDG
Why I gave Claude Code a memory (and what I got wrong about retrieval)
Every session started from zero. OpenMem is my attempt at a memory layer that survives the terminal closing — three layers, hybrid retrieval, and the ranking mistakes I had to make before it worked.
- MCP
- Go
- Retrieval