InboxPilot
From email information to real-world action.
An AI agent that reads a real Gmail account, extracts tasks and deadlines with evidence quoted from the source email, decides whether an invitation fits your week, and — only after you approve it — creates a real Gmail draft and reads it back to prove it exists.
Inspiration
The project started with a student mailbox.
Universiti Malaya sends everything by email: assignment deadlines, lab reschedules, card collection notices, hackathon invitations. None of it is hard to read. All of it is hard to act on, because acting requires holding the whole week in your head at once — this deadline is Friday, that lecture clashes with this event, this notice can be ignored, that one cannot.
The AI email tools we looked at all end in the same place: a summary. They are excellent at comprehension. But the output is a second inbox you now have to read, one more surface to check. The original email was never the bottleneck; the deciding was.
So the question we set out to answer was narrower and, we thought, more useful:
What does an email agent look like if it is allowed to act, but not allowed to act alone?
That framing decides almost everything downstream. It means the agent has to show its work, because a human is about to approve it. It means every claim needs a citation, because "the model said so" is not something you can check at a glance. And it means the interesting engineering is not in the prompt — it is in the boundary between what the model is allowed to decide and what Python decides.
What it does
The loop is short and every stage is visible in the UI:
Connect -> Understand -> Decide -> Approve -> Act -> Verify
Understand. Nemotron returns a structured analysis: category, key facts,
action items, deadlines, and an explicit list of what it could not determine.
Every fact carries a verbatim quote from the email and a label — explicit if
the email says it, inference if it was derived, unknown if it was guessed at.
The backend checks that each explicit quote actually occurs in the body. Quotes
that do not are downgraded or dropped.
Decide. For an email that invites you somewhere, the agent answers the question you actually have: does this fit my week, and is it worth rearranging for? You describe your week once — lectures, shifts, standing meetings — and set a bar: how good does an event have to be before you skip something else for it? The verdict is one of six:
| Verdict | When |
|---|---|
| Go | no clash, and it is worth the hours |
| Go anyway | it clashes, but it clears your bar |
| Decline | it clashes, and it does not clear the bar |
| Skip | no clash, but it is not worth the hours |
| Ask for the date | the email never says when it is |
| Tell me your week first | no schedule on file — the agent refuses to guess |
That last row is the one we are most attached to. The honest answer to "should I attend this?" is sometimes "I cannot know yet", and an agent that never says so is not trustworthy about the cases where it does.
Approve, Act, Verify. The approval screen shows the draft, the recipient, the evidence behind it, and what the agent still does not know. Approving creates a Gmail draft — never a send. The draft is then read back from Gmail and compared field by field; the returned draft id is displayed only when subject, recipient and body all match. There is no send call anywhere in the codebase.
It reads before you ask. A background watcher polls the inbox, analyses what arrives, and puts a card in the corner of your screen — outside the browser, because that is where you actually are. It is deliberately powerless: it can read and it can notify. It cannot create a draft, change a label, or spend an approval. Only urgency 2 and 3 produce a card; promotions, social notifications, bulk senders and mail you sent yourself are filtered before they cost an inference call.
One week, one page. The Digest page answers the other question: what did the last seven days actually ask of me? One model call over the whole window, cached in SQLite, free to look at again.
How we built it
FastAPI backend, Streamlit frontend, SQLite for state, and every answer produced by a live call to Nebius Token Factory — nothing pre-generated, nothing from a fixture. If Nebius is unreachable the app reports an error instead of inventing output.
Three NVIDIA Nemotron models, chosen for three different jobs:
| Model | Role | Why |
|---|---|---|
nvidia/nemotron-3-super-120b-a12b |
read one email | highest measured JSON reliability at the latency we can afford |
nvidia/Nemotron-3-Ultra-550b-a55b |
decide and draft a reply | multi-step judgement |
nvidia/Nemotron-3.5-Super-VL |
read a photo of a timetable | a timetable is a grid; a VLM reads the layout OCR discards |
The architectural rule that shaped the codebase is the model extracts, Python decides. The model is good at reading "the event is on Tuesday 14:00–16:00" and at scoring "how much is this worth, 1–5, with reasons". It is bad at arithmetic it cannot verify and worse at policy. So the model produces structured fields and Python does the rest. The clash test is a half-open interval intersection over the week's busy blocks $B$ and the event window $E$:
$$ \text{clash} \iff \exists b \in B \text{ and } e \in E \text{ with } \max(b_s, e_s) < \min(b_e, e_e) $$
Half-open on purpose: a lecture ending at 11:00 does not clash with an event starting at 11:00. The verdict is then a lookup over four cells (clash × score ≥ threshold), plus two cases where the arithmetic has no input at all. That means the same email always produces the same verdict, every verdict can be shown next to the exact lecture it clashes with, and a hostile email can at worst inflate a score that is clamped to 5 and printed with its reasons.
The codebase that came out of this is 292 tests against a real mailbox rather than a mock.
Challenges we ran into
The hybrid reasoning model silently returned nothing. Nemotron 3 writes to
reasoning_content before it writes an answer. On a 1200-token budget the whole
budget went to thinking, so the call came back with a 200, content: null, and
finish_reason="length" — which our code then reported as "malformed JSON",
pointing at the prompt instead of the ceiling. We now send
chat_template_kwargs: {"enable_thinking": false} on extraction and leave
thinking on where the reasoning chain is the product. Measured on the same
email: 2793 ms → 1088 ms, 140 → 81 tokens. That is 2.6× faster for identical
extraction quality, and it was only available because we measured the default
instead of trusting it.
The smaller model was not faster, and it lied. The obvious assumption — smaller model, faster answer — did not survive three runs each on the same email:
| Model | Valid JSON | Latency | Output tokens | Fabricated quotes |
|---|---|---|---|---|
nemotron-3-super-120b-a12b |
3/3 | 4.9–5.7 s | 1118–1124 | 0 |
NVIDIA-Nemotron-3-Nano-30B-A3B |
3/3 | 4.9–7.4 s | 1229–1330 | 1 |
Nemotron-3_5-Lightning |
0/3 | — | — | never valid JSON |
The 30B model wrote longer output, which cancelled its size advantage, and in one of three runs it produced a quote that does not appear in the email. For an agent whose output a human is asked to approve, a fabricated citation is a correctness bug, not a quality trade-off.
Prompt instructions are a suggestion; a schema is a constraint. The hardest
bug in the whole project was the Digest quietly padding itself. Asked to pick out
what needed attention, the model wrote plausible advice — "Confirm presentation
readiness", "Modify InboxPilot's data access permissions" — none of which
appeared in any email. We tightened the prompt. It kept doing it. Models are
simply very good at sounding like a helpful assistant, and "do not invent tasks"
is a request, not a boundary. So the model stopped writing task text entirely:
Python builds a numbered list of tasks already extracted from this week's mail,
the model returns ids into that list, and anything that does not resolve is
dropped. Invention became structurally impossible rather than discouraged. The
same reasoning applies to quiet — computed from what survived validation,
never read from the model's own flag, or a week can be called quiet after three
invented tasks.
Gmail's restricted scopes mean strangers cannot log in. gmail.readonly and
gmail.compose are restricted scopes: letting external users authorise them
requires Google verification and a CASA security assessment, which takes weeks
and is not available to a hackathon project. So this is deliberately a
single-tenant demo on one mailbox, and the honest limitation is stated in the
README rather than hidden. We also hit a trap that costs a day if you find it
late: a consent screen left in Testing issues refresh tokens that expire
after seven days, so a demo that works today can be dead next week. Publishing
the app is the fix — it is a single console action, and it has to be followed by
re-authorising, because a token issued while in Testing keeps its seven-day
expiry even after you publish.
Approval had to be idempotent, not just gated. A gate that can be clicked
twice is not a gate. The idempotency row is written before the Gmail call with
status pending, and the unique key is held across the whole write, so two
concurrent retries cannot both reach Gmail. A leftover pending row means "we
tried and did not learn the outcome", which refuses the retry rather than
allowing it.
Accomplishments that we're proud of
- The gate holds on real data. Real Gmail in, real draft created, real draft
read back and verified — and no code path that sends.
no-replysenders return an empty proposal without even calling the model. - 2.6× faster, 42% fewer tokens, from one measured flag rather than a rewrite.
- A digest that cannot invent. The padding bug is the one we are least embarrassed to describe, because the fix was a design change, not a better prompt.
- The cache is honest.
GET /analyses/{id}reads SQLite and never triggers inference, so browsing during a demo cannot spend credit. Warm reads went from 5.29 s to 0.023 s.
What we learned
Defaults are not free. Thinking-on was the default and it cost 2.6× latency for no benefit on extraction. Every number in this project that we assumed turned out to be wrong.
Put the constraint in the data structure, not the prompt. Any rule that matters has to be enforced somewhere the model cannot talk its way around — a verified quote, a resolved id, a unique key, a half-open interval.
Refusing is a feature. "Tell me your week first" and "I could not determine the date" are the answers that make the confident ones worth believing.
Show the denominator. The digest prints how many messages were read in full and how many contributed only a subject line. "Three of these I actually read" is the difference between a summary and a guess, and the reader deserves to know which one they are looking at.
What's next
Push notifications instead of polling (which needs a public HTTPS endpoint we do
not have — IMAP IDLE would be instant but demands the full-mailbox
https://mail.google.com/ scope, an enormous privacy downgrade for a few seconds
of latency). Multi-tenant token storage, which is the real blocker for letting
other people use this. Attachment understanding. Calendar write, behind the same
approval gate. And a proper evaluation set, so "the quotes are verified" can
become "the quotes are verified, and here is the rate at which the model gets the
category wrong".
Built for the Nebius x NVIDIA Global AI Hackathon, Track 2: Best Apps and Agents.
Source: github.com/CaoLongyang/inboxpilot — MIT licensed.
Built With
- google-gmail-oauth
- python
- streamlit
Log in or sign up for Devpost to join the conversation.