Email Triage
v0.5.0 ExperimentalGAIA email triage agent — read, triage, organize, and reply to Gmail/Outlook locally
- gmail
- calendar
- triage
Eval score
Aggregate benchmark score for the latest published version — the methodology and every sub-metric are in the scorecard.
Install
npm client + frozen sidecar · 31.1 MBnpm i @amd-gaia/agent-email Ships as an npm client plus a frozen binary sidecar — npm is its only supported install path (there is no PyPI wheel). A local model must be running first: gaia init then lemonade-server serve.
About Email Triage
@amd-gaia/agent-email
Sorts your Gmail or Outlook inbox into urgent / needs-reply / FYI, pulls out action items, and drafts replies — all running locally on your machine, so no email content ever leaves it.
You embed it in a JavaScript or TypeScript app. Every email is analyzed on-device by a local AI model (via AMD's Lemonade runtime); message content is never sent to a cloud service, and that's enforced when the agent starts up.
Using an AI coding assistant? This package ships a SKILL.md — load it into Claude Code (or similar) for a copy-paste integration playbook.
What it can do
- Triage — sort each message into urgent, needs-reply, FYI, promotional, or personal; summarize a thread; and extract the action items and any phishing or spam signals.
- Organize — archive, label, and move messages, one at a time or in batches.
- Reply & send — draft context-aware replies (optionally in your own writing style, learned locally from your Sent mail) and send them — with attachments. Anything that leaves your mailbox asks for confirmation first.
- Calendar — spot meeting requests, flag conflicts, RSVP, and create events from an email.
- Track follow-ups — flag replies you're still waiting on past a window you choose (it points them out; it never nudges anyone for you).
- Daily briefing — generate a morning inbox summary on a schedule, no prompt needed.
- Plain-language requests — describe what you want done ("find today's urgent mail and archive the promotions") and the agent chains the steps itself, streaming progress; runs are cancellable mid-way.
Prerequisites
A local AI model has to be running before triage or drafting works:
- Install and start it with
gaia init(downloads the default model) andlemonade-server serve. - On a fresh machine the agent still starts, but triage won't return results until that local model is up. Call
client.init()to check readiness.
You'll need about 8 GB of RAM for the default model, and one of: Windows x64, Linux x64, or macOS Apple Silicon.
Install
npm install @amd-gaia/agent-email
Behind a corporate proxy? If install fails withUNABLE_TO_GET_ISSUER_CERT, reinstall withNODE_OPTIONS=--use-system-ca npm install(Node ≥ 22).
Quick start
Triage one email — get back a category and a summary:
import { fetchBinary, startSidecar, shutdown } from "@amd-gaia/agent-email";
// Once, at build time: download and verify the agent for your platform.
const { binaryPath } = await fetchBinary({ outDir: "resources" });
// At startup: launch the local agent and hold onto the handle.
const sidecar = await startSidecar({ binaryPath, port: 8131 });
const res = await sidecar.client.triage({
payload: {
kind: "single",
principal: { email: "me@example.com" },
message: {
message_id: "m1",
from: { name: "Sarah Chen", email: "sarah@example.com" },
subject: "Prod incident follow-up",
body: "Please review the report and reply by Friday.",
},
},
});
console.log(res.result.category, res.result.summary);
// e.g. "NEEDS_RESPONSE Sarah asks you to review the report and reply by Friday."
await shutdown(sidecar);
Triage classifies and drafts using only the local model — no mailbox connection needed. Reading or acting on a live inbox (search, send, archive, calendar) uses the Google or Microsoft connector you set up in GAIA under Settings → Connectors.
Want to try it without writing code? Run npx @amd-gaia/agent-email playground for a local page to test triage, drafting, and a live send.
How it works
Three pieces, all on your own machine — no cloud, no separate GAIA install:
- Your app launches the agent and owns its lifetime.
- The agent is a single self-contained program (~30–45 MB, no Python) that serves a small local API.
- The local model does the actual thinking; the agent talks to it over your machine's local network only.
Full architecture, the complete API, authentication, and every endpoint are in SPEC.md.
How good is the triage?
Scores 83.4 / 100 on a labeled benchmark inbox — see the Scorecard tab (or SCORECARD.md) for the full breakdown, and the Evaluation tab for how it's measured.
Reference
SPEC.md— full API, authentication, lifecycle, connectors, and platforms.SKILL.md— integration playbook for AI coding assistants.SCORECARD.md/EVALUATION.md— eval results and how they're measured.CHANGELOG.md— what's new in each version.
License
Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
SPDX-License-Identifier: MIT
@amd-gaia/agent-email — Technical reference
Detailed reference for @amd-gaia/agent-email. For a quick start, see README.md; for an AI-assisted integration walkthrough, see SKILL.md. The contract version is SCHEMA_VERSION 2.4.
Architecture
Three tiers, all on the user's machine:
- Your app (a Node process) depends on this package, fetches the sidecar binary, and spawns it via the
.entry. It does not attach to an already-running GAIA instance — the package launches and owns its own sidecar and tears it down onshutdown(). - The sidecar is a self-contained, PyInstaller-frozen
email-agentbinary serving the email REST endpoints. No Python is required on the host. - Lemonade Server is the one external runtime dependency: the sidecar calls a local Lemonade for the actual LLM inference. With none reachable,
POST /v1/email/triagereturns HTTP 502.
Once a sidecar is running, any Node process can drive it over local HTTP. The sidecar serves same-origin only and sends no CORS headers, so a browser or Electron renderer reaches it through the app's main process, not a direct cross-origin fetch — see Browser / Electron renderer.
Concurrency & deployment
Run one sidecar per host, spawned once at process start — not one per request. It accepts concurrent HTTP requests, but inference runs on a single local Lemonade model slot, so parallel triage calls serialize behind one another; cap inflight calls on your side rather than fanning out. The package does not supervise or restart a crashed sidecar — watch sidecar.child exit and re-startSidecar if you need resilience. It does auto-reap the sidecar when your process exits, crashes, or is interrupted (default autoCleanup); call shutdown for a graceful, awaited stop, or pass autoCleanup: false to manage signals yourself.
Authentication
The sidecar binds 127.0.0.1 and can send mail as the user, so it authenticates its caller (#1706) — distinct from the draft→send confirmation_token, which binds a send to one exact message but does not identify the caller.
- Per-session bearer token.
spawnSidecar/startSidecarmint a cryptographically-random token, pass it to the sidecar over the privateGAIA_EMAIL_SIDECAR_TOKENenv channel, and bind it tosidecar.client. Every/v1/email/*request must carryAuthorization: Bearer <token>→ otherwise 401. Construct-your-own clients passauthToken(fromsidecar.authToken);generateSessionToken()is exported for advanced flows. Exempt:/health,/version,/v1/email/health,/v1/email/version,/v1/email/spec,/v1/email/playground. - Host allowlist — non-loopback
Host→ 400 (DNS-rebinding). - Origin rejection — non-loopback browser
Origin→ 403 (drive-by page). Non-browser clients send noOriginand are unaffected. No CORS is ever sent.
Running the sidecar by hand without GAIA_EMAIL_SIDECAR_TOKEN disables the token check (local development only, logged loudly); the Host/Origin controls still apply. The shipped product always spawns with a token.
REST API
The code-derived, CI-guarded inventory of every capability surface (internal agent-loop tools, REST, MCP, eval coverage) is CAPABILITY_MATRIX.md — the canonical cross-surface reference.
Every /v1/email/* request also requires the per-session bearer token (see Authentication); the "Auth" column below covers the additional per-endpoint connector/token requirements. EmailClient is a typed wrapper over the sidecar's HTTP surface. Methods: triage, triageBatch, search, prescan, draft, send, confirmAction, archive, unarchive, quarantine, unquarantine, listCalendarEvents, previewCalendarEvent, createCalendarEvent, respondToCalendarEvent, health, version, emailHealth, emailVersion, spec, openapi. health/version hit the root routes (the standalone sidecar); emailHealth/emailVersion hit the /v1/email-scoped mirrors (for when the router is mounted on a product app). Every non-2xx response throws HttpError (carrying status, url, bodyText) — never a silent empty/null result.
| Endpoint | Client method | Auth | What it needs |
|---|---|---|---|
POST /v1/email/triage | triage() | Standalone | Local Lemonade LLM only. Categorizes / summarizes / extracts action items + spam/phishing signals on the message you send in. No mailbox is read. Extracted action items also persist to the sidecar's local task list (see "Action-item task persistence" below); the response shape is unchanged. |
POST /v1/email/triage/batch | triageBatch() | Standalone | Same as triage for an items array (1–100). Returns a parallel results array, order-preserved; per-item failures isolate (HTTP 200 can carry errored items — inspect results[].error). A 502 fails the whole batch (Lemonade unreachable). |
POST /v1/email/search | search() | Connector | Read-only inbox search. A connected Google/Microsoft mailbox (503 if none, 400 if 2+); no confirmation token. Lists messages matching query/labels and returns metadata only (no body). |
POST /v1/email/prescan | prescan() | Connector | Reads recent inbox messages from the connected Google/Microsoft mailbox and returns the read-only triage-card envelope (kind: "email_pre_scan"). 503 if no mailbox is connected, 400 if 2+ are. Heuristic-only — no Lemonade call. |
GET /v1/email/briefing | — (plain fetch; no wrapper yet) | Standalone | The latest scheduled daily briefing (#1608) — the same email_pre_scan envelope as prescan, generated by the sidecar's daily timer without a prompt, plus a generated_at stamp. Off by default: start the sidecar with GAIA_EMAIL_BRIEFING_ENABLED=true (fire time GAIA_EMAIL_BRIEFING_TIME, 24h local HH:MM, default 08:00; scan size GAIA_EMAIL_BRIEFING_MAX_MESSAGES, default 25) — e.g. via startSidecar({ env: {...} }). 404 until a scheduled run has happened. |
POST /v1/email/draft | draft() | Standalone | Nothing external — wraps your (to, subject, body, attachments) and returns a single-use confirmation token. |
POST /v1/email/send | send() | Connector | A valid draft confirmation token and a connected Google/Microsoft mailbox. The token gate fires first: no/invalid token → 403; then 503 if no mailbox is connected, 400 if 2+ are. |
POST /v1/email/confirm | confirmAction() | Standalone | Nothing external — mints a single-use token for "archive"/"quarantine", bound to that exact (action, message_id). |
POST /v1/email/archive | archive() | Connector | A valid confirm token (action="archive") and a connected mailbox. Gate fires first (no/invalid token → 403); returns a batch_id undo handle + post_archive_id. |
POST /v1/email/unarchive | unarchive() | Connector | A connected mailbox + the batch_id. Ungated (it restores). Window expired / unknown handle → 409. |
POST /v1/email/quarantine | quarantine() | Connector (Gmail) | A valid confirm token (action="quarantine") and a connected Gmail mailbox. Applies GAIA_PHISHING_QUARANTINE + archives; refuses is_phishing: false → 400; refuses an Outlook mailbox → 400 (label-undo can't reverse a folder move, #1738). |
POST /v1/email/unquarantine | unquarantine() | Connector | A connected mailbox + the action_id. Ungated (it restores prior labels). Window expired / unknown → 409. |
GET /v1/email/calendar/events | listCalendarEvents() | Connector | A connected mailbox whose calendar scope was granted. Read-only view of the primary calendar; 403 (reconnect CTA) if the scope is missing. Optional time_min/time_max; provider only when 2+ accounts. |
POST /v1/email/calendar/events/preview | previewCalendarEvent() | Standalone | Nothing external — mints a single-use confirmation token bound to the event (calendar analogue of draft). |
POST /v1/email/calendar/events | createCalendarEvent() | Connector | A valid preview token and a connected calendar. Token gate fires first: no/invalid token → 403; then the calendar-scope / account checks. |
POST /v1/email/calendar/events/respond | respondToCalendarEvent() | Connector | A connected calendar. RSVPs accepted/declined/tentative to an existing invite. |
POST /v1/email/query | — (SSE; no wrapper yet) | Connector | Canonical agent-loop query (schema 2.4, #2016). NL request in, seven canonical SSE event types out (status/token/tool_call/tool_result/needs_confirmation/final/error), terminated by one final/error. Host mints run_id; context is pushed. See "Canonical agent-loop query" below. |
POST /v1/email/query/{run_id}/cancel | — (no wrapper yet) | Standalone | Cancel an in-flight /query run — stops tool execution between steps. 404 if no run with that id is in flight. |
GET /v1/email/init | init() | Standalone | Readiness preflight (#1795): probes the whole triage stack — Lemonade reachable and version-compatible and the triage model downloaded. Returns 200 when ready, 503 when not, with an actionable hint either way (same InitResponse envelope). Read-only — no model pull. Unlike /health (liveness only), this verifies "ready to triage," not just "process up." |
POST /v1/email/init | — (streaming; no wrapper yet) | Standalone | Provisioning (#1795): tells the running local Lemonade to download the configured triage model, streaming text/plain progress line-by-line. Lemonade unreachable → real 503 (pulls nothing); once a pull starts the 200 is committed, so the trailing ✓/✗ line carries the true outcome. Not in the OpenAPI JSON — a streaming operational verb (like GET /spec), so include_in_schema=False. |
GET /health | health() | Standalone | Liveness only — does not check Lemonade/model. |
GET /version | version() | Standalone | Version negotiation. |
GET /v1/email/health | emailHealth() | Standalone | Router-scoped liveness (mounted-on-app case). |
GET /v1/email/version | emailVersion() | Standalone | Router-scoped version. |
GET /v1/email/spec | spec() | Standalone | Human-readable HTML endpoint page. |
GET /openapi.json | openapi() | Standalone | Machine-readable OpenAPI document. |
GET /docs (Swagger UI) and GET /redoc are also served but are browser UIs, not wrapped by the client. The standalone surface is triage, draft, confirmAction, and previewCalendarEvent (plus the probes) — integrate and verify those flows with zero connector setup. The read-only search and prescan read the live inbox (a connected mailbox, but no token); the mutating calls (send, archive, quarantine, createCalendarEvent) and the reversals/calendar views need a connected mailbox whose relevant scope was granted.
Canonical agent-loop query (POST /v1/email/query, schema 2.4)
The v2 keystone (#2016): a natural-language request in, the agent reasons and chains its tools into a multi-step workflow, and the seven canonical Server-Sent Event types out (the frozen /query wire contract). Every v2 front-door (the Agent UI relay, the gaia email CLI, gaia api) relays to this one loop. Request body:
{
"query": "Triage my inbox and draft replies to anything urgent.",
"run_id": "0f9c2b6e-2c4a-4b1e-9d6a-1e2f3a4b5c6d", // host-minted UUIDv4
"context": [ { "role": "user", "content": "earlier turn" } ], // pushed slice
"model": "Gemma-4-E4B-it-GGUF", // optional
"provider": "lemonade", // optional; only 'lemonade' (local-only agent)
"max_steps": 20 // optional
}
The host mints run_id, so the run is cancellable from the instant the request is sent (POST /v1/email/query/{run_id}/cancel, which stops tool execution between steps). Context is pushed in the body — the sidecar stays stateless. The response is text/event-stream; each data: line is one canonical event discriminated on type:
type | Payload | Meaning |
|---|---|---|
status | { message } | progress narration (also folds step/thinking/plan) |
token | { delta } | an incremental chunk of assistant text |
tool_call | { tool, args } | the agent is invoking a tool |
tool_result | { tool, render?, data } | a tool returned; render names a typed card |
needs_confirmation | { run_id, action, summary } | a gated step is awaiting approval |
final | { answer, usage? } | terminal — the assistant's answer |
error | { detail, status } | terminal — an actionable failure, surfaced verbatim |
The stream ends with exactly one final or error.
Confirmation (stateless stub, epic decision D1): a step that needs approval (a destructive/external tool such as send_now) emits needs_confirmation and then the run ends with a final refusal pointing at the deterministic fixed-function route — mint a token via draft()/POST /v1/email/draft, then send()/POST /v1/email/send. Server-side resume is not wired yet, so confirm_url is omitted. Not wrapped by the typed npm client yet — call it directly (e.g. fetch with Accept: text/event-stream).
Stateful agent surface (/v1/email/agent/*, 0.4.0)
Everything above is stateless — each call analyzes the payload you send, with no memory and no agent loop. The sidecar also hosts a session-scoped, conversational agent so a host can drive the full EmailTriageAgent (memory, personalization, and every agent tool) over HTTP instead of importing it in-process. This is the surface the Agent UI uses to back its email experience with the packaged sidecar. It is not wrapped by the typed npm client yet — call it directly (e.g. fetch) or via the Agent UI. Distinct from /v1/email/query above: /agent/* is session-scoped (server-held memory + history), while /query is stateless with a host-minted run_id and pushed context and emits the canonical seven-event vocabulary.
| Endpoint | Notes |
|---|---|
POST /v1/email/agent/session | Create/reset a session ({ session_id, reset? }) → { created, memory }. Builds the agent (surfaces failures early). |
POST /v1/email/agent/query | Run one turn; SSE stream (text/event-stream) of the loop — thinking/step/tool/permission_request/error/terminal run_complete. Body { session_id, message, memory_enabled? }. Every agent tool is reachable via natural language. Overlapping turn → 409. |
POST /v1/email/agent/confirm-tool | Approve/deny a gated tool the run is blocking on ({ session_id, approved }). |
POST /v1/email/agent/cancel | Cooperatively cancel the in-flight run. |
DELETE /v1/email/agent/session/{id} | Evict a session + tear down its agent. |
GET /v1/email/agent/session/{id}/history | Conversation so far (turns[], oldest first). |
POST /v1/email/agent/memory | Runtime memory toggle (#1666), { session_id, enabled } → { enabled, available, message }. Enabling memory that was never initialized (started with GAIA_MEMORY_DISABLED / Lemonade unreachable) → 409, never a silent no-op. |
GET /v1/email/agent/memory/{id} | Memory status without changing it. |
Sessions are in-process and single-tenant (the sidecar hosts one user's agent); one turn runs at a time per session. Memory uses FAISS locally; embeddings still go over Lemonade HTTP, so the frozen binary stays free of torch/transformers.
Mailbox actions (archive / quarantine, schema 2.1)
archive and quarantine mutate the live mailbox, so each is gated on a single-use token exactly like send — but minted by confirmAction (not draft), bound to the (action, message_id). A token for one action/message cannot authorize a different one. Both are reversible inside the 30s undo window:
// Archive (gated) → undo within the window (ungated):
const { confirmation_token } = await client.confirmAction({
action: "archive",
message_id: "msg-123",
});
const { batch_id, post_archive_id } = await client.archive({
message_id: "msg-123",
confirmation_token,
});
// post_archive_id is the id valid NOW — Outlook mints a new one on the folder move.
await client.unarchive({ batch_id }); // restores to inbox; 409 if the window lapsed
// Quarantine a phishing message (Gmail-only; refuses is_phishing:false and Outlook), then undo by action_id:
const t = await client.confirmAction({ action: "quarantine", message_id: "msg-9" });
const q = await client.quarantine({
message_id: "msg-9",
is_phishing: true,
confirmation_token: t.confirmation_token,
});
await client.unquarantine({ action_id: q.action_id });
Calendar (view / create / respond, schema 2.1)
Confirmation gating — deliberate asymmetry.sendand calendar create are token-gated (a payload-boundconfirmation_tokenfromdraft/previewCalendarEvent; no/invalid token →403). Calendar respond (RSVP) is intentionally not token-gated, even though the in-process agent treatsaccept_invite/decline_inviteas confirmation-tier tools. The contract draws the line at irreversibility:sendandcreateare externally visible and not cleanly undoable, whereas an RSVP only sets your own response status on an existing invite and can be changed by responding again. The REST caller (the Agent UI's accept/decline controls) is the human-in-the-loop for that reversible action.
Agent-loop capabilities not on the contract
Some agent capabilities run only in the agent tool loop (chat / Agent UI / gaia email) and have no REST endpoint, so this package's EmailClient can't drive them — they reach hosts through the agent chat surface until routes land in a future schema bump:
- Scheduled send + snooze (#1609):
schedule_send,snooze_message,cancel_scheduled_job,list_scheduled_jobs. A send is user-confirmed at creation, persisted as a mailbox draft plus a one-shot job in the agent's SQLite, and fired by the agent's scheduler at/after its time. - Voice / style-matched drafting (#1607):
build_voice_profilesamples the user's Sent mail into a local style profile (top greetings / sign-offs, typical length, contraction & exclamation rate — derived features only, never raw content, stored on-device), and the agent's system prompt injects that guidance every turn so drafted reply bodies come out in the user's own voice instead of a neutral scaffold;clear_voice_profileforgets it. Read-only over Sent mail — nothing remote is mutated. - Follow-up tracking (#1606):
check_followupsscans every connected mailbox's Sent folder and flags threads whose latest message is still the user's own outbound mail past a configurable window (default 3 days), most overdue first. Detection only — it never sends a nudge (any send stays confirmation-gated).
None of these are on the REST/MCP contract, so none of them moves SCHEMA_VERSION.
Readiness vs liveness
health() is liveness-only — a green /health means "the REST surface is up," not "triage will work." On a fresh machine the binary boots fine, but the first triage returns HTTP 502 until a local Lemonade Server is running and the configured model is pulled.
The authoritative readiness signal is GET /v1/email/init (#1795): it probes the whole triage stack — Lemonade reachable and version-compatible and the triage model downloaded — and returns 200 when ready, 503 when not, with an actionable hint. The init() client method wraps it — returning the InitResponse on both the ready (200) and not-ready (503) paths (branch on .ready), and, like every EmailClient method, attaching the per-session bearer token (#1706) for you. A raw fetch works too (the InitResponse type is exported) but must attach it:
const r = await fetch("http://127.0.0.1:8131/v1/email/init", {
headers: { Authorization: `Bearer ${sidecar.authToken}` },
});
const init = (await r.json()) as import("@amd-gaia/agent-email").InitResponse;
if (!init.ready) throw new Error(init.hint ?? "email agent not ready to triage");
POST /v1/email/init is the companion provisioning verb: it asks the running Lemonade to pull the model and streams text/plain progress. It cannot install Lemonade itself (a host prerequisite) — if Lemonade is unreachable it returns 503 and pulls nothing.
Request shapes
Recipients and senders are address objects, not bare strings: { email: string, name?: string }. This applies to triage's message.from and principal, and to draft/send's to (a non-empty array of them). Passing a plain string for to is a 422 validation error.
draft proposes a reply and mints a single-use confirmation_token bound to that exact message; send echoes it back. A full round-trip:
const { draft, confirmation_token } = await client.draft({
to: [{ email: "you@example.com" }],
subject: "Re: Prod incident",
body: "On it — fix lands today.",
});
// `draft` is { to, subject, body, attachments }; the token authorizes exactly
// this payload.
const sent = await client.send({ ...draft, confirmation_token });
console.log(sent.sent_id);
Attachments (schema 2.2, #1542)
draft and send accept an optional attachments array of { filename, mime_type, content_base64 } (standard base64, ≤ 25 MB decoded each). Validation is fail-loud (422 for bad base64, a malformed MIME type, an empty file, or oversize — never a silent drop), and the confirmation token binds to each attachment's filename, MIME type, and content digest: a send whose attachment set differs in any way from the confirmed draft is rejected with 403. Note the send payload carries the full content_base64 — spread the request you drafted with, not the metadata-only draft echo, when attaching files:
const req = {
to: [{ email: "you@example.com" }],
subject: "Re: Prod incident",
body: "Report attached.",
attachments: [{
filename: "incident-report.pdf",
mime_type: "application/pdf",
content_base64: reportB64,
}],
};
const { confirmation_token } = await client.draft(req);
const sent = await client.send({ ...req, confirmation_token });
// sent.attachments echoes [{ filename, mime_type, size_bytes }] — metadata only.
Outlook mailboxes cap each attachment at 3 MB (the Graph simple-attach limit) — a larger file fails the send loudly rather than being truncated.
Triage response shape
triage returns { schema_version, request_kind, result }. The result (EmailTriageResult) is what you route on:
| Field | Type | Notes |
|---|---|---|
category | "URGENT" | "NEEDS_RESPONSE" | "FYI" | "PROMOTIONAL" | "PERSONAL" | The five buckets — uppercase wire strings (res.result.category === "URGENT"). |
is_spam, is_phishing | boolean | Independent signals (a message can be neither, either, or both). |
summary | string | Plain-text summary of the message/thread. |
action_items | ActionItem[] | Each { description, due_hint?, type?: "text" | "link", url? }; may be empty. |
suggested_action | "reply" | "none" | "archive" | "reply" for URGENT/NEEDS_RESPONSE, "archive" for PROMOTIONAL, else "none". |
draft | DraftScaffold | null | A proposed reply scaffold ({ to, subject } — no body) when one is suggested (schema 2.3). Triage never composes reply prose; compose the body yourself and call draft() for a full DraftReply + confirmation token. |
usage | TriageUsage | null | LLM token/latency metrics; null on the heuristic-only path. |
attachments | AttachmentMeta[] | Metadata ({ filename, mime_type, size_bytes, attachment_id? }) of the analyzed message's attachments, echoed from the request for downstream processing (schema 2.2; empty when none). |
The full request/response types are exported from the package (src/types.ts) for exact field-level reference.
Action-item task persistence (additive, #1605)
Beyond returning action_items inline, triage / triageBatch persist each extracted item as a task row in the sidecar's local SQLite (~/.gaia/email/state.db), linked back to the source via the request's message_id (or thread_id for a thread). Persistence is de-duplicated per message on the normalized description, so re-triaging the same message never creates duplicate tasks. Results with no message_id are not persisted (no source to link back to). This is a side-effect only — the wire response is byte-for-byte what it was before; there is no read/complete task endpoint on this contract yet (that surface arrives with GAIA's cross-agent task store, amd/gaia#1521).
Batch triage shape (additive, #1887)
triageBatch takes { schema_version?, items, context? } where items is 1–100 EmailInput objects (the same SingleEmailInput / ThreadInput shapes triage accepts, discriminated on kind), and context — when present — applies to all items. It returns { schema_version, results } with one BatchItemResult per item, order-preserved (1:1 with items):
| Field | Type | Notes |
|---|---|---|
index | number | 0-based position in the request items array. |
result | EmailTriageResult | null | Set when the item succeeded (same shape as triage's result). |
error | BatchItemError | null | Set (with a message) when the item failed. Exactly one of result / error is set. |
HTTP 200 with every item errored is a valid response — a per-item failure does not fail the request, so always inspect each results[].error, never just the HTTP status. A 502 means Lemonade was unreachable before any item ran (the whole batch fails). The single triage() endpoint and its types are unchanged; MAX_BATCH_SIZE is exported for the 100-item cap (over-cap → 422).
Inbox search shape
search({ query?, labels?, max_results? }) lists messages from the connected mailbox and returns { schema_version, query, count, messages, next_page_token }. It is read-only — no body is read in full, nothing is modified, no confirmation token is involved. Both query and labels are optional: a query searches all mail (Gmail search semantics), labels filter to those labels, and with neither it lists the INBOX. max_results is 1–100 (default 25); each match is hydrated with a per-message fetch, so the cap bounds that fan-out. To page, pass the response's next_page_token back as the request's page_token. Each messages[] item:
| Field | Type | Notes |
|---|---|---|
id | string | Provider message id (opaque) — pass to the agent/triage path to read in full. |
thread_id | string | null | Provider thread id. |
subject | string | Subject line. |
from | string | Raw From header (e.g. "Sarah Chen <sarah@example.com>") — a string, not an address object, unlike triage's from. |
to | string | Raw To header. |
date | string | Raw Date header. |
snippet | string | Provider-supplied short preview. |
label_ids | string[] | Label ids on the message. |
const { messages } = await client.search({ query: "is:unread", max_results: 20 });
for (const m of messages) console.log(m.subject, "—", m.from);
Lifecycle helpers
startSidecar(opts) does spawn → waitForHealth → checkVersion in one call and shuts down on any failure so a failed start never leaks a process. For finer control, the steps are exported individually:
fetchBinary(opts)→ download + verify + install; returns{ binaryPath, sha256, cached, ... }.resolveBinaryPath({ resourcesDir })→ locate a fetched binary (throwsBinaryNotFoundErrorif absent).spawnSidecar({ binaryPath, host?, port?, extraArgs? })→ spawn with--host 127.0.0.1 --port <p>(default port 8131).waitForHealth(baseUrl, { timeoutMs })→ poll/health; throwsHealthTimeoutErroron timeout (never assumes ready).checkVersion(client, { expectedApiVersion })→ throwsVersionMismatchErrorif the sidecar's apiVersion MAJOR differs (a higher MINOR is accepted).verifySha256(buf, expected, label)→ throwsIntegrityErroron mismatch.shutdown(sidecar)→ kill the whole process tree (taskkill /F /Ton Windows; detached process-group kill on POSIX). The default auto-reaper does the same on process exit/crash/signal, so only a hardSIGKILLof the host can still orphan the child.
CLI
npx @amd-gaia/agent-email playground # fetch + run the sidecar, open the playground
npx @amd-gaia/agent-email fetch --out resources
npx @amd-gaia/agent-email version # show manifest + current platform
npx @amd-gaia/agent-email help
playground is the zero-to-running shortcut: it fetchBinarys into a temp cache (--out to override), startSidecars on --port (default 8131), opens the default browser to /v1/email/playground (--no-open to skip), and runs until Ctrl+C. The command owns the sidecar lifecycle itself (autoCleanup: false) and shuts it down on SIGINT/SIGTERM/SIGHUP or on any startup error. Lemonade still has to be running for live triage — the page itself reports if it isn't.
fetch is the supported, build-time path. It resolves ${process.platform}-${process.arch}, downloads that platform's artifact from the base URL in binaries.lock.json, verifies its SHA-256 against the lock and fails loudly on any mismatch, writes it to --out, and chmod +x's it on POSIX.
| Flag | Meaning |
|---|---|
--out <dir> | Resources dir to write the verified binary into (required) |
--base-url <url> | Override the download base URL (defaults to the lock's baseUrl) |
--platform <key> | Override platform key (e.g. linux-x64); default is the host |
--force | Re-download even if a verified binary already exists |
SHA-256 is mandatory. There is no "use it anyway" path — a corrupt, truncated, or tampered download is rejected before it can ever be spawned, and the bad file is not left on disk.
Connectors & auth
An endpoint that works on the content you pass in the request is standalone — it needs nothing but the local Lemonade LLM. An action that reads from or acts on the live Gmail/Outlook mailbox or calendar requires the Google or Microsoft connector (OAuth), configured in GAIA under Settings → Connectors.
send resolves its OAuth token from the local GAIA connector store (gaia.connectors) on the host — EmailSendRequest has no access_token field (provider is only a routing hint). There is no way to pass or forward a connection through this package's API, so connector-backed calls only work on a machine where the mailbox is already connected in GAIA. Triage and draft, which need no connector, work anywhere.
As of SCHEMA_VERSION 2.2 this package's REST API exposes the read-only inbox search and pre-scan (search / prescan), the archive and phishing-quarantine mailbox actions plus their undo (confirmAction / archive / unarchive / quarantine / unquarantine), calendar view / create / respond (listCalendarEvents / previewCalendarEvent / createCalendarEvent / respondToCalendarEvent), and attachments on triage/draft/send (#1542). The full GAIA email agent does more on the live mailbox (label, move, mark spam) and calendar (detect / conflicts); those remaining actions are connector-gated by definition and are not exposed through this package's REST API yet.
Browser / Electron renderer (./client)
The default entry (.) pulls in Node built-ins (node:fs, node:child_process, node:crypto) to fetch and spawn the binary, so it can't be bundled for a browser or an Electron renderer. The browser-safe ./client subpath re-exports only zero-Node-dependency symbols — EmailClient, every error class, SCHEMA_VERSION, and all request/response types — so it bundles for a renderer.
But the sidecar serves same-origin only and sends no CORS headers, so a renderer on a different origin cannot fetch http://127.0.0.1:8131 directly. Two working patterns:
- Electron (recommended): spawn and own the sidecar in your main process (the
.entry), and exposetriage/draftto the renderer over your own IPC. - Same-origin / proxied: use
./clientfrom a page that already shares the sidecar's origin, or behind a proxy you control.
import { EmailClient } from "@amd-gaia/agent-email/client";
// Same-origin or proxied path only — not a cross-origin fetch at 127.0.0.1:8131.
const client = new EmailClient({ baseUrl: "http://127.0.0.1:8131" });
const res = await client.triage({ payload: { /* … */ } });
Module format
The package is ESM-only ("type": "module"; no CommonJS build). Import it with import …. From a CommonJS module, use a dynamic import instead of require:
const { startSidecar } = await import("@amd-gaia/agent-email");
Plain JavaScript works — the package ships compiled JS in dist/; TypeScript is the authoring language, not a consumer requirement. The bundled .d.ts files give editors autocomplete but your code never imports them.
Types
TypeScript types in src/types.ts mirror two Python sources of truth:
contract.py— the triage request/response contract plus the schema-2.1 additions (inbox search, mailbox actions, calendar, pre-scan), the schema-2.2 attachment models (AttachmentMeta/OutgoingAttachment), and the schema-2.3 triage draft scaffold (DraftScaffold;SCHEMA_VERSION = "2.3").api_routes.py— the local draft/send confirmation handshake models, the readiness-preflight envelope (InitResponse/InitLemonadeStatus/InitModelStatus, #1795), and the scheduled-briefing response (EmailBriefingResponse, #1608).
They are hand-written (vs. generated from /openapi.json) because the contract is small and version-gated, keeping the published package free of a typegen build step. The runtime checkVersion guard catches contract drift loudly; the server exposes GET /openapi.json if you prefer to regenerate.
Wire note:EmailMessage.fromis the JSON key on the wire (Python aliases itsfrom_field tofrom), so the TS interface usesfromdirectly.
Platforms
Fully supported: win32-x64, linux-x64, darwin-arm64 (Apple Silicon). Intel macOS (darwin-x64) is a best-effort target — built when the release can, and omitted with a clear "no binary for darwin-x64" install error otherwise. Each binary is built natively (PyInstaller does not cross-compile); binaries.lock.json maps every available platform to its artifact filename, SHA-256, and size.
License
Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
SPDX-License-Identifier: MIT
name: integrate-agent-email description: Use when integrating the @amd-gaia/agent-email npm package — embedding the GAIA email agent (a local triage/draft/send sidecar) into a Node, TypeScript, or Electron app. Covers install, spawning the sidecar, calling the typed client, prerequisites, and the common gotchas.
Integrating @amd-gaia/agent-email
@amd-gaia/agent-email embeds the GAIA email agent in a JS/TS app. It triages, drafts, and sends email locally on AMD Ryzen AI — no cloud LLM. This package is the client: it downloads a frozen native sidecar binary, spawns it, and talks to it over local HTTP. There is no Python and no separate GAIA install.
Follow these steps to wire it into an app.
1. Install
npm install @amd-gaia/agent-email
The package is ESM-only ("type": "module"). Use import, not require. From a CommonJS file, use await import("@amd-gaia/agent-email").
2. Pick the right entry point
- Node / main process → the default entry
@amd-gaia/agent-email. It can fetch the binary and spawn/own the sidecar (usesnode:fs,node:child_process). - Browser / Electron renderer → the
@amd-gaia/agent-email/clientsubpath. It has zero Node built-ins and only talks to an already-running sidecar over HTTP.
The desktop pattern: spawn the sidecar once from the Node/main process, then drive it from the renderer via ./client.
3. Fetch the binary and start the sidecar (Node)
import { fetchBinary, startSidecar, shutdown } from "@amd-gaia/agent-email";
// Build time (or first run): download + SHA-256-verify the platform binary.
const { binaryPath } = await fetchBinary({ outDir: "resources" });
// Runtime: spawn -> wait for /health -> version-check, in one call.
const sidecar = await startSidecar({ binaryPath, port: 8131 });
// ... use sidecar.client ...
await shutdown(sidecar); // graceful stop — auto-cleanup also reaps on exit
fetchBinarywrites a verified binary intooutDir. SHA-256 is mandatory; a bad download is rejected and not left on disk. Run it at build time or guard it to run once.startSidecarthrows if the binary can't start, never becomes healthy, or the contract MAJOR version mismatches — and cleans up so a failed start leaks nothing.- The sidecar is auto-reaped when your process exits, crashes, or is signalled (default
autoCleanup), so a missedshutdownwon't orphan the frozen binary's child.shutdown(sidecar)is the graceful, awaited stop;autoCleanup: falseopts out.
4. Call the typed client
const res = await sidecar.client.triage({
payload: {
kind: "single",
principal: { email: "me@example.com" },
message: {
message_id: "m1",
from: { name: "Sarah Chen", email: "sarah@example.com" },
subject: "Prod incident follow-up",
body: "Please review the report and reply by Friday.",
},
},
});
console.log(res.result.category, res.result.summary);
To classify many messages at once, use triageBatch — an items array (1–100) in, a parallel results array out, order-preserved. It's additive (the single triage above is unchanged). Per-item failures isolate, so an HTTP 200 can still carry errored items — inspect each results[].error, never just the status:
const batch = await sidecar.client.triageBatch({
items: [
{ kind: "single", principal: { email: "me@example.com" },
message: { message_id: "m1", from: { email: "sarah@example.com" },
subject: "Prod incident", body: "Reply by Friday." } },
],
});
for (const r of batch.results) {
if (r.error) console.warn(`item ${r.index} failed: ${r.error.message}`);
else console.log(`item ${r.index}:`, r.result!.category);
}
The interface:
| Call | Needs | Notes |
|---|---|---|
triage(req) | Local LLM only | Classify / summarize / extract action items + phishing signals on the message you pass. No mailbox read. Action items also persist to the sidecar's local task list (keyed by message_id, de-duplicated on re-triage) — the response shape is unchanged. |
triageBatch(req) | Local LLM only | Same as triage for an items array (1–100). Parallel results array; per-item failures isolate (200 can carry errored items — inspect results[].error). |
search(req) | A connected mailbox | Read-only inbox search by query/labels; returns message metadata (id, subject, sender, snippet, labels), no body. No token. No mailbox → 503, two+ → 400. |
prescan(req?) | A connected mailbox | Read-only inbox pre-scan → triage-card envelope (kind: "email_pre_scan": urgent / actionable / suggested-archive rows + an informational count). No mailbox connected → 503; 2+ → 400. Heuristic-only, no Lemonade call. |
draft(req) | Nothing external | Returns a single-use confirmation token. Optional attachments (schema 2.2): { filename, mime_type, content_base64 } each, ≤ 25 MB decoded. |
send(req) | Draft token + a connected mailbox | Gate fires first: no/invalid draft token → 403; valid token but no mailbox connected on the host → 503. Attachments must exactly match the confirmed draft's (the token binds their content digests). |
confirmAction(req) | Nothing external | Mints a single-use token for "archive"/"quarantine", bound to the (action, message_id). |
archive(req) | confirm token + a connected mailbox | Removes from inbox. Gate fires first (no/invalid token → 403). Returns a batch_id undo handle (+ post_archive_id for the Outlook id change). |
unarchive(req) | A connected mailbox | Restores within the 30s window (ungated — pass batch_id); expired/unknown → 409. |
quarantine(req) | confirm token + a connected Gmail mailbox | Applies GAIA_PHISHING_QUARANTINE + archives a phishing message. Refuses is_phishing:false → 400; Gmail-only (Outlook → 400). |
unquarantine(req) | A connected mailbox | Restores prior labels within the 30s window (ungated — pass action_id); expired/unknown → 409. |
listCalendarEvents(opts?) | Connected mailbox + calendar scope | Read-only view of the primary calendar. Optional timeMin/timeMax; provider only when >1 account. Missing scope → 403 + reconnect CTA. |
previewCalendarEvent(req) | Nothing external | Mints a single-use confirmation token bound to the event (calendar analogue of draft). |
createCalendarEvent(req) | Preview token + connected calendar | Token gate fires first: no/invalid token → 403, then the calendar checks. |
respondToCalendarEvent(req) | Connected calendar | RSVP accepted/declined/tentative to an existing invite. |
Build the standalone surface (triage, draft, confirmAction, previewCalendarEvent) with zero connector setup. The read-only search and prescan read the live inbox (a connected mailbox, no token); send, the mailbox actions (archive / quarantine), and the calendar actions (view / create / respond) need a connected mailbox whose relevant scope was granted. Mint the gate token with draft (for send), confirmAction (for archive / quarantine), or previewCalendarEvent (for createCalendarEvent); archive and quarantine are reversible inside a 30s window via the ungated unarchive / unquarantine. Every non-2xx response throws HttpError (status, url, bodyText) — handle it; there is no silent null.
Scheduled daily briefing (#1608, REST-only): the sidecar can run prescan on a daily timer with no prompt. Off by default — launch with startSidecar({ env: { GAIA_EMAIL_BRIEFING_ENABLED: "true" } }) (fire time GAIA_EMAIL_BRIEFING_TIME, 24h local HH:MM, default 08:00), then pull the latest run from GET /v1/email/briefing with plain fetch (no client wrapper yet). 404 until the first scheduled run; an invalid env value fails sidecar startup loudly.
5. From a renderer (Electron / browser)
The sidecar serves same-origin only — no CORS. A renderer on a different origin cannot fetch http://127.0.0.1:8131 directly; the browser blocks it. So:
- Recommended: spawn the sidecar in the Electron main process (step 3) and expose
triage/draftto the renderer over your own IPC. Don't call the sidecar from the renderer directly. - The
./cliententry (zero Node built-ins) is only usable from a same-origin or proxied page:
import { EmailClient } from "@amd-gaia/agent-email/client";
// Pass the sidecar's session token (from sidecar.authToken in the main process,
// forwarded over IPC) — without it every /v1/email/* call is 401.
const client = new EmailClient({ baseUrl: "http://127.0.0.1:8131", authToken });
Canonical agent-loop query (POST /v1/email/query, schema 2.4)
The v2 keystone (#2016): NL request in, the agent reasons and chains its tools, the seven canonical Server-Sent Event types out — status / token / tool_call / tool_result / needs_confirmation / final / error, terminated by exactly one final or error. This is the one loop every v2 front-door relays to. The host mints run_id and pushes the transcript slice in context, so the sidecar stays stateless; cancel a run mid-flight with POST /v1/email/query/{run_id}/cancel (stops tool execution between steps). Not wrapped by the typed client yet — call it directly:
const base = "http://127.0.0.1:8131";
const run_id = crypto.randomUUID(); // host-minted; also the cancel handle
const res = await fetch(`${base}/v1/email/query`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
authorization: `Bearer ${authToken}`, // per-session bearer (#1980)
},
body: JSON.stringify({ query: "Triage my inbox", run_id, context: [] }),
});
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const ev = JSON.parse(line.slice(5).trim());
// ev.type ∈ status|token|tool_call|tool_result|needs_confirmation|final|error
if (ev.type === "final" || ev.type === "error") { /* terminal */ }
}
}
// To cancel: await fetch(`${base}/v1/email/query/${run_id}/cancel`, { method: "POST",
// headers: { authorization: `Bearer ${authToken}` } });
A confirmation-requiring step (a destructive tool such as send_now) emits needs_confirmation then ends with a final refusal pointing at the fixed-function route — mint a token via draft(), then send() (stateless stub, epic decision D1; confirm_url omitted).
Stateful agent surface (/v1/email/agent/*, 0.4.0)
Everything above is stateless — you send a payload, the sidecar analyzes it, no memory, no conversation. The sidecar also hosts a session-scoped, conversational agent that runs the full EmailTriageAgent (memory, personalization, every agent tool) over HTTP. This is the surface the Agent UI uses. It is not wrapped by the typed EmailClient yet — call it directly with fetch against the sidecar's baseUrl:
const base = "http://127.0.0.1:8131";
// 1. Start a session (builds the agent; reports memory availability).
await fetch(`${base}/v1/email/agent/session`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: "s1" }),
});
// 2. Run a turn — the reply streams back as Server-Sent Events.
const res = await fetch(`${base}/v1/email/agent/query`, {
method: "POST", headers: { "content-type": "application/json", accept: "text/event-stream" },
body: JSON.stringify({ session_id: "s1", message: "Triage my inbox" }),
});
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = "";
for (;;) {
const { value, done } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let i; while ((i = buf.indexOf("\n\n")) >= 0) {
const line = buf.slice(0, i).split("\n").find(l => l.startsWith("data: "));
buf = buf.slice(i + 2);
if (!line) continue;
const ev = JSON.parse(line.slice(6)); // {type: "thinking"|"step"|"permission_request"|"run_complete"|...}
if (ev.type === "permission_request") { // a gated tool (send/forward/delete/...) is waiting
await fetch(`${base}/v1/email/agent/confirm-tool`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: "s1", approved: true }),
});
}
if (ev.type === "run_complete") console.log("answer:", ev.answer);
}
}
Other endpoints: POST /cancel, DELETE /session/{id}, GET /session/{id}/history, and the runtime memory toggle POST /memory + GET /memory/{id} (enabling memory that was never initialized returns 409, never a silent no-op). One turn at a time per session — an overlapping /query returns 409. See SPEC.md for the full table.
Running in a server / long-lived app
fetchBinaryis a build step, not per request (network + SHA verify). Run it once;resolveBinaryPathat runtime.- Spawn once at boot, hold the
Sidecarhandle for the process lifetime — never per request. - Low concurrency. One local Lemonade model slot, so parallel
triagecalls serialize. Cap inflight calls. - Cleanup is automatic (default
autoCleanup): the sidecar's child is reaped on exit/crash/signal. Callshutdownfor a graceful stop, orautoCleanup: falseto wire signals yourself. The package does not restart a crashed sidecar.
Prerequisites — the agent needs a local model
The sidecar runs the LLM via Lemonade Server, which this package does not install. Before triage/draft/send succeed, the host must have:
- A running Lemonade Server (
lemonade-server serve). - The model pulled (
gaia initinstalls Lemonade and downloads the default model).
Until then the binary boots, but the first triage returns HTTP 502.
Gotchas (read before debugging)
- Every
/v1/email/*call needs the session token (#1706).sidecar.clientcarries it automatically; a client you construct yourself must passauthToken(fromsidecar.authToken) or every call is 401. Non-loopbackHost→ 400, non-loopback browserOrigin→ 403./health·/version·/v1/email/spec·/v1/email/playgroundare exempt. health()is liveness-only. A green/healthmeans the REST surface is up, NOT that triage will work. For real readiness callinit()(GET /v1/email/init, #1795) — it probes Lemonade + the triage model and returns theInitResponseon both the ready (200) and not-ready (503) paths, so branch on.ready/ read.hint.POST /v1/email/initstreams a model-pull (no wrapper yet).- HTTP 502 from
triage→ Lemonade isn't running/reachable, or the model isn't pulled. It is not a bug in this package. - Addresses are objects, not strings.
to(andtriage'sfrom/principal) are{ email, name? };tois a non-empty array of them. A plain string → 422. sendneeds the draftconfirmation_token(missing/invalid → 403), but it takes no OAuth token — the mailbox is resolved from the host's GAIA connector store (no mailbox connected → 503). The read-onlysearch/prescanresolve the mailbox the same way (503 with none, 400 with 2+). Triage and draft need no connector.- Attachments bind to the token (schema 2.2). Re-send the exact
attachmentsarray you drafted with — the metadata-onlydraftecho has nocontent_base64, so spreading the echo intosendloses the files. A swapped/extra/missing attachment → 403; bad base64, a malformed MIME type, or > 25 MB decoded → 422; Outlook additionally rejects files over 3 MB (Graph simple-attach limit). archive/quarantineare gated likesend, but their token comes fromconfirmAction(notdraft) and is bound to the(action, message_id)— a token for one can't authorize the other. Undo withunarchive(pass the returnedbatch_id) /unquarantine(pass theaction_id) within 30s; past the window the reversal returns 409 (restore manually in the mail client). For Outlook, use thepost_archive_idfrom the archive response — the folder move changes the id.- Cleanup is automatic by default — the sidecar is reaped on exit/crash/signal; only
autoCleanup: false(or a hardSIGKILLof your process) can orphan the child.shutdownstays the graceful stop. - Some capabilities are agent-loop-only — no REST endpoint, no client method. Scheduled send / snooze (#1609), voice / style-matched drafting (#1607 —
build_voice_profilelearns a local style profile from Sent mail so drafts come out in the user's own voice), and follow-up tracking (#1606 —check_followupsflags sent mail still awaiting a reply, detection only) all run in the agent tool loop. The REST contract has no routes for them yet, so don't look forclient.scheduleSend()/client.snooze()/ a voice or follow-up method — they don't exist (and none of these movesSCHEMA_VERSION). - ESM-only.
require("@amd-gaia/agent-email")fails; useimport/ dynamicimport().
Verify the integration
A green path looks like: fetchBinary succeeds → startSidecar resolves → client.triage(...) returns a result with a category and summary. If triage 502s, start Lemonade and pull the model, then retry — the rest of your integration is fine.
To eyeball the agent by hand without writing any code, run npx @amd-gaia/agent-email playground — it fetches the binary, starts the sidecar, and opens an interactive page where you can fire triage/draft and see a stack-health check.
For the full endpoint list, lifecycle internals, and connector details, see SPEC.md next to this file.
How the Email Triage agent is evaluated
Short version: we measure how reliably the agent sorts email into the right priority, using a fixed set of labeled emails and comparing its answer to the correct one. The current result is on the Scorecard tab. This page explains what that number means and how it's measured — in plain terms first, with the technical recipe at the end.
What we measure
The agent sorts each email into one of five buckets — urgent, needs-reply, FYI, promotional, or personal — so nothing important gets buried. The eval checks how often it puts an email in the right bucket, or a close one.
Why "or a close one"? Priority is a ranking (urgent > needs-reply > FYI > promotional). Calling a needs-reply email urgent is a near miss, not a disaster — you still see it. Calling it promotional is a real miss — it gets buried. So the headline score gives full credit for the exact bucket or the one next to it, and no credit for anything further off. That's what the 83.4 / 100 headline means: on most emails, the agent lands on the right priority or right next to it.
Alongside the headline we also report the stricter "exact bucket" rate, how many truly-urgent emails it catches (so a model can't cheat by calling everything urgent), and a couple of others. Only the headline counts toward the published score; the rest are there for transparency.
What it's tested on
- A balanced set of ~250 labeled emails drawn from a real vendor mailbox dataset — not emails we made up to make the agent look good, and balanced across the five buckets so every category (including the rare personal one) is measured fairly.
- No real personal data ever enters the test set — that's a deliberate policy.
- The whole run is on-device: the agent uses a local AI model to classify each email, and the scoring is a simple, exact comparison to the known-correct label (no cloud, no second AI "judge", no API key). That keeps the numbers stable and cheap to re-run.
There's also a separate, optional check that rates how well the agent drafts replies in your voice — that one uses an AI judge and is reported on its own; it does not affect the 83.4 triage score.
Can you trust the number?
Yes, and you can re-run it yourself. Every published score is stamped with the exact command, model, and dataset that produced it, and the test emails are rebuilt deterministically from one committed source file — so the score is reproducible, not a one-off. Each release has to clear a minimum bar before it can ship, and the score is re-measured whenever the agent's behavior or the dataset changes.
Reproducing it yourself
You need a source checkout of amd/gaia and AMD Ryzen AI hardware (the npm package ships neither the test corpus nor the eval harness). The exact, version-stamped command lives in the Scorecard's Reproduction section — it's auto-generated so it always matches the published number. Run that block; it installs the eval tools, starts a local model server, rebuilds the test emails from the committed seed, and runs the benchmark (~17 minutes on a 4B model).
<details> <summary>Technical detail (for maintainers)</summary>
- Harness:
gaia eval benchmark(src/gaia/eval/benchmark.py) drives the unchanged agent over aFakeGmailBackendsynthetic inbox; scoring is exact label-matching insrc/gaia/eval/quality_metrics.py(no LLM judge, noANTHROPIC_API_KEY). - Dataset: committed source of truth is
tests/fixtures/email/vendor_corpus_seed.jsonl;generate_mbox.pybuilds the gitignoredsynthetic_inbox.mbox+ground_truth.jsonfrom it (--verifychecks they're in sync). Full schema/provenance/PII policy intests/fixtures/email/_schema.md. - Metrics: the aggregate is
within_one_bucket_accuracy(weight 1.0);category_accuracy,urgent_recall,urgent_vs_not_accuracy, andpersonal_recallare reported at weight 0. Formula + worked recomputation are inSCORECARD.md. - Running it: set
GAIA_AGENT_TOOL_TIMEOUT=1800(full-corpus triage is one long tool call); run evals serially (twogaia evalruns against one Lemonade server race-evict each other's model); use--experiments 3for run-to-run variance (mean/stdev/95% CI). - CI:
test_email_agent_eval.yml(nightly, report-mode on the self-hosted AMDstxpool) andemail_scorecard_refresh.yml(regeneratesSCORECARD.mdon agent/corpus changes). The drafting eval needsANTHROPIC_API_KEY; absent → loud skip, never a pass. </details>
<!-- Generated by packaging/capability_matrix.py -- do not edit by hand. -->
Email Agent Capability Matrix
Code-derived surface inventory for the GAIA Email Triage agent (#2013). Regenerate with:
python hub/agents/python/email/packaging/capability_matrix.py
Definitions
- tools_count: the number of internal @tool-decorated agent-loop functions across gaia_agent_email/tools/*.py mixins (one per capability the agent's own LLM tool-calling loop can invoke). This is distinct from, and larger than, the REST API's 16 functional verbs and the MCP interface's 4 task-level tools -- both smaller, purpose-built surfaces for external callers, not agent-loop tools.
- no quality eval sentinel:
no quality eval (contract-tested only)-- the op is contract/shape-tested only; no judged quality bar exists for it.
Capability matrix
22 exposed ops (18 REST functional + 4 MCP) and their eval coverage:
| Op | Surface | Eval coverage |
|---|---|---|
archive | REST | no quality eval (contract-tested only) |
briefing | REST | briefing |
calendar/events (GET) | REST | no quality eval (contract-tested only) |
calendar/events (POST) | REST | no quality eval (contract-tested only) |
calendar/events/preview | REST | no quality eval (contract-tested only) |
calendar/events/respond | REST | no quality eval (contract-tested only) |
confirm | REST | no quality eval (contract-tested only) |
draft | REST | drafting |
draft_reply | MCP | drafting |
prescan | REST | no quality eval (contract-tested only) |
quarantine | REST | no quality eval (contract-tested only) |
query | REST | no quality eval (contract-tested only) |
query/{run_id}/cancel | REST | no quality eval (contract-tested only) |
search | REST | no quality eval (contract-tested only) |
send | REST | no quality eval (contract-tested only) |
send_email | MCP | no quality eval (contract-tested only) |
triage | REST | quality |
triage/batch | REST | quality |
triage_email | MCP | quality |
triage_email_batch | MCP | quality |
unarchive | REST | no quality eval (contract-tested only) |
unquarantine | REST | no quality eval (contract-tested only) |
Surface totals
- Internal
@toolagent-loop functions: 52 -calendar_tools: 6 -delete_tools: 3 -followup_tools: 1 -organize_tools: 15 -phishing_tools: 2 -preference_tools: 4 -profile_tools: 1 -read_tools: 8 -reply_tools: 5 -schedule_tools: 4 -summarize_tools: 1 -voice_tools: 2 - REST functional verbs: 18 (21 total operations in the frozen contract, including health/version/init probes)
- MCP tools: 4 -
draft_reply-send_email-triage_email-triage_email_batch - Eval suites: 6 -
action_items: enforce=False, acceptance_enforce=None, wired=True -briefing: enforce=False, acceptance_enforce=None, wired=True -drafting: enforce=False, acceptance_enforce=None, wired=True -followups: enforce=False, acceptance_enforce=None, wired=False -perf: enforce=False, acceptance_enforce=None, wired=True -quality: enforce=False, acceptance_enforce=True, wired=True - Additionally served but out of the frozen contract (footnote context, not guarded machinery):
agent_routes.py8 session routes,connector_routes.py4 OAuth routes,packaging/server.py2 inline probes -- ~36 total routes served by the sidecar.
MCP Scope Decision
Tools: draft_reply, send_email, triage_email, triage_email_batch
MCP exists so a host LLM can invoke the email agent as a tool, not so an external app can drive the full REST surface over stdio. Its 4 tools are task-level verbs sized for tool-calling (triage / triage_batch / draft / send) -- explicitly NOT a replica of the REST API. REST is the integration contract for the npm client; MCP is the tool-shaped facade for an orchestrating model. Adding an MCP tool is justified by 'a host LLM needs this verb to use the agent as a tool', never by 'REST has an endpoint for it'.
Eval Enforcement Status & Follow-up Plan
action_items (enforce=False, wired=True)
Extraction-quality bars with no judged baseline yet. Follow-up: generate the first nightly Strix Halo / Gemma-4-E4B baseline (the #1949 eval's documented follow-up) and flip enforce to true once it stabilizes.
briefing (enforce=False, wired=True)
Judge-scored summary-quality gate for the scheduled daily briefing (approval / recall / hallucination-free / faithfulness bars). Follow-up: establish and maintain a passing hardware baseline, and tighten the bars in the fixture as baselines improve.
drafting (enforce=False, wired=True)
Judge-scored draft-approval gate (#1269 metric, approval_min 0.70) run by release_agent_email.yml. Follow-up: establish and maintain a passing hardware baseline, and raise approval_min once a larger judged corpus is available.
followups (enforce=False, wired=False)
Detection-quality bars, CI-unwired: no eval_followup_report.py exists, unlike the other five suites. Follow-up: #2040 tracks wiring an eval_followup_report.py plus a workflow step and, separately, establishing a judged baseline (the #1950 eval's documented follow-up) before flipping enforce to true.
perf (enforce=False, wired=True)
Strix Halo perf bars (ttft / throughput / pipeline / memory) run by release_agent_email.yml. Follow-up: keep the bars in the fixture calibrated to observed hardware runs -- re-tighten as the agent gets faster, widen only with measured evidence.
quality (enforce=False, wired=True)
Triage FP/FN bars that only become meaningful once 4-way categorization accuracy improves (see the #1266 history), per the fixture's own _comment; a separate acceptance_enforce release gate runs on the within-one-bucket metric. Follow-up: flip enforce to true in the fixture once accuracy stabilizes above the gate's bars.
Wiring followups into CI (report script + workflow step) is tracked in #2040.
Changelog
What's new in @amd-gaia/agent-email, in plain language. For the technical detail behind any entry — API shapes, endpoints, and version semantics — see SPEC.md.
0.5.0
- Ask the agent in plain language. Send a free-form request ("find today's urgent mail and archive the promotions") to a new streaming endpoint and the agent works through it step by step with its tools, reporting progress as it goes; a run can be cancelled mid-way. Anything that would actually send mail still stops and routes you to the explicit draft-and-confirm flow. Not yet wrapped by the typed client — call the endpoint directly (see
SPEC.md). - Docs rewritten for humans. The README, this changelog, and the evaluation guide now lead with what the agent does in plain language; the deep technical reference lives in
SPEC.md.
0.4.0
- Reply drafts come back as a ready-to-fill scaffold (recipient + subject) instead of an always-empty body. Triage sorts and summarizes but doesn't write the reply text — so compose the body yourself and send it with
draft()+send(). - The local agent now checks who's calling it. Because it can send mail as you, it now requires a private per-session key that your app gets automatically — so another program on your machine, or a web page in your browser, can't quietly reach it to draft or send.
- Draft in your own voice. The agent can learn your writing style locally from your Sent mail (top greetings, sign-offs, typical length — never the raw content, and it stays on your device) and match it when drafting replies.
- Better spam detection that works beyond Gmail. Spam is now judged by the content itself, on-device, so it works for Outlook and any mailbox — not just Gmail's own spam label.
- Follow-up tracking. The agent can flag threads where you're still waiting on a reply past a window you choose (default 3 days), most overdue first. It points them out; it never sends a nudge for you.
- Schedule a send or snooze a message. Ask the agent to "send this tomorrow at 9am" or push a message out of the inbox until a chosen time. Both are confirmed up front and can be cancelled before they fire.
- Attachments. Triage now sees attachments, and drafts and sends can include files (up to 25 MB each). When you confirm a send, the attachments are locked to what you approved — nothing can be swapped in or added after.
- Action items become a task list. Items pulled from an email are saved locally and linked back to the message, so re-triaging never creates duplicates.
- Daily inbox briefing. The agent can produce a morning inbox summary on a schedule with no prompt. Off by default; turn it on when you launch the agent.
- A readiness check before your first triage. Ask the agent whether the local model is actually up and get a clear yes/no with a hint on what to fix, instead of hitting an error on the first request.
- Runtime memory toggle. Turn the agent's memory (inbox profiling, learned preferences) on or off without restarting it.
- Hold an ongoing conversation. Beyond one-shot requests, the agent can be driven as a stateful, streaming chat over its local API — the same thing the GAIA Agent UI uses to power its email experience.
0.3.0
- The eval score now measures what users feel. Triage priority is ranked (urgent > needs-reply > FYI), so the score credits an exact or one-off bucket — a "needs-reply" called "urgent" is close, not a total miss. It measures 83.4 / 100, and every release has to clear the bar to ship.
- Triage many emails in one call. New
triageBatch()handles up to 100 emails or threads at once instead of one request each; each item succeeds or fails on its own, so check every result, not just the overall status. - Search your inbox, view your calendar, and file messages — through the package. Read-only inbox search, calendar view/create/RSVP, and archive plus phishing-quarantine (both reversible within 30 seconds) are now available to apps embedding the agent, matching what the GAIA Agent UI can do.
- Inbox pre-scan. Get the triage card (urgent / needs-action / suggested-archive rows) for your recent inbox in one call.
0.2.5
Sending from a mailbox connected with view-only permissions now gives a clear error naming the missing mail-send permission, instead of a confusing server error. The playground's connect flow now asks for send access up front, so connect → send just works.
0.2.4
First fully-published release of this feature set. Ships the per-platform agent downloads plus this client. (The combined all-platforms download is temporarily disabled — it exceeded a hosting size limit; the individual downloads work.)
0.2.3
Re-cut of 0.2.2 after a publishing-infrastructure fix — the first fully-published release of this feature set.
0.2.2
Publishing-reliability fix so the download and npm publish complete. No change to how the agent behaves.
0.2.1
- One-command playground.
npx @amd-gaia/agent-email playgroundfetches the agent, starts it, and opens a browser page to try it — no setup. - Automatic cleanup. The agent now shuts itself down when your app exits, crashes, or is interrupted, so it never lingers holding a port.
0.2.0
- Browser-safe client. A separate
@amd-gaia/agent-email/clientimport works in a browser or Electron renderer (the main import stays Node-only, since it downloads and launches the agent).
0.1.0
- Initial release: the typed email client, the build-time downloader, and the helpers to launch and shut down the local agent.
Aggregate score 83.4 / 100. View the canonical scorecard ↗
Email Triage — Eval Scorecard v0.3.0
Aggregate score: 83.4 (out of 100)
Recipe
| Field | Value |
|---|---|
| Dataset | [tests/fixtures/email/ground_truth.json](tests/fixtures/email/ground_truth.json) |
| Description | Vendor-derived labelled email corpus for GAIA email-triage evaluation (FakeGmailBackend, schema-2.0 triage taxonomy: urgent / needs_response / fyi / promotional / personal); a deterministic, category-balanced subset of the vendor mailbox dataset |
| Dataset size | 249 labeled examples |
| Test cases run | 249 |
| Methodology | gaia eval benchmark over the vendor-derived labelled corpus via FakeGmailBackend; no LLM judge. The full 249-email corpus is scored (GAIA_EMAIL_TRIAGE_MAX_MESSAGES lifts the interactive per-call scan cap for the eval so the whole balanced corpus is covered). Aggregate = within-one-bucket ACCEPTANCE accuracy (#1437): triage priority is ordinal (URGENT>NEEDS_RESPONSE>FYI>PROMOTIONAL), so a prediction is credited when it is exact or an adjacent bucket ( |
Metrics
- within_one_bucket_accuracy: 0.8340 × 1.0 - urgent_vs_not_accuracy: 0.7845 × 0.0 - urgent_recall: 0.9938 × 0.0 - personal_recall: 0.3636 × 0.0 - category_accuracy: 0.7684 × 0.0
Aggregate score recomputation
Formula: round(100 × Σ(weightᵢ × valueᵢ) / Σ(weightᵢ), 2)
Worked example:
round(100 × ((0.8340 × 1.0) + (0.7845 × 0.0) + (0.9938 × 0.0) + (0.3636 × 0.0) + (0.7684 × 0.0)) / 1.0, 2) = 83.4
A reader can reproduce this value from the aggregate.components in the front matter alone — no eval-harness access needed.
Reproduction
Run the following commands from the repository root:
# Prerequisites: install the eval extras and start a Lemonade Server
# with the model on AMD Ryzen AI hardware (Strix Halo recommended).
uv pip install -e ".[dev,eval,api]"
lemonade-server serve # in a separate shell; must stay running
# Step 0: build the corpus from the committed seed. The mbox +
# ground_truth are GENERATED artifacts (gitignored), so a fresh
# checkout must materialise them before the benchmark can read them.
python tests/fixtures/email/generate_mbox.py
# Step 1: run the benchmark (requires the Lemonade Server above with the
# model loaded; AMD Ryzen AI / Strix Halo recommended)
PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \
GAIA_AGENT_TOOL_TIMEOUT=1800 \
PYTHONPATH="$(pwd)" \
gaia eval benchmark \
--model Gemma-4-E4B-it-GGUF \
--mbox-path tests/fixtures/email/synthetic_inbox.mbox \
--ground-truth tests/fixtures/email/ground_truth.json \
--limit 250 \
--output-dir /tmp/email-eval
# Step 2: generate this scorecard from the benchmark output
PYTHONPATH="$(pwd)" \
python hub/agents/python/email/packaging/gen_scorecard.py \
--benchmark-dir /tmp/email-eval \
--ground-truth tests/fixtures/email/ground_truth.json \
--limit 250
# Background, dataset details, a worked example, and metric
# definitions: see EVALUATION.md (next to this scorecard).
See eval-scorecard docs and the [adding-eval-scorecard skill](.claude/skills/adding-eval-scorecard/SKILL.md) for the full setup guide.
Environment
| Field | Value |
|---|---|
| gaia_commit | 8dad5985 |
| lemonade_version | 10.7.0 |
| model | Gemma-4-E4B-it-GGUF |
| hardware | AMD Ryzen AI MAX+ (Strix Halo) |
Category breakdown (pooled across all 3 runs)
_Each of the 249 test cases is scored once per run, so the totals below sum to test_cases_run × 3._
| Category | Total | Correct | Accuracy |
|---|---|---|---|
| fyi | 162 | 125 | 0.7716 |
| needs_response | 162 | 162 | 1.0000 |
| personal | 99 | 36 | 0.3636 |
| promotional | 162 | 103 | 0.6358 |
| urgent | 162 | 148 | 0.9136 |
Top confusions:
- promotional → urgent: 48 - personal → needs_response: 48 - fyi → needs_response: 37 - personal → urgent: 15 - urgent → needs_response: 12