Skip to main content
← Agent Hub

Email Triage

Experimental

GAIA email triage agent — read, triage, organize, and reply to Gmail/Outlook locally

Install with npm below, then drive it from your app. View on npm →

Install
Runs on: Windows x64Linux x64macOS (Apple Silicon)

About Email Triage

@amd-gaia/agent-email

npm version · contract SCHEMA_VERSION 2.3 · last updated 2026-07-10

Eval scorecard (v0.3.0): aggregate 83.4 / 100 — within-one-bucket acceptance accuracy (3-run mean, 95% CI [82.1, 84.7]) over the full 249-email labeled corpus (SCORECARD.md). Triage priority is ordinal, so the bar (#1437) credits exact-or-adjacent buckets — what users feel — not exact 4-way match (reported as a secondary, 0.77). The linked scorecard carries the full recipe, metrics + reported secondaries, run-to-run variance/CI, a per-category breakdown, the run environment, and a worked recomputation; EVALUATION.md is the companion guide — what's measured, the dataset, a worked example, and step-by-step reproduction.

Embed the GAIA email agent in your JS/TS app. It triages, organizes, replies to, and schedules from Gmail and Outlook — with every email body analyzed locally on AMD Ryzen AI via Lemonade. No message content is sent to a cloud LLM; local-only inference is enforced at startup.

This package is the client: a typed EmailClient, a build-time fetcher that downloads and SHA-256-verifies the right native binary for your platform, and helpers to spawn, health-check, and shut down the sidecar. It ships no Python — the agent runs as a frozen, self-contained REST sidecar your app launches and owns.

Using an AI coding assistant? This package ships a SKILL.md — load it into Claude Code (or similar) for a focused, copy-paste integration playbook.

What the agent does

  • Triage — classify each message (urgent / needs-response / FYI / promotional / personal), summarize a thread, and extract action items and phishing signals. Extracted action items also persist as a local task list linked back to the source message, de-duplicated on re-triage.
  • Organize — archive, label, move, mark read/unread — one message or in batches.
  • Reply & send — draft context-aware replies and forwards, then send — attachments included (schema 2.2): triage exposes attachment metadata, and draft/send accept base64 file payloads. In the agent tool loop it can also draft in the user's own voice (a local style profile learned from Sent mail, #1607), schedule a confirmed send for a future time, and snooze messages out of the inbox — agent-loop capabilities today, not yet on this package's REST surface.
  • Track — flag sent mail still awaiting a reply past a configurable window (follow-up tracking, #1606; detection only — it never auto-sends a nudge) and run a scheduled daily inbox briefing.
  • Calendar — detect meeting requests, flag conflicts, RSVP, and create events from an email.
  • Safe by construction — email bodies are treated as untrusted data, never instructions, and anything that leaves the mailbox (send, delete, RSVP) asks for confirmation.

Triage and draft analyze the content you pass and need only the local LLM. Reading or acting on a live mailbox (send, archive, label, calendar) goes through the Google or Microsoft connector (OAuth, configured in GAIA).

How it works

@amd-gaia/agent-email architecture — your Node code spawns and drives a frozen email-agent sidecar (127.0.0.1:8131) over HTTP, which runs triage inference on a local Lemonade Server; a browser/Electron renderer drives the same sidecar over HTTP via the ./client entry.

Three tiers, all on the user's machine — no cloud, no separate GAIA install:

  • Your app (Node) fetches and spawns the sidecar via the . entry and owns its lifecycle (shutdown() tears it down).
  • The sidecar is a frozen email-agent binary serving the email REST API — no Python on the host.
  • Lemonade Server is the one runtime dependency: the sidecar calls a local Lemonade for inference. With none reachable, triage returns HTTP 502.

Once it's running, any Node process can drive it over local HTTP. A browser or Electron renderer reaches it through your app's main process — the sidecar is same-origin only (no CORS), so a renderer can't call it cross-origin directly (see Browser / Electron).

Install

npm install @amd-gaia/agent-email
Corporate TLS: if install fails with UNABLE_TO_GET_ISSUER_CERT behind a proxy, use Node's system CA store: NODE_OPTIONS=--use-system-ca npm install (Node ≥ 22).

Quick start

import { fetchBinary, startSidecar, shutdown } from "@amd-gaia/agent-email";

// Build-time: download + SHA-256-verify the binary for this platform.
const { binaryPath } = await fetchBinary({ outDir: "resources" });

// Runtime: spawn -> wait for /health -> version-check.
const sidecar = await startSidecar({ binaryPath, port: 8131 });

// Drive it with 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);

await shutdown(sidecar); // kills the whole process tree

Triage many at once

triageBatch sends up to 100 emails or threads in a single request and returns a parallel results array (one entry per item, order-preserved). It's additive — the single triage() above is unchanged. Per-item failures are isolated, 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." },
    },
    {
      kind: "single",
      principal: { email: "me@example.com" },
      message: { message_id: "m2", from: { email: "promo@shop.example" },
        subject: "Sale", body: "Shop now." },
    },
  ],
});
for (const item of batch.results) {
  if (item.error) console.warn(`item ${item.index} failed: ${item.error.message}`);
  else console.log(`item ${item.index}:`, item.result!.category);
}

Browser / Electron renderer

The . entry uses Node built-ins, so it can't be bundled for a browser or an Electron renderer. The sidecar also serves same-origin only — it sends no CORS headers — so a renderer on a different origin (file://, a dev server, an app:// scheme) cannot call http://127.0.0.1:8131 directly; the browser blocks it. Don't design around a direct renderer→sidecar fetch.

Recommended (Electron): spawn and own the sidecar in your main process via the . entry, and expose triage/draft to the renderer over your own IPC. The renderer never touches the sidecar.

The browser-safe ./client entry (zero Node built-ins, so it bundles for a renderer) is for the case where the renderer does have a same-origin or app-proxied path to the sidecar:

import { EmailClient } from "@amd-gaia/agent-email/client";

// Only from a same-origin page, or behind a proxy you control — not a
// cross-origin fetch straight at 127.0.0.1:8131. Pass the sidecar's session
// token (see Authentication) — get it from your main process via IPC; the
// renderer never spawns the sidecar itself.
const client = new EmailClient({
  baseUrl: "http://127.0.0.1:8131",
  authToken, // from sidecar.authToken in the main process
});
const res = await client.triage({ payload: { /* … */ } });

Authentication

The sidecar binds 127.0.0.1 and can send mail as the user, so it authenticates its caller (#1706). This is separate from the draft→send confirmation_token, which binds a send to one exact message but does not identify who is calling.

  • Per-session bearer token. spawnSidecar / startSidecar mint a cryptographically-random token, hand it to the sidecar over the private GAIA_EMAIL_SIDECAR_TOKEN env channel, and bind it to sidecar.client. Every /v1/email/* request must carry Authorization: Bearer <token> or the sidecar returns HTTP 401. Using sidecar.client you get this for free; a client you construct yourself must pass authToken (read it from sidecar.authToken).
  • Host / Origin allowlist. A non-loopback Host header → 400 (DNS-rebinding); a non-loopback browser Origin403 (drive-by web page). No permissive CORS is ever sent.
const sidecar = await startSidecar({ binaryPath, port: 8131 });
sidecar.authToken; // the per-session token, if you need to forward it (e.g. IPC)
await sidecar.client.draft({ to: [{ email: "a@b.com" }], subject: "Re: x", body: "hi" });
// A raw client without the token is refused:
new EmailClient({ baseUrl: sidecar.baseUrl }).send(/* … */); // → HttpError 401

Liveness/version probes (/health, /version) and the HTML pages (/v1/email/spec, /v1/email/playground) are exempt from the token.

Prerequisites

The package fetches and spawns the binary, but it does not provision the model. Before any LLM call succeeds, the host needs:

  1. A running Lemonade Serverlemonade-server serve.
  2. The model pulled — via gaia init (installs Lemonade and downloads the default model, Gemma-4-E4B-it-GGUF).

On a fresh machine the binary boots fine, but the first triage returns HTTP 502 until Lemonade and the model are in place. health() is liveness-only — a green /health means the REST surface is up, not that triage will work.

To check actual readiness before your first triage, call client.init() (GET /v1/email/init, #1795) — it probes Lemonade reachability + version + the triage model and returns the InitResponse on both the ready (200) and not-ready (503) paths, so branch on .ready / read .hint instead of catching an error. As a client method it attaches the per-session bearer token for you (a raw fetch would have to add Authorization: Bearer ${sidecar.authToken} itself). POST /v1/email/init can then ask the running Lemonade to pull the model, streaming progress. See SPEC.mdReadiness vs liveness.

Interface

You drive everything through the typed EmailClientsidecar.client after startSidecar, or constructed directly against a running sidecar (see the renderer example above). Every non-2xx response throws HttpError (with status, url, bodyText) — never a silent null.

CallNeedsDoes
triage(req)Local LLM onlyClassifies the message you pass, summarizes it, and extracts action items + spam/phishing signals. No mailbox is read. Action items also persist to the sidecar's local task list, linked to the message_id and de-duplicated per message — the response shape is unchanged.
triageBatch(req)Local LLM onlySame as triage, but for an items array (1–100). Returns a parallel results array; per-item failures isolate (HTTP 200 can carry errored items — inspect results[].error).
search(req)A connected Gmail/Outlook mailboxSearches the connected inbox (read-only) by Gmail-style query/labels and returns message metadata — id, subject, sender, snippet, labels. No message is read in full or modified; no confirmation token needed.
prescan(req?)A connected Gmail/Outlook mailboxReads recent inbox messages and returns the triage-card envelope (urgent / needs-response / suggested-archive rows + an informational count). Read-only — nothing is archived, marked, or sent.
draft(req)Nothing externalProposes a reply (to is a list of { email } objects, not strings) and returns a single-use confirmation token. Optional attachments (schema 2.2): { filename, mime_type, content_base64 } each, ≤ 25 MB decoded — the token binds to their content digests.
send(req)A connected Gmail/Outlook mailbox + the tokenActually transmits the mail, attachments included. Must carry the exact attachment set the token was minted for — a swapped file or a smuggled extra is rejected with 403.
confirmAction(req)Nothing externalMints a single-use token for a destructive action ("archive" / "quarantine"), bound to that exact (action, message_id).
archive(req)A connected mailbox + the tokenRemoves the message from the inbox. Returns a batch_id undo handle.
unarchive(req)A connected mailboxRestores an archived message within the 30s window (ungated — pass the batch_id).
quarantine(req)A connected Gmail mailbox + the tokenApplies the GAIA_PHISHING_QUARANTINE label + archives a phishing message. Refuses is_phishing: false; Gmail-only (an Outlook mailbox → 400, since label-undo can't reverse a folder move).
unquarantine(req)A connected mailboxRestores a quarantined message's prior labels within the 30s window (ungated — pass the action_id).
listCalendarEvents(opts?)A connected mailbox + calendar scopeViews events on the primary calendar (read-only). Optional timeMin/timeMax RFC 3339 bounds; provider only when >1 account is connected.
previewCalendarEvent(req)Nothing externalMints a single-use confirmation token bound to a proposed event — the calendar analogue of draft.
createCalendarEvent(req)A connected mailbox + calendar scope + the tokenCreates the event. Requires the token from previewCalendarEvent; a missing/invalid token is rejected with 403 before any backend call.
respondToCalendarEvent(req)A connected mailbox + calendar scopeRSVPs accepted/declined/tentative to an existing invite.

triage, draft, confirmAction, and previewCalendarEvent are standalone — you can build and verify those flows with zero connector setup. The read-only search and prescan need a connected mailbox (they read the live inbox) but no confirmation token (503 with none connected, 400 with two). The mutating calls — send, archive, quarantine, and createCalendarEvent — are different on two counts: each requires a single-use confirmation token (call draft for send, confirmAction for archive/quarantine, or previewCalendarEvent for createCalendarEvent; a missing/invalid token is rejected with 403 before anything else), and each acts on the mailbox connected in GAIA on the host (under Settings → Connectors) — so even with a valid token, a headless server returns HTTP 503 until a mailbox is connected.

Scheduled daily briefing (#1608)

The sidecar can generate the prescan triage card on a daily schedule — no prompt, no caller. Off by default; opt in with env vars when launching:

const sidecar = await startSidecar({
  binaryPath,
  env: {
    GAIA_EMAIL_BRIEFING_ENABLED: "true",
    GAIA_EMAIL_BRIEFING_TIME: "08:00", // 24h local HH:MM (default 08:00)
    GAIA_EMAIL_BRIEFING_MAX_MESSAGES: "25", // 1–100 (default 25)
  },
});

Each run persists the email_pre_scan envelope with a generated_at stamp; pull the latest one from GET /v1/email/briefing (plain fetch — no client wrapper yet, so attach the per-session bearer token yourself). It returns 404 until the first scheduled run has happened, and an invalid env value fails sidecar startup loudly rather than guessing a schedule. archive/quarantine are reversible within a 30-second window via unarchive/unquarantine (which are not gated — they restore, never destroy); the calendar actions additionally need the account's calendar scope (a missing scope fails loud with 403 and the reconnect CTA). For a server integration, treat the standalone calls as your surface and handle the mailbox/calendar-mutating calls where a connector is available.

Lifecycle

startSidecar({ binaryPath, port }) runs spawn → health-check → version-check in one call and cleans up if any step fails (so a failed start never leaks a process). When you need finer control, the steps are exported individually:

HelperPurpose
fetchBinary(opts)Download + SHA-256-verify the platform binary (build time).
spawnSidecar(opts)Launch the binary on 127.0.0.1:<port> (default 8131).
waitForHealth(baseUrl)Poll /health until live; throws on timeout.
checkVersion(client)Throw if the sidecar's contract MAJOR differs from the client's.
shutdown(sidecar)Kill the whole process tree.

As of SCHEMA_VERSION 2.2 this package exposes inbox search (read-only), the archive / phishing-quarantine mailbox actions (+ their undo), calendar view / create / respond, and attachments (#1542: triage exposes metadata, draft/send accept files). The remaining mailbox actions (label, move, mark read/unread) are part of the full agent but not yet exposed through this package — see SPEC.md for the complete surface.

Stateful agent surface (/v1/email/agent/*, 0.4.0)

The typed client above is stateless — each call analyzes the payload you pass, with no memory and no conversation. As of 0.4.0 the sidecar also hosts a session-scoped, conversational agent (/v1/email/agent/*) that runs the full EmailTriageAgent — memory, personalization, and every agent tool — over HTTP, streaming each turn back as Server-Sent Events. Create a session, POST /v1/email/agent/query to run a turn, approve gated tools via /confirm-tool, and toggle memory at runtime via /memory. This is the surface the Agent UI uses; it is not wrapped by the typed client yet — drive it with fetch. See SPEC.md and SKILL.md for the endpoint table and a streaming example.

Running in production

This is a long-lived local resource, not a per-request one. Wire it like this:

  • Fetch at build time, not per request. fetchBinary does network I/O and SHA-256 verification — run it once in your build / postinstall step, ship the verified binary, then resolveBinaryPath at runtime.
  • Spawn once at boot. Call startSidecar during app startup and hold the Sidecar handle for the process lifetime. Never spawn per request.
  • Keep concurrency low. The sidecar accepts concurrent HTTP requests, but inference runs on a single local Lemonade model slot — parallel triage calls queue behind one another. Cap inflight calls (a small queue / concurrency limit) rather than fanning out.
  • Pick a port you own. port is yours to choose; if it's already taken, startSidecar fails its health wait (HealthTimeoutError) — run with DEBUG=agent-email to see the bind error.
  • Supervise it yourself. The package does not restart a crashed sidecar. If you need resilience, watch sidecar.child for exit and re-startSidecar.
  • Cleanup is automatic. By default the sidecar is reaped when your process goes away — normal exit, process.exit(), an uncaught exception, or SIGINT/SIGTERM/SIGHUP (Ctrl+C) — so the frozen binary's detached child never leaks and never keeps holding its port. No signal wiring required. For a graceful, awaited shutdown that lets in-flight work finish, call shutdown(sidecar) (the automatic reaper is a SIGKILL backstop on top of it):
const sidecar = await startSidecar({ binaryPath, port: 8131 });
// … use sidecar.client …
await shutdown(sidecar); // optional & graceful; auto-cleanup also runs on exit

To own the lifecycle yourself, pass autoCleanup: false and wire the signals. (A hard SIGKILL of your process can't be intercepted by anyone, so the safest guarantee is the default in-process reaper.)

const sidecar = await startSidecar({ binaryPath, port: 8131, autoCleanup: false });
const reap = (code = 0) => shutdown(sidecar).finally(() => process.exit(code));
for (const sig of ["SIGTERM", "SIGINT"]) process.once(sig, () => reap(0));

Errors & retries

Every non-2xx throws HttpError (status, url, bodyText); a network failure or timeout surfaces as HttpError with status === 0 (not an HTTP code).

FailureMeaningRetry?
HttpError 502 (from triage)Lemonade is down or the model is cold/missingYes — transient; back off and retry
HttpError 0 (network/timeout)Sidecar not reachable / crashedYes — after re-spawning the sidecar
HttpError 403 (from send / archive / quarantine)Missing/invalid confirmation tokenNo — call draft (for send) or confirmAction (for archive/quarantine) first and pass its token
HttpError 503 (from send / archive / quarantine)No mailbox connected on the hostNo — configuration, not transient
HttpError 409 (from unarchive / unquarantine)Undo window expired or handle unknownNo — past the 30s window; restore manually in the mail client
HttpError 400Bad request, 2+ mailboxes connected without a provider, or quarantine with is_phishing: falseNo — fix the call/config
HealthTimeoutError / VersionMismatchError / IntegrityErrorStartup faults (port taken, contract mismatch, bad binary)No — fail fast at boot

Playground

One command fetches the binary, starts the sidecar, and opens the playground:

npx @amd-gaia/agent-email playground

It serves a zero-setup, localhost-only page at http://127.0.0.1:8131/v1/email/playground — a stack-health check, live triage and draft, and a Connectors panel to connect Gmail/Outlook and try a live send. It's served same-origin under a strict CSP, so the page can only ever reach your local sidecar: triage and draft stay on-device, while a send transmits to your mail provider by definition. Press Ctrl+C to stop (--port <n> to bind elsewhere, --no-open to skip auto-opening the browser, --out <dir> to choose where the binary is cached).

Requirements

  • Platforms: Windows x64, Linux x64, macOS Apple Silicon (darwin-arm64).
  • Memory: 8 GB RAM — sized for the default local model (Gemma-4-E4B-it-GGUF) running in Lemonade, not the sidecar itself.
  • Lemonade: the sidecar calls a local Lemonade endpoint (default http://localhost:13305); cloud hosts are rejected at startup.
  • Footprint: one self-contained native binary (~30–45 MB depending on platform), no Python runtime. (Model weights are downloaded and managed separately by Lemonade.)

Troubleshooting

SymptomCause & fix
triage() returns HTTP 502Lemonade isn't running or the model isn't pulled. Start it (lemonade-server serve) and provision the model (gaia init). Not a bug in this package.
/health is green but triage failshealth() is liveness-only — it doesn't check Lemonade or the model. Use GET /v1/email/init for real readiness (it probes Lemonade + the model; 503 + hint when not ready).
npm install fails with UNABLE_TO_GET_ISSUER_CERTCorporate TLS proxy. Reinstall with Node's system CA store: NODE_OPTIONS=--use-system-ca npm install (Node ≥ 22).
require(...) throws ERR_REQUIRE_ESMThe package is ESM-only. Use import, or await import("@amd-gaia/agent-email") from CommonJS.
Sidecar process lingers after exitAuto-cleanup reaps it on exit/crash/signal by default; a lingering sidecar means autoCleanup: false (call shutdown(sidecar) yourself) or a hard SIGKILL of the host.
IntegrityError / VersionMismatchError on startThe downloaded binary failed its SHA-256 check, or its contract MAJOR differs from the client. Clear resources/ and re-fetchBinary, and make sure the package version matches the binary.

Set DEBUG=agent-email for verbose spawn/fetch/health logs (on stderr).

Reference

  • SPEC.md — full API, lifecycle helpers, connectors, module format, and platforms.
  • CAPABILITY_MATRIX.md — code-derived, CI-guarded inventory of every capability surface (internal tools, REST, MCP, eval coverage).
  • SKILL.md — load into Claude Code (or similar) for a step-by-step integration playbook.
  • SCORECARD.md — latest eval results (score, metrics, per-category breakdown, run environment).
  • EVALUATION.md — evaluation guide: what's measured, the dataset, a worked example, and how to reproduce the scorecard.
  • CHANGELOG.md — version history.

License

Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.

SPDX-License-Identifier: MIT