[CASE STUDY 08][IN DEVELOPMENT]

mailagent Human-in-the-Loop Scheduling Agent

An AI scheduling agent that watches a Gmail inbox, turns meeting requests into calendar proposals and books nothing until its owner approves them. On its 14-email golden set it scored 92.9% exact match and found all nine meetings with no false alarms, and in its current build all 77 emails of its injection suite passed, even with a stand-in model that obeys every attack.

Role
AI & Backend Engineer — end to end
Timeline
Aug 2026 – Present
Status
Pilot · dry-run mode
Stack
Python, FastAPI, LangGraph +6
mailagent graph: mail is fetched and scrubbed, classified, extracted and checked against free/busy, then the graph stops at an interrupt checkpointed in Postgres until I confirm, edit or cancel from a web app; an edit extracts again, a cancel writes no event, and a confirm reaches the calendar only through a guarded write, which in the dry-run pilot is recorded rather than sent

* THE GRAPH, AND WHERE IT STOPS

mailagent: The graph, and where it stops

[AT A GLANCE]

OWNED
End to end: the LangGraph agent, extraction and its evaluation, the approval web app, the write guard, the injection defences and the deployment
USERS
Single-user by design: the web app admits one verified Google account, and the agent reads one mailbox
STATUS
Pilot since 3 October 2026, in dry-run mode on an earlier build: it reads real mail, checks clashes against a test calendar and records each approved write instead of sending it. The injection defences below, from mail scrubbing to tool-free reading, are built and tested in the current build, not yet in the pilot
QUALITY BAR
2,400+ automated tests across the agent and the web app, lint and strict type checks clean, and a written spec behind every module built so far
NOT MINE
The models and platforms: Gemini for triage and extraction, LangGraph's Postgres checkpointer, Auth.js sign-in, and Supabase, Vercel and ngrok for the database, the web app and the tunnel

[STACK]

  • Python
  • FastAPI
  • LangGraph
  • Gemini
  • PostgreSQL
  • pgvector
  • Gmail & Calendar APIs
  • Next.js
  • Web Push

[01] The problem

Anyone can write to your inbox

Scheduling by email is manual work: read the request, work out the time and zone, check the calendar, then create the event. An agent can do it, but anyone can send an email, so it has to read attacker-controlled text without being steered by it, get times and zones exactly right across clock changes, and never touch the calendar without the owner's approval.

[02] The constraints

What an open inbox rules out

  1. Every email is untrusted input

    Whoever sends a message writes the text the model reads. No model that reads mail may hold a tool it could be talked into using, and nothing it returns may reach the calendar unchecked.

  2. A wrong time is worse than no meeting

    A meeting booked an hour out across a clock change, or in the wrong zone, costs more than a request left for a person. Times had to be exact, so the offset arithmetic could not be left to a model.

  3. Every call has a price

    The pilot runs on an all-in budget of $20–50 a month. By default the agent stops model spending at $40 a month and $0.50 a message, checked before each call, and every model call is costed against dated rates.

[03] The approach

How it was built

The evaluation harness came before the extractor: its first runs scored naive floors of 35.7% and 0%, so the baseline meant something. The agent is a LangGraph graph that stops at an interrupt, checkpointed in Postgres, until I confirm, edit or cancel it in a web app. Every calendar write the agent makes passes a guard that checks it against the exact action approved.

[KEY DECISIONS]

  1. [01]Built the evaluation harness, with naive floors, before the extractor.
  2. [02]Modelled the agent as a LangGraph graph that interrupts for approval and checkpoints in Postgres.
  3. [03]Bound every calendar write to the approved action with an HMAC over its exact arguments.
  4. [04]Gave no model that reads mail a tool, and enforced it with a structural test.
  5. [05]Costed every model call against dated rates, under hard monthly and per-message caps.

[04] The trade-offs

Where caution beat convenience

  1. [01]

    Build the evaluation harness before the extractor

    Instead of
    Run the extractor on real mail first and measure later
    What it cost
    The extractor started later, behind a harness that at first had only stubs to score.
    What it bought
    A baseline taken before any tuning: 92.9% exact match against naive floors of 35.7% and 0%.
  2. [02]

    The model returns local time and a zone name; code converts

    Instead of
    Ask the model for UTC or an offset
    What it cost
    A larger output schema, and a conversion step to maintain.
    What it bought
    Clock-change arithmetic moved into tested code: title, start, end and zone were right on all nine golden-set meetings.
  3. [03]

    No model that reads mail holds a tool

    Instead of
    A reviewer agent with tools double-checking the extractor
    What it cost
    The reviewer's possible accuracy gain was never measured; it was removed.
    What it bought
    A compromised model has nothing to call, and a structural test fails if any extraction or planning call is given a tool.
  4. [04]

    Extract from the message alone

    Instead of
    Retrieve related threads as context, which found a relevant message in the top five for all 29 test queries
    What it cost
    The extractor loses context from earlier threads.
    What it bought
    Text from other senders never sits beside an attacker's text in the prompt.

[05] The build

What mailagent is made of

  • [01]

    From inbox to proposal

    The agent syncs the inbox every two minutes and polls for new mail every ten. A small model classifies each message and a larger one extracts the title, local time, zone and guests; code converts the time and checks free/busy, and the graph stops with a proposal, checkpointed in Postgres. In an August test on three live emails, classification averaged 1.3 s.

  • [02]

    Approval in a web app

    A Next.js web app shows each proposal with Confirm, Edit and Cancel. The web app can only read the database: decisions go through its server to the agent's API, behind a bearer token, into a queue that one worker drains at once or within 15 seconds.

mailagent guarded write: a Confirm in the web app goes through its server, signed in with Google, to the agent's API behind a bearer token and into a queue one worker drains; a registry checks the nonce, a hash of the approved arguments, the dry-run mode and any outside guests, then writes once or, in dry run, records the write, and refuses and records anything else; below, untrusted mail reaches only a tool-less reader, a planner that never sees email text, built but not yet deployed, and an executor in code produces an answer that labels where each fact came from

Every write checked against what was approved

[03]

Every write checked against what was approved

Before any calendar write the agent makes, a registry verifies an HMAC-SHA256 over the exact arguments approved, from title and times to guests and location, and checks that neither the dry-run mode nor the proposal's version has changed and that any outside guest was allowed. Event IDs are deterministic, so a retried write cannot book twice; anything else is refused and recorded.

  • [04]

    Injection defences, tested against a compromised model

    The current build's suite holds 77 emails, from credential-bearing mail and text hidden from the reader to forged structure and injected guests, with attacks paraphrased and hidden across 13 techniques, plus 14 poisoned titles and locations. Driven by a deliberately compliant stand-in model, all 77 emails passed: no attack could act without my Confirm, injected guests were flagged and blocked until allowed, and genuine meetings still came through. One hiding technique, white text on white, is a documented gap.

  • [05]

    Built to fail safely

    An approval survives the process dying, and a crash forced after every worker step still applies the action exactly once. A re-poll never claims a message twice, the health check fails after three missed polls, every Gmail call is bounded at 35 s, and the sync uses at most a third of its per-minute API quota.

  • [06]

    Cost recorded on every call

    Every model call records its cost against dated rates, with embedding tokens estimated where the API reports none. A model with no listed price is recorded as unpriced, never as free, and new work waits until it has one. Triage costs about $0.08 per 100 emails.

[06] What broke

And what fixed it

  1. !01

    The pilot's first real email failed

    The free/busy check needs a scope the agent never requested, so the first real email in the pilot failed with a 403, and no meeting could become a proposal until consent was granted again. A test now checks every Google API method the agent calls against the scopes it requests.

  2. !02

    Every page failed after the first decision

    Idle serverless copies of the web app held on to their database connections until the pooler's limit of 15 was reached, so every page failed after the first real decision. The web app moved to the transaction pooler, which lends a connection per query.

  3. !03

    A blank secret opened the calendar route

    Found by running the server before it was deployed: a blank webhook secret loaded as an empty string rather than nothing, so the one public route that could write to a calendar accepted any request that sent an empty secret header. Blank secrets now count as unset, and the comparison runs in constant time.

  4. !04

    Placeholder prices understated cost 3.2×

    A review found the rate table still held placeholder prices, yet the README quoted its output as measured. Checked against the provider's pricing, triage cost had been understated 3.2 times, and cached tokens were billed twice; every rate now carries the date it takes effect.

[07] The result

What mailagent measured

  • 92.9%

    Exact-match extraction

    * 13 of 14 golden-set emails; floors 35.7% and 0%

  • 9 / 9

    Meetings detected

    * no false alarms on the 5 non-meetings

  • 77 / 77

    Injection cases passed

    * current build, compliant stand-in; last full run 4 Oct

  • $0.08

    Triage per 100 emails

    * scaled from live mail, at Sep 2026 rates

[HOW THESE WERE MEASURED]

Extraction and detection are the August 2026 baseline on 14 synthetic golden-set emails, nine of them meetings; the set has since grown to 18 and awaits re-scoring on the current pipeline. The injection results drive the full write path with a deliberately compliant stand-in model, last run in full on 4 October; the run against the production model is next. Triage cost is scaled from three live emails at rates checked on 30 September. The pilot has no usage figures yet: it runs in dry-run mode for one user.

[08] What I’d do differently

Honestly

I'd run one real meeting through the whole loop first. The pilot's first real email failed on a missing scope that no unit test was looking for.

Written by Faizan Amir

Role on mailagent: AI & Backend Engineer — end to end

Last reviewed

Need something like this built?

Next case study [09]

Document Research Agent

Read the case study →

AVAILABLE FOR SENIOR ENGINEERING WORK + REACT + NEXT.JS + NODE.JS + PYTHON + RAG + AGENTIC AI