All writing

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.

The GDG Hanoi organising team on stage at Google I/O Extended Hanoi 2026
The team Evy works for — twelve people, one spreadsheet, one Chat space

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?

ToolBlast radiusPolicy
tasks_list(filter)nonealways allowed
tasks_search(query)nonealways allowed
docs_search(query)nonealways allowed (LightRAG over event docs)
task_add(...)one new rowallowed, echoed back
task_update_status(id, status)one cellallowed, echoed back
task_reassign(id, owner)one cell + notificationallowed, echoed back
task_delete(id)one row, gonerequires confirmation
email_send(to, subject, body)leaves the buildingrequires 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