Integrating the Email Triage Agent over the API
This guide is for developers building an application that owns the user-facing experience and the mailbox connection, and wants GAIA’s Email Triage agent as a local, on-device processing component — every email body analyzed on the user’s machine via Lemonade, never sent to a cloud LLM.Not building an integration? If you just want to triage your own Gmail/Outlook
from GAIA’s chat, the Agent UI, or
gaia email, read Email Triage
instead. That guide covers the flow where GAIA owns the UX and the connection.
This guide covers the flow where your app does.When to use this guide
Use this integration path when you want:
- Local inference — email bodies are classified and summarized on-device by
Lemonade. There is no configuration path that routes email content to a cloud LLM;
the agent rejects a non-local
base_urlat startup. - Your own UX and mailbox connection — your app runs OAuth, owns the tokens, and forwards a connection to GAIA so the agent can read/send on the user’s behalf.
- A structured contract — a frozen request/response schema (
SCHEMA_VERSION2.5) you can code against, over REST or MCP stdio.
Architecture
Three tiers, all on the user’s machine — no cloud inference, no separate mailbox for GAIA:- Your app runs its own OAuth, holds the user’s
refresh_token, and drives the agent over HTTP (or MCP). - The GAIA email surface persists the forwarded connection and processes each email locally. It never runs a second consent flow — it refreshes as your client.
- Lemonade Server is the one runtime dependency: the agent calls a local
Lemonade for inference. With none reachable — or the triage model unavailable on
it —
POST /v1/email/triagereturns HTTP 502.
Standing up the API surface
You need a running Lemonade Server (lemonade-server serve) and the model pulled
(gaia init installs Lemonade and downloads the default Gemma-4-E4B-it-GGUF). Then
pick one of the entry points below.
- GAIA backend (REST — recommended)
- Frozen sidecar / npm
- MCP stdio
The GAIA Agent UI backend is the single process that co-serves both the email
REST surface (
/v1/email/*) and the connection-forwarding router
(/v1/connections/*) — so your app can forward a connection and drive triage/send
against one host./v1/email/*— triage, draft, send, search, prescan, calendar, health, version./v1/connections/*— forward a pre-authenticated mailbox connection (below).
Pointing the sidecar at an existing (embedded) Lemonade
If your application already ships its own Lemonade Server, setLEMONADE_BASE_URL
before starting the email surface and every LLM call the agent makes (triage
classification and summarization) targets that endpoint — every entry point above
inherits it from the environment. The agent never spawns a Lemonade of its own and
never discovers a different one: if the endpoint is unreachable, or the triage model
isn’t available there, the call fails loudly with the URL and the fix in the error —
no fallback.
GET /v1/email/init — lemonade.base_url is the effective URL the
agent will call and model.id is the model it will use. When something is wrong,
the endpoint returns 503 with a hint naming exactly what to fix.
Migration note: triage no longer auto-downloads the model on its first call.
On a fresh server, provision once —
POST /v1/email/init (the GAIA backend
streams the sidecar’s provisioning progress through), or pull the model on
your Lemonade — before the first triage request; until then triage returns
502 with the fix in the error.
The target server’s catalog must list the model that GET /v1/email/init
reports as model.id (user.-prefixed registrations match tolerantly).Fast local iteration (dev mode)
The frozen sidecar ships for production — but it’s opaque, so when you hit a triage or draft bug there’s nothing to edit. To iterate on the agent, run its Python source instead and drive it with the same client. The frozen binary is that source frozen, so the contract is identical — only the base URL changes. Set up once — install the Python package editable so your edits take effect live:-
Start the source agent with auto-reload (caller token off for local dev):
-
Attach your app.
connectSidecarhealth- and version-checks the running server, spawns nothing, and returns a bound client — same API as production: -
Edit the Python under
gaia_agent_email/, save — it reloads in ~a second — and re-run your call. That’s the whole loop.
npx @amd-gaia/agent-email dev starts the source server for
you (--python <path> to pick a venv, --port <n> to rebind).
Going to production changes one line: swap connectSidecar for startSidecar
(which fetches + spawns the frozen binary). The calls are identical.
- The server starts (and
connectSidecarsucceeds) before Lemonade is up —/healthis liveness-only. Livetriagereturns 502 until a local Lemonade Server is running with the model pulled (lemonade-server serve;gaia initto provision). You can still iterate on non-LLM code paths first. - You own the
serveprocess — there’s nochildon the returned handle and nothing toshutdown(); Ctrl+C the terminal runningserve. - Auto-reload restarts the server, so conversational
/v1/email/agent/*sessions reset on reload. The statelesstriage/draft/sendcalls are unaffected (each request is self-contained). - Running
servewithoutGAIA_EMAIL_SIDECAR_TOKENdisables the caller token (dev only, logged loudly). Set it — and passauthTokentoconnectSidecar— to exercise the authenticated path.
Authentication
The frozen sidecar binds127.0.0.1 and can send mail as the user, so it
authenticates its caller (#1706). This is separate from the draft→send
confirmation_token (below), which binds a send to one exact message but does not
identify who is calling.
- Per-session bearer token. Every
/v1/email/*request to the sidecar must carryAuthorization: Bearer <token>or it is rejected with HTTP 401. The token is a cryptographically-random per-session secret the spawning parent hands to the sidecar — preferably as a0600owner-only file whose path arrives inGAIA_EMAIL_SIDECAR_TOKEN_FILE(the GAIA daemon does this for 0.6.0+ binaries, so the secret never sits in the process environment), or directly in theGAIA_EMAIL_SIDECAR_TOKENenv var (npm lifecycle and older binaries). - Host / Origin allowlist. A non-loopback
Hostheader → HTTP 400 (DNS-rebinding); a non-loopback browserOrigin→ HTTP 403 (drive-by web page). No permissive CORS is ever sent.
- Frozen sidecar / npm —
startSidecarmints the token, injects it, and binds it tosidecar.client, sosidecar.client.triage(...)just works. A client you construct yourself must passauthToken(fromsidecar.authToken); forward it to a browser/Electron renderer over your own IPC — never embed it in a page. - GAIA backend (REST) — the Agent UI backend proxies
/v1/email/*to a sidecar supervised by the GAIA daemon (gaia daemon), which spawns it and mints its token; the backend acquires and replays the token for you, so your calls to127.0.0.1:4200need no bearer token — it is internal to the backend→sidecar hop. Because the daemon owns the sidecar, it keeps running after the backend exits; stop it withgaia daemon stop-agent emailorgaia daemon stop. - Direct-to-sidecar for local dev — if you launch the frozen binary yourself,
set
GAIA_EMAIL_SIDECAR_TOKEN(or pointGAIA_EMAIL_SIDECAR_TOKEN_FILEat a file holding the token) and send the matching bearer header. Running it with neither variable disables the token check (local development only, logged loudly) — the Host/Origin controls still apply. A setGAIA_EMAIL_SIDECAR_TOKEN_FILEwhose file is missing or empty fails startup loudly rather than silently disabling auth.
Connection forwarding
(#1292) Your app runs its own OAuth, then forwards the resulting grant to GAIA. GAIA persists the forwarded OAuth client and the user’srefresh_token, and later
refreshes as your client — there is no second consent screen.
1. Run your own OAuth
Obtain a long-livedrefresh_token for the user with your OAuth client, requesting
offline access and the scopes the agent needs:
Add calendar scopes only if you use the calendar endpoints: Google
https://www.googleapis.com/auth/calendar.events (and/or calendar.readonly),
Microsoft https://graph.microsoft.com/Calendars.ReadWrite.
2. Forward the connection to GAIA
POST /v1/connections/microsoft with the Graph scopes above.
Response — 201 Created, metadata only (secrets are never echoed back):
refresh_tokenandclient_secretare write-only secret inputs. They are stored in the OS keyring and never returned by any endpoint (GET /v1/connectionsandGET /v1/connections/{provider}return metadata only).grant_agentsmust includeinstalled:email. Without this grant the connection exists but the email agent can’t resolve it at send time, and you’ll hit a “no grant” dead end. (Ties to #1592.)X-Gaia-UI: 1header is required on the POST and DELETE. It’s a CSRF guard — a custom header forces a CORS preflight, so a drive-by form POST can’t forge it. Missing header → 403.- Empty
client_id/refresh_token→ 422. Missing required scopes for the granted agent → 403 with amissing_scopesdetail. GAIA cannot widen scope at refresh time, so forward everything the agent needs up front.
DELETE /v1/connections/{provider} (with X-Gaia-UI: 1) clears the refresh
token, the forwarded client credentials, and every per-agent grant.
The triage contract
(#1262, frozen atSCHEMA_VERSION 2.5) POST /v1/email/triage takes an email or a full
thread and returns structured analysis. No mail is read or sent — it analyzes only the
payload in the request, so you can build and verify triage with zero connector setup.
See the full schema in the Email Triage Contract.
Triage always uses the local Lemonade LLM (there is no engine toggle; a
high-confidence spam/promotional heuristic may skip the classify call internally, but the
summary is always model-generated). With Lemonade unreachable — or the triage model
unavailable on it — triage returns HTTP 502 naming the URL and the fix.
Single email
EmailTriageResponse):
categoryis one of the five buckets:URGENT,NEEDS_RESPONSE,FYI,PROMOTIONAL,PERSONAL.is_spam/is_phishingare independent boolean signals.suggested_actionisreply,archive, ornone.draftis aDraftScaffold(to+subjectonly, nobody— schema 2.3) ornull— triage classifies and summarizes but never composes reply prose, and it never proposes a reply to spam/phishing or to yourself. To get a sendable reply, compose the body yourself and pass it toPOST /v1/email/draft.usageisnullon the heuristic-only path (no LLM call made).
Thread
Pass"kind": "thread" with a thread_id and a messages array (oldest-first). To bias
categorization, add an optional top-level context object
(people / projects / tone / self_email).
Batch
POST /v1/email/triage/batch takes an items array (1–100 email/thread inputs) and
returns one result per item, order-preserved. Per-item failures are isolated: HTTP 200
can carry errored items, so inspect each results[].error, not just the status. A
502 means Lemonade was unreachable or the triage model is unavailable there,
detected before any item was processed.
Mid-run questions (needs_input)
(#2469, contract 2.6) The agent can ask the user a question while a /query run
is in flight, and carry on from the answer. It uses this most often to set up or
repair mailbox access: rather than returning an error telling the user to go run
gaia connectors connect google …, it works out which of the four problems it has
(nothing connected, credentials stopped working, a missing scope, or connected but
not granted to this agent), asks about that one, and fixes it.
Declare whether you can answer. POST /v1/email/query takes
can_answer_questions (default false). Set it to true only if your UI can render
a question and POST the answer. Leave it false for a one-shot, a batch job, or
anything unattended — the agent then refuses the step immediately with something
actionable, instead of parking the run until the question times out.
/query:
404 means no run with that id is in flight; 409 means the run is not waiting on
that request_id (already answered, or timed out) — a stale answer is rejected
rather than applied to whatever is pending now.
Two rules worth respecting in your UI: render each option’s description (the
label alone does not tell the user what they are agreeing to), and when
sensitive is true mask the input and never log it — that flag is set when the
agent asks for an OAuth client secret. Lines beginning : on the stream are
heartbeat comments; skip them, but let them reset any read-idle timer you keep, or
you will abandon a run that is simply waiting on a human.
Draft → confirmed send
(#1264) A send is never performed without an explicit, payload-bound confirmation token. This is a safety invariant, not a preference.1. Mint a token
POST /v1/email/draft returns a single-use confirmation_token bound to the exact
(to, subject, body):
2. A send without the token is rejected (403)
/v1/email/draft for a token bound to this exact
(to, subject, body), then echo it in confirmation_token.
3. Send with the token (200)
Unlike triage/draft, send acts on the live mailbox, so a connection must be
forwarded first (with
grant_agents: ["installed:email"]). Status codes:
403 = missing/invalid token or a mailbox auth/scope error;
503 = no mailbox connected;
400 = two mailboxes connected and neither the token nor the request names a
provider. (The event-loop crash tracked in #1594 is fixed — the documented flow runs.)MCP stdio alternative
(#1104) MCP-native hosts get the same capability over stdio. Launch:triage_email, triage_email_batch, draft_reply, send_email. The
triage_email tool takes the same EmailTriageRequest and returns the identical
#1262 structured result as POST /v1/email/triage — the REST and MCP paths call the same
EmailTriageService, so output is byte-compatible. Parity is enforced by
tests/mcp/test_email_mcp_stdio_parity.
Self-describing reference
GET /v1/email/spec— a human-readable HTML page describing every endpoint.GET /v1/email/version—{"apiVersion": "2.5", "agentVersion": "…"}.apiVersionis the frozen contract version; negotiate against its MAJOR so a breaking upgrade fails loudly.GET /v1/email/init— readiness: the effective Lemonadebase_url, the resolvedmodel.id, and a 503 with an actionablehintwhen not ready. This is how you confirm aLEMONADE_BASE_URLoverride took effect.POST /v1/email/init— provisioning: tells a running Lemonade to download the triage model, streaming newline-delimitedtext/plainprogress (curl -N). Once the stream commits 200, the final✓/✗line is the authoritative outcome; a 503 means Lemonade itself is unreachable.GET /v1/email/health— liveness only ({"status":"ok"}). A green health check means the REST surface is up, not that Lemonade is reachable — the readiness signal isGET /v1/email/init.
Provider coverage
- Gmail — full support (triage, search, draft/send, archive, quarantine, calendar).
- Outlook — personal (Outlook.com) and work/school (Microsoft 365 / Entra
ID) mailboxes, via the browser (PKCE) or device-code OAuth flow on the
commontenant. Phishing quarantine is Gmail-only (an Outlook mailbox is refused with 400, because its label-based undo can’t reverse Outlook’s folder move).
Related
- Email Triage — the Agent UI /
gaia emailflow (GAIA owns the UX). - Email Triage Contract — the full #1262 request/response schema.
@amd-gaia/agent-email— the JS/TS client + frozen sidecar.