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.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/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).

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:
The loop:
  1. Start the source agent with auto-reload (caller token off for local dev):
  2. Attach your app. connectSidecar health- and version-checks the running server, spawns nothing, and returns a bound client — same API as production:
  3. Edit the Python under gaia_agent_email/, save — it reloads in ~a second — and re-run your call. That’s the whole loop.
Prefer one command? 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 connectSidecar succeeds) before Lemonade is up — /health is liveness-only. Live triage returns 502 until a local Lemonade Server is running with the model pulled (lemonade-server serve; gaia init to provision). You can still iterate on non-LLM code paths first.
  • You own the serve process — there’s no child on the returned handle and nothing to shutdown(); Ctrl+C the terminal running serve.
  • Auto-reload restarts the server, so conversational /v1/email/agent/* sessions reset on reload. The stateless triage/draft/send calls are unaffected (each request is self-contained).
  • Running serve without GAIA_EMAIL_SIDECAR_TOKEN disables the caller token (dev only, logged loudly). Set it — and pass authToken to connectSidecar — to exercise the authenticated path.

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 — preferably as a 0600 owner-only file whose path arrives in GAIA_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 the GAIA_EMAIL_SIDECAR_TOKEN env var (npm lifecycle and older binaries).
  • 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 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 to 127.0.0.1:4200 need 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 with gaia daemon stop-agent email or gaia daemon stop.
  • Direct-to-sidecar for local dev — if you launch the frozen binary yourself, set GAIA_EMAIL_SIDECAR_TOKEN (or point GAIA_EMAIL_SIDECAR_TOKEN_FILE at 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 set GAIA_EMAIL_SIDECAR_TOKEN_FILE whose 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’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 Outlook (personal or work/school), 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.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

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.

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.
Answer it out of band; the original stream resumes — do not issue a second /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):
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.5", "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).
  • Outlook — personal (Outlook.com) and work/school (Microsoft 365 / Entra ID) mailboxes, via the browser (PKCE) or device-code OAuth flow on the common tenant. Phishing quarantine is Gmail-only (an Outlook mailbox is refused with 400, because its label-based undo can’t reverse Outlook’s folder move).