Email Triage
v0.6.0 VerifiedGAIA 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 · 41.5 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 (personal or work Microsoft 365) 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).
- Spot what's waiting on you — flag inbound mail that asks you directly for a reply, decision, or meeting time, with a sender, subject, and how long it's been sitting there. Requires a real back-and-forth already in that thread — a bare question mark, a convincing cold-outreach email, or having emailed the sender before in some unrelated thread never qualifies on its own.
- 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);
Or hand the agent a plain-language request and watch it work — query() streams typed progress events (status, tool_call, tool_result, …) and ends with the answer; cancelQuery() stops a run mid-way:
const runId = crypto.randomUUID(); // yours to mint — it's also the cancel handle
for await (const ev of sidecar.client.query({
query: "Find today's urgent mail and archive the promotions.",
run_id: runId,
context: [],
})) {
if (ev.type === "status") console.log(ev.message);
// The agent can ask you something mid-run — answer it and the same stream
// carries on. This is how it sets up mailbox access without sending you away.
if (ev.type === "needs_input") {
await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));
}
if (ev.type === "final") console.log(ev.answer); // last event
}
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 (personal or work Microsoft 365) you set up in GAIA under Settings → Connectors — or, from 2.6, that the agent sets up with you, in the conversation: if it has no usable mailbox it works out which of the four problems it has and offers to fix that one, asking through needs_input rather than returning a command for you to go run (#2469). Connecting Google still requires your own OAuth client ID and secret; the agent says so up front. (Inside the full GAIA Agent UI daemon the connector token is forwarded to the agent by the daemon — sidecar contract 2.5, #2154; a standalone integrator using this package is unaffected and resolves the mailbox from the local GAIA connector store.)
Mail is required; calendar is requested but optional — consent asks for both up front so you're never prompted twice, but declining calendar (or connecting with an older, mail-only grant) still leaves you with a fully working triage/reply/send mailbox. Calendar tools fail loudly, naming the exact scope to add, only when you actually try to use one.
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.
Personal mailbox vs work mailbox
The package ships six built-in skills — short playbooks the agent can load into its own thinking — grouped into a personal set (inbox triage, newsletter digests, trip itineraries) and a work set (inbox triage, meeting scheduling, action items, escalation).
They are switched off in this release. Nothing is loaded at launch and a personal and a work mailbox get identical behaviour, because there is no eval evidence yet that the skills improve triage. The skill files stay in the package, inert, and the agent's full context window goes to your mail instead of to skill text.
Nothing for you to do or change: there is no set to pin, and passing --skill-set / GAIA_EMAIL_SKILL_SET fails at startup saying so rather than quietly doing nothing. Re-enabling is a change inside the agent, not in your integration. Full detail in SPEC.md.
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 84.53 / 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. This client pins SCHEMA_VERSION 2.14, matching the sidecar's current contract — every schema bump since 2.4 has been additive (see contract.py's own per-version changelog for the full log), so nothing here is a breaking upgrade for an existing integration.
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. Sidecar binaries 0.6.0+ also acceptGAIA_EMAIL_SIDECAR_TOKEN_FILE— the path of a0600, owner-only file holding the token, so the secret never sits in the process environment (readable via/proc/<pid>/environ/ps eww). The GAIA daemon delivers the secret this way and treats the env channel as a logged, deprecated compatibility leg for older binaries; a set path var whose file is missing or empty fails sidecar startup loudly. The npm lifecycle currently uses the env channel. - 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 (personal or work) 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 (personal or work) mailbox and returns the read-only triage-card envelope (kind: "email_pre_scan"), whose needs_you (schema 2.11, #2743) is the ONE worklist the card renders — up to 5 things that need you, plus bulk for the filtered remainder. 503 if no mailbox is connected, 400 if 2+ are. Heuristic-only — no Lemonade call. NeedsYouItem.detail is reserved on the wire but always empty today on every surface — the per-item extraction pass that would fill it shipped and was withdrawn before merge; a follow-up issue will populate it. |
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 (personal or work) 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 — omitting both defaults to a forward window (now → +30 days); provider only when 2+ accounts are connected. |
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 | query() | Connector | Canonical agent-loop query (schema 2.4, #2016). NL request in, canonical SSE event types out (status/token/tool_call/tool_result/needs_confirmation/needs_input/final/error), terminated by one final/error. query() returns an async iterator of typed QueryEvents. Host mints run_id; context is pushed. See "Canonical agent-loop query" below. |
POST /v1/email/query/{run_id}/respond | respondToQuery() | Connector | Answer the needs_input question a paused /query run is waiting on (schema 2.6, #2469); the ORIGINAL stream resumes. Body {request_id, value}. 404 = no such run in flight; 409 = stale request_id. |
POST /v1/email/query/{run_id}/cancel | cancelQuery() | 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 — terminal under the stateless model |
needs_input | { run_id, request_id, question, options, allow_free_text, sensitive?, respond_url, timeout_seconds? } | the agent is asking the user a question — not terminal; answer it and the run resumes (2.6, #2469) |
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.
Mid-run questions (schema 2.6, #2469): set can_answer_questions: true on the request only when your UI can render a question and answer it — it defaults to false, and a caller that leaves it off gets an immediate refusal rather than a run parked on a question it cannot show. Check the peer first: the sidecar's request model is strict, so sending this field to a sidecar below 2.6 is a 422 on every request. Read version() (apiVersion) and omit the field below 2.6 — the installed sidecar is often older than the client you built against. the agent can ask the user something while it runs — most importantly to set up or repair mailbox access instead of ending the run with a shell command for the user to go run elsewhere. It emits needs_input carrying the question, 0-4 mutually exclusive options (each with a label to pick and a description of what picking it does) and an allow_free_text escape. The run PAUSES on the open stream; respondToQuery(runId, requestId, value) delivers the answer and the same stream continues. sensitive: true means the answer is a credential — mask it and never log it. An unanswered question ends the run with an error after timeout_seconds; it never hangs.
Typed client: query() wraps the stream as an async iterator of typed QueryEvents (discriminated on type); cancelQuery(runId) wraps the cancel route and respondToQuery(runId, requestId, value) the resume route.
const runId = crypto.randomUUID(); // host-minted (spec §2.3); also the cancel handle
for await (const ev of sidecar.client.query({
query: "Triage my inbox and draft replies to anything urgent.",
run_id: runId,
context: [], // pushed transcript slice; [] for a fresh conversation
})) {
switch (ev.type) {
case "status": spinner.text = ev.message; break;
case "token": answer += ev.delta; break;
case "tool_call": console.log(`→ ${ev.tool}`, ev.args); break;
case "tool_result": renderCard(ev.render, ev.data); break;
case "needs_confirmation": /* run then ends with a final refusal (D1) */ break;
case "needs_input": // the run is PAUSED here — answer and keep iterating
await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));
break;
case "final": console.log(ev.answer); break; // terminal
case "error": console.error(ev.detail); break; // terminal, verbatim
default: console.warn("unsupported event", ev); // additive future type
}
}
// Mid-run, from anywhere that knows runId:
// await sidecar.client.cancelQuery(runId);
Semantics: exactly one terminal final/error ends the iterator — a terminal error event is yielded (its detail is the actionable message), while transport/contract failures throw (HttpError on a non-2xx; QueryStreamError on a non-SSE response, a malformed event, or a stream that closes with no terminal event). An event type outside the canonical vocabulary is yielded as { type: "unknown", eventType, raw } — surfaced, never silently dropped (contract §7). The client's timeoutMs bounds time-to-first-response only; a healthy run streams as long as the agent works (pass an AbortSignal via query(req, { signal }) to abort the transport — and also call cancelQuery so the sidecar stops the loop, not just the socket).
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 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. |
GET /v1/email/agent/autonomy/{id} | Inspectable autonomy status: { level, enabled, trust_min_samples, trust_threshold, trusted_scope_count, scopes[] } — the earned-trust ledger, never a black box. |
POST /v1/email/agent/autonomy | Set the autonomy level, { session_id, level } where level ∈ off | suggest | earn_trust | full (off = kill switch). Bad level → 400. |
POST /v1/email/agent/autonomy/run | Trigger one observe→decide→act cycle, { session_id, max_messages? } → { level, executed[], proposals[], decisions[], skipped }. decisions[] (#2529) is a per-message log — { message_id, tool, action, outcome, reason, sender } for every candidate considered, whatever the outcome — so a held-back decision (importance guard, confirm floor) is explained, not silent. The daemon clock / scheduler drives this in production. Refused with 409 while the session's level is off — the kill switch is never mistakable for "ran and found nothing to do" (#2528). |
POST /v1/email/agent/autonomy/undo | Reverse one auto-executed action and record the correction against its trust scope, { session_id, action_id } → { action_id, action_type, message_id, undone, correction_captured } (#2529). action_id comes from a prior executed[] entry. Unknown/expired/already-undone id → 409; an action_type with no reversal implemented → 400. correction_captured is false (mutation still reversed) when action_id wasn't an autonomy-executed action. |
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.
Full autonomy (earn-trust). At earn_trust the agent auto-executes only reversible actions — today archive (promotional/spam mail) and mark_read (FYI mail: useful context stays visible, but doesn't sit unread) — and only where your explicit preferences sanction it (a low-priority sender, or a category defaulted to archive) or a sender/category has crossed the trust bar (autonomy_trust_min_samples decisions at ≥ autonomy_trust_threshold accuracy); everything else is proposed. The destructive floor — send, forward, RSVP, quarantine — always requires confirmation, at every level. There is no permanent-delete tool: Gmail gates real permanent delete behind a full-mailbox scope GAIA never requests, so the agent only ever offers reversible Trash. Undoing an auto-action — via POST .../autonomy/undo, or the conversational undo_archive_batch tool for a batch archive — feeds the trust ledger as a correction (a negative outcome), so trust ratchets down on a mistake; positive-outcome accrual that would let a scope cross the bar through earned trust is not yet wired. See docs/plans/email-full-autonomy.mdx.
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). - Waiting-on-you detection (#2581):
list_waiting_on_youis the inbound counterpart tocheck_followups— it scans every connected mailbox's inbox for messages that ask directly for a reply, decision, or meeting time AND sit in a thread with genuine back-and-forth already in it (multiple prior exchanges, or one genuinely substantive prior message — a single one-line prior contact is not enough on its own). Corroboration is deliberately scoped to THIS thread's own history only — having emailed the same address before, in some other thread, does not corroborate anything; "waiting on your reply" means you're in a conversation and it's your turn, which a one-off prior contact elsewhere doesn't establish. Precision-first by design: a bare?or a human-looking sender name is never enough — both are common in adversarial marketing mail — and a message the category heuristic confidently calls promotional never qualifies regardless of corroboration. A sender the user has told to stop contacting them (address-normalized, so a plus-tagged variant can't dodge it) is suppressed unconditionally. Detection only, read-only against the mailbox.
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.connectSidecar({ baseUrl, authToken?, timeoutMs?, healthTimeoutMs?, verifyVersion?, expectedApiVersion?, signal? })→ attach mode:waitForHealth+ (default)checkVersionagainst a server this package did not spawn, returning anAttachedSidecar({ host, port, baseUrl, client, authToken? }— nochild). Spawns nothing and owns no lifecycle, so there is nothing toshutdown(). Pass anAbortSignalassignalto cancel the health wait early (e.g. the server process you're waiting on died). This is the client half of the fast dev loop — pair it with the Python source server (gaia-agent-email serve --reload), which serves an identical contract to the frozen binary. See Fast local iteration.
Fast local iteration (dev mode)
The published flow fetches and spawns a frozen binary — there is no source to edit when you hit a bug. To iterate on the agent, run its Python source and attach this client instead. The frozen binary is that source frozen (PyInstaller freezes packaging/server.py, a thin re-export of gaia_agent_email.server), so the /v1/email/* contract is byte-for-byte identical — only the base URL differs from production.
pip install -e hub/agents/email/python # editable: your edits take effect live
gaia-agent-email serve --reload # source server, auto-reload, token off for dev
import { connectSidecar } from "@amd-gaia/agent-email";
const dev = await connectSidecar({ baseUrl: "http://127.0.0.1:8131" });
await dev.client.triage({ payload: { /* … */ } });
// edit Python → auto-reload → re-run. `npx @amd-gaia/agent-email dev` launches the
// serve process for you (`--python <path>` to use a specific venv).
The serve CLI (gaia_agent_email.server:main) accepts --host, --port (rejects the reserved 4001), --reload (import-string app + watches the package dir; add --reload-dir for your core checkout), --dev (implies --reload), --skill-set <name> (accepted but currently unusable — the agent declares no skill sets, so any value errors at startup), and --print-openapi. Running without GAIA_EMAIL_SIDECAR_TOKEN disables the caller token (local dev only, logged loudly); Host/Origin protection still applies. Auto-reload resets in-process /v1/email/agent/* sessions — irrelevant to the stateless triage/draft/send surface.
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.
Mail is required; calendar is requested but optional (#2730). Every connect path (GAIA's Agent UI, the CLI, and this sidecar's own /configure route) asks for the full mail + calendar scope union up front, so accepting everything at once never means a second consent round-trip. But only the mail scopes (gmail.modify/gmail.send on Google, Mail.ReadWrite/Mail.Send on Outlook) gate whether the mailbox works at all — a connection that declined calendar, or was granted before calendar scopes existed, still triages, drafts, and sends. Calendar tools (listCalendarEvents, createCalendarEvent, respondToCalendarEvent) are the only ones that require the calendar scopes, and they fail loudly — naming the exact missing scope and the reconnect command — rather than silently no-opping. This is the request/enforce split: what's asked for at consent time is wider than what's required to mint a working token.
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 client 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.
OAuth forward-out (GAIA daemon deployment, sidecar contract 2.5)
In the GAIA Agent UI daemon deployment (not this standalone client), the daemon is the custody home for OAuth: it owns the long-lived refresh token and forwards short-lived access tokens to the sidecar's intake — POST /v1/connections/{provider} (with GET /v1/connections and DELETE /v1/connections/{provider}), added additively as sidecar contract 2.5 (#2154). The sidecar answers mailbox calls with the forwarded token and never receives the refresh token or the OAuth client secret; the daemon re-forwards on expiry and withdraws on revocation/uninstall. Forwarding honors the per-agent grant model — only connectors granted to the email agent are forwarded, and a missing/expired/scope-short credential is a loud, actionable error, never a silent empty token.
These routes are daemon-managed: a standalone integrator using this package does not call them, and the "no way to forward through the client API" rule above is unchanged. A sidecar boots into forwarded mode only when the daemon sets the private GAIA_EMAIL_FORWARDED_CREDENTIALS env channel on spawn; otherwise it uses the local connector store exactly as before.
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.
Skill sets (#2466)
Status: disabled. The agent loads zero skills. It bundles six Agent Skills and the machinery to activate one named set of them per launch, but the skill_sets: and default_skill_set: blocks in gaia-agent.yaml are commented out pending an eval run that shows the skills improve triage. Concretely, on the shipped binary:
active_skill_setisNoneandloaded_skillsis empty; no skill text reaches the system prompt.- A personal and a work mailbox get identical behaviour.
--skill-set/GAIA_EMAIL_SKILL_SETfail loudly at startup — the agent declares no sets, so there is no valid name to pass: "requested skill set 'personal', but this agent declares no skill sets — Agent Skills are switched off in this build. Drop the option, or uncomment the 'skill_sets:' and 'default_skill_set:' blocks in gaia-agent.yaml." That is the no-silent-fallbacks rule working, not a bug.- The bulk-triage result envelope is back to its full 6144 tokens (16384 − 9216 − 1024), the pre-skills value; the
personalset had cut it to 4810 andworkto 4070. - Nothing else moves: same endpoints, same tools, same permissions, same
SCHEMA_VERSION.
Re-enabling is uncommenting those two manifest blocks — both together, since a non-empty skill_sets: without a default_skill_set: is a parse error. The rest of this section describes what the machinery does when enabled.
Two different files in this package are namedSKILL.md. They are not the same kind of artifact. -SKILL.md, beside this file, is the integration playbook — instructions for an AI coding assistant helping a developer wire this npm package into an app. -gaia_agent_email/skills/<name>/SKILL.md, inside the sidecar, are Agent Skills — instructions the email agent itself would load into its own prompt at runtime (none load today, per the status above). Different audience, different format. Everything in this section is about the second kind; nothing here changes the integration playbook.
The bundled skills
Each is a Markdown procedure (skills/<name>/SKILL.md in the sidecar). All six still ship in the binary; none currently loads:
| Skill | What it makes the agent better at |
|---|---|
inbox-triage | Sorting an inbox into what needs a reply, what needs a decision, and what is just noise. |
newsletter-digest | Condensing newsletters and bulk mail into one short digest, then clearing them out. |
travel-itinerary | Assembling scattered booking confirmations into one chronological itinerary. |
meeting-scheduling | Turning meeting requests into calendar decisions — accept, decline, or propose another time. |
action-item-extraction | Pulling the concrete commitments out of a thread: who owes what, by when. |
escalation-routing | Deciding what needs attention now, what can wait, and what belongs to someone else. |
The two sets (currently commented out)
These are the sets gaia-agent.yaml declares when the blocks are uncommented; exactly one would be active per launch:
| Set | Skills |
|---|---|
personal (default_skill_set) | inbox-triage, newsletter-digest, travel-itinerary |
work | inbox-triage, meeting-scheduling, action-item-extraction, escalation-routing |
inbox-triage is in both — sets overlap, they do not partition. (A skill that should load for every set belongs in the manifest's top-level skills: list instead; this agent declares none.)
Resolution order (inert while the blocks are commented out)
- Explicit request — the
--skill-setflag orGAIA_EMAIL_SKILL_SET. Wins over everything. - The agent's selector —
EmailTriageAgent.select_skill_set()maps the connected mailbox's account type onto a set:personal→personal,work→work. default_skill_setfrom the manifest (personal), used when the account type is unknown.
An undeclared set name never falls back — it raises at startup naming the valid sets, per GAIA's no-silent-fallbacks rule. With no sets declared every name is undeclared, which is why --skill-set currently always errors.
How the account type is derived
At connect time GAIA classifies a Microsoft account from the tid (tenant id) claim of its OAuth id_token: the well-known consumers tenant means a personal account (Outlook.com / Hotmail / Live), any other tenant id means a work or school (Entra ID) account. The result is stored on the connection and exposed as account_type ("personal" / "work") by the GAIA connector store. GAIA_EMAIL_ACCOUNT_TYPE still pins this value directly and still rejects an invalid one, but with no sets declared the pin currently selects nothing — there is no set for it to hand off to.
Three consequences worth knowing:
- Gmail has no equivalent claim, so a Gmail-only mailbox has no account type to read. The kind is genuinely unknown; once sets are re-enabled, the manifest's
default_skill_setapplies here — not because anything is inferred from the mailbox, but because that is the declared default, and the resolution is logged. Nothing guesses; nothing is silent. - GAIA splits Microsoft into two connectors —
microsoft(personal,consumersauthority) andmicrosoft_work(work/school, Entra ID) — and both are now in this agent'sREQUIRED_CONNECTORS(#2629). The derivation reads whichever connector is connected, so once sets are re-enabled the work path resolves automatically for either mailbox kind. - The kind is recorded when the connection is made. A Microsoft mailbox connected before this feature shipped carries no
account_typeuntil it is reconnected, so it too resolves through the default. - No new permission or scope is involved — the claim is already in the token the connect flow receives.
Configuration
With the blocks commented out, a value passed via --skill-set or GAIA_EMAIL_SKILL_SET fails loudly at startup (see Status above), and GAIA_EMAIL_ACCOUNT_TYPE is accepted but currently selects nothing. The table and example below describe the full surface for when skill sets are re-enabled:
| Surface | Values | Effect |
|---|---|---|
--skill-set <name> (sidecar serve) | a declared set name | Pins the set for every agent session this sidecar serves. Validated against the manifest; exported as GAIA_EMAIL_SKILL_SET so per-request sessions see it. |
GAIA_EMAIL_SKILL_SET | a declared set name | Same effect, as an env var. Backs EmailAgentConfig.skill_set. |
GAIA_EMAIL_ACCOUNT_TYPE | personal | work | Pins the mailbox kind instead of the set, letting the selector do the mapping. Backs EmailAgentConfig.account_type. An invalid value raises rather than being ignored. |
From this package, either one reaches the sidecar through startSidecar:
const sidecar = await startSidecar({
binaryPath,
port: 8131,
extraArgs: ["--skill-set", "work"], // the CLI flag …
// env: { GAIA_EMAIL_SKILL_SET: "work" }, // … or the env var. Equivalent.
});
What skill sets do NOT change
The bundled skills are instruction-only: none declares tools: or permissions:, so activating a set would change only what the agent knows how to do well, never what it is able to do. Disabling them therefore removes no capability:
- The agent's tool count is unchanged (59), and so is every tool's behaviour.
- The REST and MCP contracts are unchanged — no new endpoints, no schema bump, and
SCHEMA_VERSIONdoes not move. - The connector surface and the permission model are unchanged.
Relocating the agent's tool implementations into skills is separate future work (#2672) and has not happened.
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).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).query_routes.py+ the frozen/querySSE contract (docs/spec/agent-ui-query-sse-contract.md) — the schema-2.4 agent-loop query:EmailQueryRequest/QueryContextItem, the sevenQueryEventshapes (plus theunknownplaceholder for additive future types), andQueryCancelResponse.
Every schema since 2.4 has been additive over the one before it (see contract.py's own per-version changelog for the exact field-level diff of each): OAuth-forward /v1/connections (2.5, #2154), mid-run needs_input + /query/{run_id}/respond (2.6, #2469), the read-only attention view (2.8, #2582), EmailPreScanResult.total_inbox (2.9, #2638/#2643), AttentionCoverage.message_errors (2.10, #2716), the pre-scan needs_you worklist view (NeedsYouItem[]) plus the filtered-remainder BulkSummary (2.11, #2743, see below), EmailQueryRequest.session_id: an optional conversation id that resolves the same agent across turns sharing it, instead of a throwaway per-call agent (2.12, #2829), PreScanItem.is_phishing/is_spam plus EmailPreScanResult.suspicious/suspicious_total: the phishing/spam-flagged subset of actionable, captured before its own cap so a flagged message ranked past it is never silently dropped from the count (2.13, #2900), and — current, SCHEMA_VERSION = "2.14" — a third mailbox provider value, microsoft_work (work Microsoft 365 / Entra, distinct from the personal microsoft Outlook.com connector), now valid wherever a provider string is accepted or returned (#2629).
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.
This file is NOT one of the agent's own skills. It is the integration playbook — how you wire this npm package into an app. The sidecar separately bundles six Agent Skills at gaia_agent_email/skills/<name>/SKILL.md, which are instructions the email agent itself would load into its own prompt at runtime — currently disabled, so none of them loads. Same filename, different artifact: don't load those into your assistant, and don't ship this one as an agent skill. See Skill sets below.
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"), whose needs_you (schema 2.11) is the ONE worklist the card renders — up to 5 things that need you, plus bulk for the filtered remainder. Also carries suspicious/suspicious_total (schema 2.13): the phishing/spam-flagged subset of actionable, each item tagged is_phishing/is_spam. No mailbox connected → 503; 2+ → 400. Heuristic-only, no Lemonade call. NeedsYouItem.detail is reserved on the wire but always empty today on every surface — see CHANGELOG.md. |
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 — omitting both defaults to a forward window (now → +30 days); 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. |
query(req) | A connected mailbox (for mailbox tools) | The agent loop (schema 2.4): async iterator of the seven typed SSE events. You mint run_id; push the transcript slice in context. See "Canonical agent-loop query" below. |
cancelQuery(runId) | Nothing external | Cancel an in-flight query() run between steps (pass the run_id you minted). Not in flight → 404. |
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.6)
The v2 keystone (#2016): NL request in, the agent reasons and chains its tools, the canonical Server-Sent Event types out — status / token / tool_call / tool_result / needs_confirmation / needs_input / 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. The typed client wraps it (#2097): query() returns an async iterator of typed QueryEvents; cancelQuery(runId) stops the run between steps:
const runId = crypto.randomUUID(); // host-minted; also the cancel handle
for await (const ev of sidecar.client.query({
query: "Triage my inbox",
run_id: runId,
context: [], // pushed transcript slice; [] for a fresh conversation
})) {
switch (ev.type) {
case "status": console.log(ev.message); break;
case "token": process.stdout.write(ev.delta); break;
case "tool_call": console.log(`→ ${ev.tool}`, ev.args); break;
case "tool_result": console.log(`← ${ev.tool}`, ev.data); break;
case "needs_confirmation": break; // run then ends with a final refusal (D1)
case "needs_input": // PAUSED — answer, then keep iterating
await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));
break;
case "final": console.log(ev.answer); break; // terminal
case "error": console.error(ev.detail); break; // terminal, verbatim
default: console.warn("unsupported event", ev); // future additive type
}
}
// Mid-run, from anywhere that knows runId:
// await sidecar.client.cancelQuery(runId);
Rules an integration must respect:
- Mint
run_idyourself (crypto.randomUUID()) and keep it — it is the cancel handle, valid from the instant the request is sent. - Exactly one terminal event. A terminal
erroris yielded (surfacedetailverbatim); transport/contract failures throw (HttpErrornon-2xx,QueryStreamErrorfor a non-SSE response / malformed event / stream that closes without a terminal). Never treat iterator completion without afinalas success — the client already throws for you. - Gate
can_answer_questionson the peer's version. Callversion()first: a sidecar belowapiVersion2.6 does not know the field and answers422to every request carrying it — includingfalse. Omit it below 2.6 and treat mid-run questions as unavailable. - Declare
can_answer_questionshonestly. It defaults tofalse. Set ittrueonly when a human is watching a UI that renders the question; a one-shot or batch job must leave it off, and then gets an immediate actionable refusal instead of a run parked on a question nobody can see. - Answer
needs_input, do not restart. The run is parked on the SAME stream. CallrespondToQuery(runId, ev.request_id, value)and keep iterating the existing iterator — issuing a freshquery()abandons the paused run.valueis an option'svalue(itslabelalso works) or free text whenallow_free_text. Render every option'sdescription: the label alone does not tell the user what they are agreeing to. Whensensitiveis set, mask the input and never log it. Ignoring the question is safe but wasteful — the run ends with anerroraftertimeout_seconds. - Handle the
defaultbranch. Atypeoutside the canonical vocabulary arrives as{ type: "unknown", eventType, raw }— render an "unsupported event" placeholder or log it; it is never silently dropped. - Long runs are normal.
timeoutMsbounds time-to-first-response only. To abort from the client side passquery(req, { signal })AND callcancelQueryso the sidecar stops the loop, not just the socket.
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). That is an approval and stays terminal and deny-by-default; a question (needs_input) is the resumable one. Do not treat them alike.
Mailbox setup is the agent's job now (#2469). When the agent has no usable mailbox — not connected, credentials broken, missing a scope, or connected-but-not-granted — it asks the user about that specific problem via needs_input and fixes it, rather than returning an error telling them to run a CLI command. Two cases are worth knowing: the connected-but-not-granted case needs no browser at all (a local permission write), and connecting Google still requires the user to supply their own OAuth client ID and secret, so expect a sensitive: true question on that path.
Mail-required, calendar-optional (#2730). Every setup/reconnect path — this self-repair flow included — requests the full mail + calendar scope union at consent time, but only the mail scopes gate whether the flow reports success. A user who declines calendar still ends up with a working mailbox; calendar tools raise their own actionable error, naming the exact scope, the first time one is actually called. Do not "fix" a self-repair flow that requests only mail scopes — that narrower request is the bug this issue removed, not a simplification to reintroduce.
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.
Full autonomy (/v1/email/agent/autonomy/*)
The agent can run proactively at the earn_trust level: it archives low-signal (promotional/spam) mail and marks FYI mail read on its own where your explicit preferences already sanction it (a low-priority sender, or a category you default to archive) or a sender/category has earned enough trust, and always asks before anything destructive (send / forward / RSVP / quarantine). There is no permanent-delete — the agent only ever moves mail to Trash, which is always reversible. Reply drafting is not yet wired into this proactive loop (the policy layer supports it, but no candidate reaches it today). Turn it on and inspect the earned trust:
// Turn on full autonomy (levels: off | suggest | earn_trust | full; "off" = kill switch)
await fetch(`${base}/v1/email/agent/autonomy`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: "s1", level: "earn_trust" }),
});
// Run one observe→decide→act cycle now (the daemon/scheduler drives this in production)
const r = await fetch(`${base}/v1/email/agent/autonomy/run`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: "s1", max_messages: 25 }),
});
const report = await r.json();
// { level, executed:[…], proposals:[…], decisions:[…], skipped }
// decisions[] explains EVERY candidate considered: { message_id, tool, action, outcome, reason, sender }
// Inspect the earned-trust ledger — autonomy is never a black box
const status = await (await fetch(`${base}/v1/email/agent/autonomy/s1`)).json();
// { level, enabled, trust_min_samples, trust_threshold, trusted_scope_count, scopes:[…] }
The agent learns from your corrections: undoing an auto-executed action — POST /v1/email/agent/autonomy/undo with { session_id, action_id } from the executed[] entry, or the conversational undo_archive_batch tool for a batch archive — is captured as a negative outcome that pulls the sender/category back below the trust bar. (Positive-outcome accrual — trust rising as suggestions are accepted or left standing — is not yet wired, so today the ledger only ratchets trust down.) Every auto-action is reversible with undo. A bad level returns 400; an unknown session returns 404; undoing an unknown/expired action_id returns 409; /run while the level is off returns 409 too — it refuses rather than returning the same 200 shape a real, found-nothing cycle would (#2528).
The Python host also ships a thin-client CLI over this same surface: gaia email autonomy {status|set-level|pause|resume|run|trust|kill} (#2516).
Skill sets — disabled in this release
The sidecar bundles six Agent Skills (personal: inbox-triage, newsletter-digest, travel-itinerary; work: inbox-triage, meeting-scheduling, action-item-extraction, escalation-routing), but the agent's manifest currently declares no sets, so none of them loads. A personal and a work mailbox get identical behaviour. This is deliberate: the skills are held back until an eval run shows they improve triage.
What that means for your integration:
- Do not pass
--skill-setorGAIA_EMAIL_SKILL_SET. Any value fails at startup with... but this agent declares no skill sets — Agent Skills are switched off in this build.There is no working name. This is fail-loud behaviour, not a bug to work around. GAIA_EMAIL_ACCOUNT_TYPEstill validates but selects nothing.- Nothing in the API changes either way — same endpoints, same tools, same permissions. Re-enabling happens inside the agent's
gaia-agent.yaml; your code does not change.
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.
Fast local iteration (when you need to fix the agent, not just call it)
The steps above spawn a frozen binary — you can't edit it. To debug or improve the agent, run its Python source and attach the same client. The frozen binary is that source frozen, so the contract is identical; only the base URL changes.
pip install -e hub/agents/email/python # editable install
gaia-agent-email serve --reload # source server on 127.0.0.1:8131, auto-reload
import { connectSidecar } from "@amd-gaia/agent-email";
// Attaches (health + version check), spawns nothing, token off in dev:
const dev = await connectSidecar({ baseUrl: "http://127.0.0.1:8131" });
await dev.client.triage({ payload: { /* … */ } });
// Edit the Python under gaia_agent_email/, save → reload → re-run. Seconds.
npx @amd-gaia/agent-email dev launches the serve process for you (--python <path> to use a specific venv). There's no child on the returned handle and nothing to shutdown() — you own the serve process (Ctrl+C). Switch back to production by using startSidecar (frozen binary) instead of connectSidecar; the client calls are unchanged.
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. - OAuth forward-out is daemon-only (sidecar contract 2.5, #2154). The
/v1/connections/{provider}intake exists for the GAIA Agent UI daemon to forward short-lived access tokens to the sidecar (the sidecar never holds the refresh token). A standalone integrator using this package does not call it — keep resolving the mailbox from the host's GAIA connector store as before. There is noclient.forwardConnection()method, by design. - 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), follow-up tracking (#1606 —check_followupsflags sent mail still awaiting a reply, detection only), and waiting-on-you detection (#2581 —list_waiting_on_youflags INBOUND mail awaiting the user's reply; it only qualifies a message that has both a genuine ask/meeting-time signal and corroboration that it's real correspondence) 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, follow-up, or waiting-on-you method — they don't exist (and none of these movesSCHEMA_VERSION). --skill-set/GAIA_EMAIL_SKILL_SETalways fail right now. Agent Skills are disabled in this release, so the agent declares no sets and every name is invalid. Don't wire either into your spawn options.- 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 84.53 / 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 84.53 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(manual dispatch only; a full-corpus run regeneratesSCORECARD.md, a subset run smoke-tests the pipeline without committing). 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/email/python/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 23 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
27 exposed ops (23 REST functional + 4 MCP) and their eval coverage:
| Op | Surface | Eval coverage |
|---|---|---|
/v1/connections | REST | no quality eval (contract-tested only) |
/v1/connections/{provider} (DELETE) | REST | no quality eval (contract-tested only) |
/v1/connections/{provider} (POST) | REST | no quality eval (contract-tested only) |
archive | REST | no quality eval (contract-tested only) |
attention | 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) |
query/{run_id}/respond | 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: 66 -briefing_tools: 3 -calendar_tools: 6 -connection_tools: 1 -delete_tools: 4 -followup_tools: 1 -onboarding_tools: 2 -organize_tools: 15 -phishing_tools: 2 -preference_tools: 8 -profile_tools: 1 -read_tools: 9 -ref_resolve: 1 -reply_tools: 5 -schedule_tools: 4 -summarize_tools: 1 -voice_tools: 2 -waiting_on_you_tools: 1 - REST functional verbs: 23 (26 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.py12 session routes (includes the autonomy control surface, #2529),connector_routes.py4 OAuth routes,server.py2 inline probes -- ~43 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.6.0] - 2026-08-12
- Work Microsoft 365 mailboxes are now supported alongside Gmail and personal Outlook. A work/school Microsoft account (Entra ID) can now be connected and triaged the same way as Gmail or a personal Outlook.com mailbox — connecting, onboarding copy, and mailbox selection all recognize the new
microsoft_workconnector (#2629, schema 2.14). - Compatibility note: if your app or its users refer to a mailbox as "office365", "o365", "m365", "microsoft 365", "entra", or "exchange", that now names the new work connector instead of personal Outlook. Before this release those words all pointed at the personal
microsoftconnector — the only Microsoft connector that existed. Someone with only a personal Outlook connected who uses one of these words is now told to connect the work mailbox instead of being served from their personal one. Plainmicrosoft/outlook/outlook.com/hotmail/liveare unaffected. query()can now carry a conversation forward.EmailQueryRequestgains an optionalsession_id: set it once and reuse it on every turn of a conversation (e.g.crypto.randomUUID()), and the sidecar resolves the SAME agent each time instead of a throwaway one per call — so a follow-up referring to something an earlier turn surfaced has something to resolve against. Leave it unset and nothing changes (#2829, schema 2.12).- A scoped "anything suspicious in my inbox?" question no longer dumps the full triage report (#2900).
PreScanItemgainsis_phishing/is_spam(boolean, defaultfalse) — a flag previously readable only inside a prosewhystring is now a real field — andEmailPreScanResultgainssuspicious/suspicious_total(schema 2.13): the phishing/spam-flagged subset ofactionable, captured before its own cap so a flagged message ranked past it is never silently dropped from the count. - The agent's built-in skills ship switched off, so the whole context window goes back to your mail. The six skills below are still in the package, but no set is active and none of them loads: nothing yet shows they make triage better, and an active set was consuming most of the room the agent had for bulk-triage results. A personal and a work mailbox get identical behaviour again, and
--skill-set/GAIA_EMAIL_SKILL_SETnow fail at startup saying there are no sets to pick rather than quietly doing nothing. Nothing else changes — same endpoints, same tools, same permissions. - One inbox triage card instead of two that disagreed. Asking the agent to triage your inbox used to draw two summary boxes from two separate scans at different depths — one might say "nothing needs you" while the other, five lines below, listed a message needing review. The card is now one worklist (
needs_you, schema 2.11) built from a single scan: up to five things that genuinely need you, each tagged with what to do (reply, decide, check, or a carried-over action item) and how old it is.NeedsYouItem/BulkSummaryare new onEmailPreScanResult—BulkSummarycarries a count plus the id(s) of the test(s) that filtered it, for an app that wants to render why a message didn't make the list, rather than a bare unauditable number; nothing existing was removed or renamed (#2743).NeedsYouItem.detailis also new — reserved for a couple of lines of real substance per row (the question actually asked, the meeting time actually proposed, the deadline actually quoted) — but ships always empty in this release: the per-item extraction pass that would fill it was implemented and then withdrawn before merge so it could ship on a firm timing budget rather than risk a slow scan; a follow-up will populate it. - Reconnecting your mailbox with no flags — the exact command GAIA's own error message told you to run — could silently wipe your permissions instead of fixing them. A bare
gaia connectors connect google(or the same reconnect from a first-time self-repair conversation) used to fall back to identity-only sign-in scopes whenever it wasn't told exactly what to ask for, overwriting a working mail-plus-calendar connection with nothing usable. That path now fails with a clear, copy-pasteable command instead of guessing, and every surface — the CLI, the Agent UI, this package's own connector setup, and the in-chat self-repair flow — now asks for the same scopes so none of them can quietly narrow what another one granted. Separately, calendar access is now clearly optional: a mailbox missing only calendar permission still triages, drafts, and sends normally, and calendar tools name the exact scope to add instead of taking the whole mailbox down with them (#2730). - The agent can now tell you which inbound mail is waiting on your reply — not just which of your own messages went unanswered. Previously the agent could only flag sent mail nobody replied to; a colleague's "did you get a chance to look at this? can we meet Thursday?" was invisible to it. It now also flags inbound messages that ask directly for a reply, a decision, or a meeting time — but only when there's real corroboration that it's genuine correspondence (an existing back-and-forth in the thread, or a sender you've emailed before). A question mark or a convincing-looking sender name is deliberately not enough on its own — both show up constantly in marketing and cold-outreach mail, and a false "someone is waiting on you" costs more trust than a missed one.
- Triggering an autonomy cycle while autonomy is switched off now tells you so, instead of quietly reporting nothing happened.
POST /v1/email/agent/autonomy/runused to return the same "nothing to do" response whether autonomy was disabled or had genuinely run and found nothing — there was no way to tell which. It now returns an error naming the current level and how to turn autonomy back on. - Asking the agent to draft a reply or forward now actually drafts one, instead of asking you to write it. The agent would correctly find the right email, then ask you to supply the reply or forward text — the exact thing you'd asked it to write. Nothing told it that composing the message was its own job (that instruction only existed once it had learned your writing style from enough sent mail, so it never applied to a fresh mailbox). It now writes the reply or forward itself from the original message plus whatever you specified (length, tone, points to hit), and still uses your exact wording when you hand it over yourself. Sending is unchanged — every draft still needs your confirmation before it goes out (#2524).
- Six built-in skills, and the groundwork for treating a personal mailbox differently from a work one — shipped switched off. The skills (
personal: inbox triage, newsletter digests, trip itineraries;work: inbox triage, meeting scheduling, action items, escalation) and the machinery that picks a set from the kind of Microsoft account you connected are in the package, but no set is declared, so none of it is active — see the first entry above. Turning it on is a change inside the agent; nothing in your integration changes either way (#2466). - Opt-in preview: small on-device models can now decide phishing flags and triage categories instead of keyword rules. Turn it on with
GAIA_EMAIL_USE_SLM=trueon the sidecar (oruse_slm=Truein config). A compact classifier — running on the same local Lemonade server as the chat model, so nothing leaves the machine — makes the phishing call, and a second one labels the triage category, taking that decision away from the bigger LLM (which is still consulted for the spam verdict when the rules can't settle it). It is experimental, so it stays off unless you turn it on. If the models are unavailable for any reason, triage falls back to exactly the previous behavior. No API shape changed. - A trashed email is recoverable any time it's still in Trash — not just for a few seconds after you delete it. The only way back used to be a short undo window right after trashing; miss it, and the agent told you the message was stuck, even though Gmail actually keeps Trash for 30 days. It can now find the message and restore it any time it's still there. The agent also stopped calling a trashed message "archived" in its confirmation — trash and archive recover differently, so it now says exactly what it did.
- The agent no longer claims it can permanently delete email — because it can't. Permanently deleting a Gmail message needs a scope GAIA deliberately never asks for (it would hand over delete access to your whole mailbox for one rare action), so every attempt failed. Asked directly, the agent used to say it could do it anyway. Now it says plainly it can only move mail to Trash.
- Full autonomy now does more than archive, explains its decisions, and can be undone. Previously the proactive
earn_trust/fullloop only ever archived low-signal mail — every other reversible action the trust model already declared (marking mail read, starring, labeling) was unreachable, the run report never said why a message was held back, and there was no way to undo an auto-executed action other than the archive-onlyundo_archive_batchtool. Now: FYI mail is marked read instead of archived (it stays visible, just no longer sits unread);POST /v1/email/agent/autonomy/runreturns a newdecisions[]field explaining every candidate's outcome and reason, including "held back for confirmation" and "held back — provider-flagged IMPORTANT"; and a newPOST /v1/email/agent/autonomy/undoreverses any auto-executed action and records the correction against its trust scope, the same negative-feedback loopundo_archive_batchalready gave archives. The destructive floor (send/forward/permanent-delete/RSVP/quarantine) is unaffected — it was already inviolable and stays that way at every level (#2529). - The agent sets up your mailbox itself, in the conversation. Before, hitting the email agent without a working mailbox produced an error and a shell command to go run somewhere else — a dead end for anyone in a terminal or chat window. It now works out which of the four problems it actually has (nothing connected, credentials stopped working, a missing permission, or connected but not allowed for this agent), says something specific about that one, and offers to fix it right there. The connected-but-not-allowed case is fixed with no browser at all. Connecting Google still needs your own OAuth client ID and secret — the agent now tells you that up front with a link, instead of failing later (#2469). Integrators:
can_answer_questionsis only understood from 2.6 onward, so checkversion()before sending it — an older sidecar rejects the unknown field outright rather than ignoring it. - New: the agent can ask you a question mid-run — schema 2.6, additive. A new non-terminal SSE event
needs_inputcarries a question, 2-4 labelled options each with a description of what choosing it does, and a free-text escape;respondToQuery(runId, requestId, value)(POST /v1/email/query/{run_id}/respond) delivers the answer and the ORIGINAL stream resumes. An unanswered question ends the run with an error rather than hanging. Approvals (needs_confirmation) are unchanged: still terminal, still deny-by-default (#2469). - Work/school Outlook (Microsoft 365 / Entra ID) mailboxes now work, not just personal Outlook.com. The Microsoft connector previously signed in only against the
consumerstenant, so a corporate Microsoft 365 account was rejected before GAIA ever saw a token. It now uses thecommontenant by default (both account types), overridable withGAIA_MICROSOFT_TENANT. A new zero-setup device-code sign-in connects without an Azure app registration or loopback redirect — from the CLI (gaia connectors connect microsoft --device) or the Agent UI (a Sign in with a code button on the Microsoft tile). No email-agent tool changed — the existing Outlook backend just reaches more mailboxes (#1275). - In the GAIA daemon deployment, the sidecar no longer holds long-lived OAuth secrets. Previously a sidecar read the mailbox connection straight from the machine keyring. Now, under the Agent UI daemon, the daemon (the custody home) owns the refresh token and forwards only short-lived access tokens to a new sidecar intake (
POST /v1/connections/{provider}, plusGET/DELETE) — the sidecar never sees the refresh token, the daemon re-forwards on expiry and withdraws on revocation, and only connectors granted to the email agent are forwarded. Added as sidecar contract 2.5 (additive over 2.4; every 2.4 request/response shape is unchanged). This is daemon-managed — a standalone integrator using this package is unaffected and keeps resolving the mailbox from the local GAIA connector store exactly as before (#2154). - The agent's autonomy commands now work against the shipped binary.
gaia email autonomy status/set-level/pause/resume/run/undo/kill/trustcall REST routes (/v1/email/agent/autonomy*) that did not exist in any previously published binary — a sidecar installed from 0.5.0 or earlier 404'd on every one of them, with nothing telling the caller why. All eight subcommands now reach a real route and get back a 200, or a correct 409 when autonomy is off (#2894). - Muting a sender no longer buries their genuinely urgent mail as promotional. The category override for a muted (low-priority) sender was unconditional — every message from that sender was force-classified PROMOTIONAL regardless of content, which also made it an autonomy auto-archive candidate with no confirmation. "I don't care about most of this sender's mail" is not "this specific message is never urgent" — category is now always decided by content; muting only affects ordering (#2774).
- Scanning a real Gmail inbox no longer fails outright on a rate limit. A scan batching 100 messages in one request reliably tripped Gmail's per-user concurrency limit, and a single 429 discarded the other 99 already-successful results with the whole scan failing on
CONNECTOR_ERROR. Batches are now chunked to a measured-safe size, a 429 is retried with backoff, and a message still rate-limited after retrying is dropped individually and reported — not thrown away with everything else (#2727). - A counting question about a long-bodied sender no longer overflows the model's context and comes back empty. Searching messages defaulted to fetching full bodies, and a "how many emails from X in the last two weeks?" question against a verbose sender could blow the context window before the model produced an answer. The search now defaults to metadata only (subject/from/date/snippet, no body) — a counting or listing question never needed the body — cutting the result size by roughly an order of magnitude (#2782).
- A fresh conversation's first inbox listing or search could overflow the NPU profile's context window before you got a reply.
listInbox/searchMessagescapped each message's body independently but never checked the COMBINED size of the result — a realistic 25-message inbox built a response over the NPU profile's 32K-token budget on the very first call, and the overflow sometimes surfaced as a silently truncated count (10 requested, 8 returned) rather than an error. Both now shrink every message's body together to fit the active device's budget; a request too large even at the smallest usable body size fails with an actionable error naming the limit instead of quietly returning less than asked for (#2514). - Calendar answers can no longer invent attendee names or invite confirmations that aren't in the mailbox. Asked "did anyone send me a meeting invite?", the agent could answer "yes" with no message, mutation, or attachment behind it — a real
organizerfield was sometimes narrated as "sent you an invite." Calendar listing and conflict checks now surface each event's realattendees(an event with none normalizes to[]instead of the field being omitted), and two new checks catch an invite or attendee claim the tool result doesn't support before it reaches you. Scoped to calendar attendee/invite claims only — not a general claim about hallucination elsewhere (#2766). - A reply, draft, or send could report failure even after it actually succeeded, and retrying made it worse. A transient local bookkeeping write, unrelated to the real Gmail/Outlook call, could fail right after the message was actually sent or the draft actually created — and that bookkeeping failure was surfaced as if the whole action had failed. Retrying then hit an already-consumed draft id.
draft()/send()/forward now report success whenever the real mail action succeeded regardless of that local write, and retrying an already-sent draft gets a plain "already sent" instead of a generic error (#2908). - The triage card is now assembled from the scan's own data, not retyped by the model. The categorized breakdown the model used to compose freehand — numbering, message counts, addresses — could drift from the scan that produced it: a number pointing at the wrong message, an item repeated or dropped, or a bare item count with no list at all. The card is now rendered directly from the same
needs_youdata the scan already computed — a template fill, not a generation — so a reference likearchive 3always names the message actually shown as 3; the model still writes the opening sentence and nothing else. On a 55-item real inbox this completed in under a minute end to end (#2858). - The launch secret no longer sits in the sidecar's environment. The per-session auth token used to be handed to the sidecar as a bare environment variable, visible to any local process that can inspect process environments. A 0.6.0+ sidecar spawned by the GAIA daemon now receives it as an owner-only (
0600) file that is removed when the sidecar stops; the env channel (GAIA_EMAIL_SIDECAR_TOKEN) keeps working for older binaries and for the npm lifecycle, exactly as before. - Asking "what's on my calendar?" no longer digs up years-old meetings. Listing calendar events without a date range used to return the oldest instances of recurring series — events from years ago narrated as if they were this week. An unbounded listing now defaults to the next 30 days (starting now); passing explicit
time_min/time_maxbounds works exactly as before. - The plain-language agent loop is now part of the typed client. 0.5.0's streaming endpoint required hand-rolled
fetch+ SSE parsing; nowclient.query()returns an async iterator of typed events (status,token,tool_call,tool_result,needs_confirmation,final,error— plus a visibleunknownplaceholder for event types added by a newer agent, never a silent drop), andclient.cancelQuery(runId)stops a run mid-way. You mintrun_id, so a run is cancellable from the instant you send it. A stream that breaks mid-run throws instead of looking like success. - The client now speaks contract 2.4.
SCHEMA_VERSIONmoved 2.3 → 2.4 (additive — every 2.3 request/response shape is unchanged). The startup version handshake accepts any 2.x sidecar, so a 2.3-pinned client keeps working against a 2.4 sidecar exactly as before; only the newquery()/cancelQuery()calls need a 2.4 (0.5.0+) agent binary. - On NPU-capable machines, triage now runs on the NPU by default. When you haven't pinned a specific model, the agent checks whether the Lemonade Server it's talking to has an AMD NPU and the NPU-optimized model ready — if so, it uses that automatically for lower power draw; otherwise it keeps using the existing GPU/CPU model, exactly as before.
GET /v1/email/initreports which one was picked. Accuracy/throughput numbers for the NPU model aren't published yet — that measurement lands in a follow-up release.
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). - Iterate on the agent from source. New
connectSidecar({ baseUrl })attaches the client to a server you run yourself, andgaia-agent-email serve --reload(ornpx @amd-gaia/agent-email dev) runs the agent's Python source with hot reload — so you can fix a triage/draft bug and re-test in seconds instead of waiting for a new binary. Additive — your existing calls are unchanged, and shipping to production just swapsconnectSidecarforstartSidecar. Exports the newConnectOptions/AttachedSidecartypes. Full walkthrough inSPEC.md→ Fast local iteration. - 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 84.53 / 100. View the canonical scorecard ↗
Email Triage — Eval Scorecard v0.5.0
Aggregate score: 84.53 (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 | 299 labeled examples |
| Test cases run | 250 |
| Methodology | gaia eval benchmark over the vendor-derived labelled corpus via FakeGmailBackend; no LLM judge. The full corpus is scored — see dataset_size (GAIA_EMAIL_TRIAGE_MAX_MESSAGES lifts the interactive per-call scan cap for the eval so the whole 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.8453 × 1.0 - urgent_vs_not_accuracy: 0.7987 × 0.0 - urgent_recall: 1.0000 × 0.0 - personal_recall: 0.3636 × 0.0 - category_accuracy: 0.7813 × 0.0 - draft_approval_rate: 0.6111 × 0.0
Aggregate score recomputation
Formula: round(100 × Σ(weightᵢ × valueᵢ) / Σ(weightᵢ), 2)
Worked example:
round(100 × ((0.8453 × 1.0) + (0.7987 × 0.0) + (1.0000 × 0.0) + (0.3636 × 0.0) + (0.7813 × 0.0) + (0.6111 × 0.0)) / 1.0, 2) = 84.53
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/email/python/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 | eca42a0e |
| lemonade_version | 10.10.0 |
| model | Gemma-4-E4B-it-GGUF |
| ctx_size | 16384 |
| hardware | AMD Ryzen AI MAX+ (Strix Halo) |
Category breakdown (pooled across all 3 runs)
_Each of the 250 test cases is scored once per run, so the totals below sum to test_cases_run × 3._
| Category | Total | Correct | Accuracy |
|---|---|---|---|
| fyi | 162 | 124 | 0.7654 |
| needs_response | 162 | 162 | 1.0000 |
| personal | 99 | 36 | 0.3636 |
| promotional | 165 | 112 | 0.6788 |
| urgent | 162 | 152 | 0.9383 |
Top confusions:
- personal → needs_response: 44 - promotional → urgent: 40 - fyi → needs_response: 38 - personal → urgent: 16 - promotional → needs_response: 13
Performance
_Measured on the run environment above (model / hardware / gaia_commit / corpus size); the perf gate is report-only, so these are observed values, not pass/fail bars (see tests/fixtures/email/perf_gate_thresholds.json)._
| Metric | Value |
|---|---|
| ttft_s | 24.673 |
| throughput_tps | 23.767 |
| pipeline_s | 6926.411 |
| total_input_tokens | 316983.667 |
| total_output_tokens | 169056.667 |
| tokens_per_triage | 1906.033 |
| llm_classified_count | 250.0 |
| emails_per_run | 250 |
Capability quality
_Beyond the headline triage accuracy, these are the agent's other capabilities scored by their own evals (spam detection, action-item extraction, briefing quality). Report-only — they don't feed the aggregate above; see the per-capability gate thresholds under tests/fixtures/email/._
| Capability | Metric | Value |
|---|---|---|
| spam | precision | 0.1078 |
| spam | recall | 0.3333 |
| spam | f1 | 0.1629 |
| action_items | precision | 0.0000 |
| action_items | recall | 0.0000 |
| action_items | f1 | 0.0000 |
| briefing | approval | 0.0000 |
| briefing | must_include_recall | 0.0500 |
| briefing | faithful | 1.0000 |
| briefing | hallucination_free | 1.0000 |