Skip to main content

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_url at 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_VERSION 2.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/triage returns HTTP 502.
The GAIA repository ships a demo harness used to prove this integration path end-to-end. That harness is a testing/proof tool, not part of the GAIA product — do not install it or depend on it. This guide teaches the integration pattern the harness demonstrates (self-OAuth → connection forwarding → triage/draft/send), which you implement against the real endpoints below.

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.

Pointing the sidecar at an existing (embedded) Lemonade

If your application already ships its own Lemonade Server, set LEMONADE_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.
Verify with GET /v1/email/initlemonade.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.
LEMONADE_BASE_URL is not restricted to loopback: pointing it at a non-local host sends email content (and the Lemonade API key, if configured) to that host. Only point it at a Lemonade instance you trust with mailbox contents.
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 binds 127.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 carry Authorization: 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 private GAIA_EMAIL_SIDECAR_TOKEN env channel.
  • Host / Origin allowlist. A non-loopback Host header → HTTP 400 (DNS-rebinding); a non-loopback browser OriginHTTP 403 (drive-by web page). No permissive CORS is ever sent.
How this maps to the entry points above:
  • Frozen sidecar / npmstartSidecar mints the token, injects it, and binds it to sidecar.client, so sidecar.client.triage(...) just works. A client you construct yourself must pass authToken (from sidecar.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 to 127.0.0.1:4200 need 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_TOKEN and 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’s refresh_token, and later refreshes as your client — there is no second consent screen.

1. Run your own OAuth

Obtain a long-lived refresh_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

For personal Outlook, POST /v1/connections/microsoft with the Graph scopes above. Response201 Created, metadata only (secrets are never echoed back):
Key rules the endpoint enforces (all fail loudly):
  • refresh_token and client_secret are write-only secret inputs. They are stored in the OS keyring and never returned by any endpoint (GET /v1/connections and GET /v1/connections/{provider} return metadata only).
  • grant_agents must include installed: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: 1 header 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 a missing_scopes detail. GAIA cannot widen scope at refresh time, so forward everything the agent needs up front.
To revoke: 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 at SCHEMA_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

Response (EmailTriageResponse):
Field notes:
  • category is one of the five buckets: URGENT, NEEDS_RESPONSE, FYI, PROMOTIONAL, PERSONAL. is_spam / is_phishing are independent boolean signals.
  • suggested_action is reply, archive, or none.
  • draft is a DraftScaffold (to + subject only, no body — schema 2.3) or null — 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 to POST /v1/email/draft.
  • usage is null on 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):
Drafting reads no mailbox and needs no connector — surface the draft to your user for approval, and only echo the token back on an approved send.

2. A send without the token is rejected (403)

The 403 body explains the gate: call /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)

The token is bound and single-use: a token minted for one message can’t be replayed to send different content, and it’s consumed on first use.
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:
Tools exposed: 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": "…"}. apiVersion is the frozen contract version; negotiate against its MAJOR so a breaking upgrade fails loudly.
  • GET /v1/email/init — readiness: the effective Lemonade base_url, the resolved model.id, and a 503 with an actionable hint when not ready. This is how you confirm a LEMONADE_BASE_URL override took effect.
  • POST /v1/email/init — provisioning: tells a running Lemonade to download the triage model, streaming newline-delimited text/plain progress (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 is GET /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.