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.3) 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).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 over the privateGAIA_EMAIL_SIDECAR_TOKENenv channel. - 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 it spawns and authenticates for you, so your calls to127.0.0.1:4200need no bearer token; the token is internal to the backend→sidecar hop. - Direct-to-sidecar for local dev — if you launch the frozen binary yourself,
set
GAIA_EMAIL_SIDECAR_TOKENand send the matching bearer header. Running it without the env var disables the token check (local development only, logged loudly) — the Host/Origin controls still apply.
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.3) 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.
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.3", "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).
- Personal Outlook.com — supported via the device-code OAuth flow; phishing quarantine is Gmail-only (an Outlook mailbox is refused with 400, because its label-based undo can’t reverse Outlook’s folder move).
- Enterprise Microsoft Graph — deferred.
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.