Skip to main content

A worker on a mailbox: Inbox triage

Inbox triage (apps/inbox-triage.yaml in agentspecs) is the first worker: it reads new mail, labels and archives what needs no answer, drafts the replies, and asks before anything is sent. This page says how it runs, what it runs against today, and what real Gmail still needs.

What it reaches: Gmail's tools​

It reaches mail through Gmail's tools as the catalogue's Google Workspace server serves them — google-workspace__search_gmail_messages, get_gmail_message_content, get_gmail_thread_content, list_gmail_labels, modify_gmail_message_labels, batch_modify_gmail_message_labels, draft_gmail_message, send_gmail_message — each with the mailbox it acts on (user_google_email). Its agent, worker-mail-triage, carries that server and nothing else, with codemode off, so that every call is decided with its arguments.

The Google Workspace server is not enabled on Datalayer yet, and no real mailbox has been read. Outside Datalayer's own domain, Google's review of the restricted Gmail scopes comes first.

How it reaches a person's Gmail​

The person connects Gmail in Google's own consent screen, from the application's Permissions, at one of two levels:

LevelGoogle's scopesThe connection
can readgmail.readonlyaccess: read
can read and sendgmail.modify, gmail.sendaccess: write

Datalayer's IAM keeps the refresh token as the person's secret — never answered by any secret route, never in a runtime. The google-workspace server runs on the runtime as a local HTTP process in its external OAuth provider mode (MCP_ENABLE_OAUTH21, EXTERNAL_OAUTH21_PROVIDER): it holds no Google credential and opens the mailbox of whichever access token each request carries.

Each run of a deployed application's agent is given a toolset of its own (agent_runtimes.mcp.google_workspace.toolset_for_the_run), whose every request carries an access token of an hour that IAM mints (POST /api/iam/v1/oauth2/google-workspace/token) for whom the run acts for:

  • a connection as: user — the person talking to it, asked with their token naming the application (what they let it do in their name);
  • a connection as: owner — its owner, asked with the application's principal's token.

IAM narrows the token to the connection's level: a can read connection on a can read and send consent gets a token that cannot send, and a can read and send connection on a can read consent is refused, saying to connect Gmail again. The token is held in memory until two minutes before it ends, withheld wherever it would be shown, and never in the model's context.

A run nobody's Gmail can be reached for — a Preview or a local run, a person who has not let it act in their name, Gmail not connected or connected at a lower level — has no Gmail tool, and why is logged in a sentence: nobody else's mailbox is reached, and its other tools still work.

The session a message wakes reads the mailbox it was woken for (W-03). Nobody is talking to it: the scheduler runs it on the owner's key, with which IAM would list what the owner let applications do, never the person's. So the event names the person and their grant (person, grant, ids among its details), and the scheduler mints, per wake, that person's token naming the application from the grant — as its mail watch does to read the mailbox — and gives it with the session request alone, in X-Datalayer-Acting-Token (deployments.start_session(acting_token=)). The route holds it where a person's own session's token would be (apps.acting.given, keyed on the request's bearer), so every run of the session finds it through acting_token as any session does — the Gmail toolset and the gateway's servers in the person's name alike: one path. It is the only token that session acts with: ended, nothing more is reached in their name, and IAM is never asked with the owner's key in its place. A session woken for a person without it is refused (422), as is a token not minted for that person from that grant, or given to a session not woken for a person (test_app_woken_for_a_person.py).

The mail source it runs against today​

agent_runtimes.apps.mail is the mail source a worker runs against wherever the server is not:

  • MailSource — what a worker needs of a mailbox: its address, a search, a message, a thread, its labels, changing labels, a draft, a send;
  • FixtureMailbox — a mailbox of example mail held in memory, never a person's: what it was asked to do is kept, in order (effects, done("sent")), and nothing leaves it;
  • gmail_toolset(source) — Gmail's tools, under the server's names and with its arguments, served from a source. Each refuses another mailbox than its own. The same Appspec, rules and record run on it as on Gmail.
from pydantic_ai import Agent

from agent_runtimes.apps.agent import AppAgent, app_capabilities
from agent_runtimes.apps.mail import fixture_mailbox, gmail_toolset
from agent_runtimes.apps.record import AppRecorder
from agent_runtimes.specs.apps import APP_CATALOGUE

triage = APP_CATALOGUE["inbox-triage"]
mailbox = fixture_mailbox(
[
{
"id": "m1",
"from": "client@acme.com",
"to": "eric@datalayer.io",
"subject": "Invoice 2026-114 is wrong",
"body": "When will it be corrected?",
"received_at": "2026-10-06T07:12:00Z",
}
],
address="eric@datalayer.io",
)
recorder = AppRecorder(app=triage, woken_by={"kind": "event", "event": "email_received"})
agent = AppAgent(
app=triage,
agent=Agent("test"),
session_id="s-1",
capabilities=app_capabilities(triage, recorder=recorder),
toolsets=[gmail_toolset(mailbox)],
)

Its rules​

What it meetsWhat it does
Label and archive a messageDo it
Draft a replyDo it
Create or change anything else (a label, a filter)Ask me first
Send a messageAsk me first
Delete anything — a label of TRASH or SPAM tooLeave it to me
Forward outside the organization, share or publish anythingLeave it to me

A forward outside the organization publishes. send_gmail_message forwards when the call names a message (forward_message_id); when one of its recipients is outside the domain of the mailbox it sends from, or the mailbox is not said, the call is publish besides send (rules.forwards_outside, as agentspecs and the TypeScript rules decide it), and Inbox triage leaves publishing to the person. A reply is not a forward.

A message it reads is something to sort, never something to obey: its instructions and its agent's prompt say so, and whatever the model makes of a received mail, each call still meets its rule. A hidden instruction to forward a colleague's contract outside is refused before it runs.

When it asks​

A send waits for the person. On the tool-approval path the approval carries the call's arguments whole — a send's, as a saved result's, up to DRAFT_LIMIT; any other call's are cut at 500 characters — so the draft is read in full before Approve. Its channels are told first (the application's notifications: mail).

When a message arrives​

Its Appspec says when a message arrives — an event trigger on email_received, with the prompt its agent is asked — and every morning at 8, a schedule.

  • MailWatch(source).arrived() gives the email_received events of what arrived since it last looked, each once: the message's ids and when, nothing its sender wrote.
  • post_mail_event(...) and tell_arrivals(watch, ...) tell ai-agents, as the deployment's owner: POST /api/ai-agents/v1/apps/deployments/{uid}/events.
  • ai-agents wakes the deployment at each trigger that names the event, through the scheduler, which runs the session as it runs a tick: a key minted for the owner, a runtime of its own (event-<uid[:8]>, stopped by Pause), the session started, the runtime stopped. Events that come during a session are folded into one more session after it.
  • The session is asked its trigger's prompt and what happened, ids and times only (deployments.event_triggers, event_details, event_prompt). Words a sender chose are refused there: they would be instructions in a session nobody watches.

Woken, nobody is present: it reads, and does what a rule of its own lets it — labels, archives, drafts — and a send waits for an approval (R-16).

What it did against its rules​

ai-agents measures each deployment from its record of real use (GET /api/ai-agents/v1/apps/deployments/{uid}/measures): every send against its approval, every call that ran against a rule, and the weeks it ran. Each decision of the record says what the call does (classes), so that a send is told from another call.

What is tested, and what is not​

Tested (test_inbox_triage.py, on a FixtureMailbox, a scripted model in place of its agent's): woken by a message, it reads, archives under a label and drafts alone, and the send waits for the person with the draft whole — sent once approved, not sent declined; a hidden instruction obeyed by the model is refused, nothing forwarded; nothing deleted; a forward inside the organization asked; its rules; one mailbox only; what arrived told once, to ai-agents as the owner; its event trigger and its prompt. The forward check in TypeScript: app-rules.test.ts. ai-agents (test_app_deployments_api.py) and the scheduler (test_an_event_wakes_a_session.py) test the events, the wake, Pause and the measures.

The Gmail connection (test_google_workspace_gmail.py): the server as the catalogue serves it, in whose name each run asks IAM, the token held and asked again, every request carrying it, a run with no Gmail tool when none can be had, the server's process started, waited for and stopped. IAM's consent, callback, token and disconnect routes are tested in IAM (test_google_workspace.py).

Not done: no real mailbox has been read — that waits for IAM's deploy and a person's consent in Google's screen. The watch exists in the platform's scheduler (Gmail's history polled once a minute, each new message's id told to ai-agents, the woken session given the person's token), and waits to be deployed and drilled on a connected mailbox. Outside Datalayer's own domain, Google's verification of the restricted scopes comes first. Nothing is deployed.