{
  "schema_version": 1,
  "generated_at": "2026-08-13T23:23:34.892Z",
  "agents": [
    {
      "id": "email",
      "name": "Email Triage",
      "description": "GAIA email triage agent — read, triage, organize, and reply to Gmail/Outlook locally",
      "category": "productivity",
      "latest_version": "0.6.0",
      "icon": "mail",
      "language": "python",
      "author": "AMD",
      "security_tier": "verified",
      "download_size_bytes": 43495936,
      "tags": [
        "email",
        "gmail",
        "calendar",
        "triage"
      ],
      "tools_count": 66,
      "models": [
        "Gemma-4-E4B-it-GGUF"
      ],
      "min_gaia_version": "0.22.0",
      "permissions": [],
      "deprecated": false,
      "requirements": {
        "min_memory_gb": 8,
        "min_disk_gb": 0,
        "min_context_size": 0,
        "platforms": [
          "win-x64",
          "linux-x64",
          "darwin-arm64"
        ],
        "npu": "optional",
        "gpu_vram_gb": 0
      },
      "readme": "# @amd-gaia/agent-email\n\n[![npm version](https://img.shields.io/npm/v/@amd-gaia/agent-email?label=version)](https://www.npmjs.com/package/@amd-gaia/agent-email)\n\nSorts your Gmail or Outlook (personal or work Microsoft 365) inbox into\nurgent / needs-reply / FYI, pulls out action items, and drafts replies — all\nrunning **locally on your machine**, so no email content ever leaves it.\n\nYou embed it in a JavaScript or TypeScript app. Every email is analyzed on-device\nby a local AI model (via AMD's Lemonade runtime); message content is never sent to\na cloud service, and that's enforced when the agent starts up.\n\n> Using an AI coding assistant? This package ships a\n> [`SKILL.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SKILL.md)\n> — load it into Claude Code (or similar) for a copy-paste integration playbook.\n\n## What it can do\n\n- **Triage** — sort each message into urgent, needs-reply, FYI, promotional, or\n  personal; summarize a thread; and extract the action items and any phishing or\n  spam signals.\n- **Organize** — archive, label, and move messages, one at a time or in batches.\n- **Reply & send** — draft context-aware replies (optionally in your own writing\n  style, learned locally from your Sent mail) and send them — with attachments.\n  Anything that leaves your mailbox asks for confirmation first.\n- **Calendar** — spot meeting requests, flag conflicts, RSVP, and create events\n  from an email.\n- **Track follow-ups** — flag replies you're still waiting on past a window you\n  choose (it points them out; it never nudges anyone for you).\n- **Spot what's waiting on you** — flag inbound mail that asks you directly for\n  a reply, decision, or meeting time, with a sender, subject, and how long it's\n  been sitting there. Requires a real back-and-forth already in that thread —\n  a bare question mark, a convincing cold-outreach email, or having emailed\n  the sender before in some unrelated thread never qualifies on its own.\n- **Daily briefing** — generate a morning inbox summary on a schedule, no prompt\n  needed.\n- **Plain-language requests** — describe what you want done (\"find today's\n  urgent mail and archive the promotions\") and the agent chains the steps\n  itself, streaming progress; runs are cancellable mid-way.\n\n## Prerequisites\n\nA local AI model has to be running before triage or drafting works:\n\n1. Install and start it with **`gaia init`** (downloads the default model) and\n   **`lemonade-server serve`**.\n2. On a fresh machine the agent still starts, but triage won't return results\n   until that local model is up. Call `client.init()` to check readiness.\n\nYou'll need about 8 GB of RAM for the default model, and one of: Windows x64,\nLinux x64, or macOS Apple Silicon.\n\n## Install\n\n```bash\nnpm install @amd-gaia/agent-email\n```\n\n> **Behind a corporate proxy?** If install fails with `UNABLE_TO_GET_ISSUER_CERT`,\n> reinstall with `NODE_OPTIONS=--use-system-ca npm install` (Node ≥ 22).\n\n## Quick start\n\nTriage one email — get back a category and a summary:\n\n```ts\nimport { fetchBinary, startSidecar, shutdown } from \"@amd-gaia/agent-email\";\n\n// Once, at build time: download and verify the agent for your platform.\nconst { binaryPath } = await fetchBinary({ outDir: \"resources\" });\n\n// At startup: launch the local agent and hold onto the handle.\nconst sidecar = await startSidecar({ binaryPath, port: 8131 });\n\nconst res = await sidecar.client.triage({\n  payload: {\n    kind: \"single\",\n    principal: { email: \"me@example.com\" },\n    message: {\n      message_id: \"m1\",\n      from: { name: \"Sarah Chen\", email: \"sarah@example.com\" },\n      subject: \"Prod incident follow-up\",\n      body: \"Please review the report and reply by Friday.\",\n    },\n  },\n});\n\nconsole.log(res.result.category, res.result.summary);\n// e.g. \"NEEDS_RESPONSE  Sarah asks you to review the report and reply by Friday.\"\n\nawait shutdown(sidecar);\n```\n\nOr hand the agent a plain-language request and watch it work — `query()` streams\ntyped progress events (`status`, `tool_call`, `tool_result`, …) and ends with the\nanswer; `cancelQuery()` stops a run mid-way:\n\n```ts\nconst runId = crypto.randomUUID(); // yours to mint — it's also the cancel handle\nfor await (const ev of sidecar.client.query({\n  query: \"Find today's urgent mail and archive the promotions.\",\n  run_id: runId,\n  context: [],\n})) {\n  if (ev.type === \"status\") console.log(ev.message);\n  // The agent can ask you something mid-run — answer it and the same stream\n  // carries on. This is how it sets up mailbox access without sending you away.\n  if (ev.type === \"needs_input\") {\n    await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));\n  }\n  if (ev.type === \"final\") console.log(ev.answer); // last event\n}\n```\n\nTriage classifies and drafts using only the local model — no mailbox connection\nneeded. Reading or acting on a live inbox (search, send, archive, calendar) uses\nthe **Google or Microsoft connector** (personal or work Microsoft 365) you set up in GAIA under\n*Settings → Connectors* — or, from 2.6, that the agent sets up **with you, in the\nconversation**: if it has no usable mailbox it works out which of the four\nproblems it has and offers to fix that one, asking through `needs_input` rather\nthan returning a command for you to go run (#2469). Connecting Google still\nrequires your own OAuth client ID and secret; the agent says so up front. (Inside the full GAIA Agent UI daemon the connector token\nis forwarded to the agent by the daemon — sidecar contract 2.5, #2154; a standalone\nintegrator using this package is unaffected and resolves the mailbox from the local\nGAIA connector store.)\n\nMail is **required**; calendar is **requested but optional** — consent asks for\nboth up front so you're never prompted twice, but declining calendar (or\nconnecting with an older, mail-only grant) still leaves you with a fully\nworking triage/reply/send mailbox. Calendar tools fail loudly, naming the\nexact scope to add, only when you actually try to use one.\n\nWant to try it without writing code? Run `npx @amd-gaia/agent-email playground`\nfor a local page to test triage, drafting, and a live send.\n\n## Personal mailbox vs work mailbox\n\nThe package ships six built-in **skills** — short playbooks the agent can load into\nits own thinking — grouped into a `personal` set (inbox triage, newsletter digests,\ntrip itineraries) and a `work` set (inbox triage, meeting scheduling, action items,\nescalation).\n\n**They are switched off in this release.** Nothing is loaded at launch and a\npersonal and a work mailbox get identical behaviour, because there is no eval\nevidence yet that the skills improve triage. The skill files stay in the package,\ninert, and the agent's full context window goes to your mail instead of to skill\ntext.\n\nNothing for you to do or change: there is no set to pin, and passing\n`--skill-set` / `GAIA_EMAIL_SKILL_SET` fails at startup saying so rather than\nquietly doing nothing. Re-enabling is a change inside the agent, not in your\nintegration. Full detail in\n[`SPEC.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SPEC.md).\n\n## How it works\n\nThree pieces, all on your own machine — no cloud, no separate GAIA install:\n\n- **Your app** launches the agent and owns its lifetime.\n- **The agent** is a single self-contained program (~30–45 MB, no Python) that\n  serves a small local API.\n- **The local model** does the actual thinking; the agent talks to it over your\n  machine's local network only.\n\nFull architecture, the complete API, authentication, and every endpoint are in\n[`SPEC.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SPEC.md).\n\n## How good is the triage?\n\nScores **84.53 / 100** on a labeled benchmark inbox — see the **Scorecard** tab (or\n[`SCORECARD.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SCORECARD.md))\nfor the full breakdown, and the **Evaluation** tab for how it's measured.\n\n## Reference\n\n- [`SPEC.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SPEC.md) — full API, authentication, lifecycle, connectors, and platforms.\n- [`SKILL.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SKILL.md) — integration playbook for AI coding assistants.\n- [`SCORECARD.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SCORECARD.md) / [`EVALUATION.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/EVALUATION.md) — eval results and how they're measured.\n- [`CHANGELOG.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/CHANGELOG.md) — what's new in each version.\n\n## License\n\nCopyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.\n\nSPDX-License-Identifier: MIT\n",
      "changelog": "# Changelog\n\nWhat's new in `@amd-gaia/agent-email`, in plain language. For the technical detail\nbehind any entry — API shapes, endpoints, and version semantics — see\n[`SPEC.md`](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SPEC.md).\n\n## [0.6.0] - 2026-08-12\n\n- **Work Microsoft 365 mailboxes are now supported alongside Gmail and personal\n  Outlook.** A work/school Microsoft account (Entra ID) can now be connected and\n  triaged the same way as Gmail or a personal Outlook.com mailbox — connecting,\n  onboarding copy, and mailbox selection all recognize the new `microsoft_work`\n  connector (#2629, schema 2.14).\n- **Compatibility note:** if your app or its users refer to a mailbox as\n  \"office365\", \"o365\", \"m365\", \"microsoft 365\", \"entra\", or \"exchange\", that\n  now names the new work connector instead of personal Outlook. Before this\n  release those words all pointed at the personal `microsoft` connector — the\n  only Microsoft connector that existed. Someone with only a personal Outlook\n  connected who uses one of these words is now told to connect the work\n  mailbox instead of being served from their personal one. Plain `microsoft` /\n  `outlook` / `outlook.com` / `hotmail` / `live` are unaffected.\n- **`query()` can now carry a conversation forward.** `EmailQueryRequest`\n  gains an optional `session_id`: set it once and reuse it on every turn of\n  a conversation (e.g. `crypto.randomUUID()`), and the sidecar resolves the\n  SAME agent each time instead of a throwaway one per call — so a\n  follow-up referring to something an earlier turn surfaced has something\n  to resolve against. Leave it unset and nothing changes (#2829, schema\n  2.12).\n- **A scoped \"anything suspicious in my inbox?\" question no longer dumps the\n  full triage report (#2900).** `PreScanItem` gains `is_phishing`/`is_spam`\n  (boolean, default `false`) — a flag previously readable only inside a\n  prose `why` string is now a real field — and `EmailPreScanResult` gains\n  `suspicious`/`suspicious_total` (schema 2.13): the phishing/spam-flagged\n  subset of `actionable`, captured before its own cap so a flagged message\n  ranked past it is never silently dropped from the count.\n- **The agent's built-in skills ship switched off, so the whole context window\n  goes back to your mail.** The six skills below are still in the package, but\n  no set is active and none of them loads: nothing yet shows they make triage\n  better, and an active set was consuming most of the room the agent had for\n  bulk-triage results. A personal and a work mailbox get identical behaviour\n  again, and `--skill-set` / `GAIA_EMAIL_SKILL_SET` now fail at startup saying\n  there are no sets to pick rather than quietly doing nothing. Nothing else\n  changes — same endpoints, same tools, same permissions.\n- **One inbox triage card instead of two that disagreed.** Asking the agent\n  to triage your inbox used to draw two summary boxes from two separate scans\n  at different depths — one might say \"nothing needs you\" while the other,\n  five lines below, listed a message needing review. The card is now one\n  worklist (`needs_you`, schema 2.11) built from a single scan: up to five\n  things that genuinely need you, each tagged with what to do (reply, decide,\n  check, or a carried-over action item) and how old it is. `NeedsYouItem` /\n  `BulkSummary` are new on `EmailPreScanResult` — `BulkSummary` carries a\n  count plus the id(s) of the test(s) that filtered it, for an app that\n  wants to render why a message didn't make the list, rather than a bare\n  unauditable number; nothing existing was removed or renamed (#2743).\n  `NeedsYouItem.detail` is also new — reserved for a couple of lines of\n  real substance per row (the question actually asked, the meeting time\n  actually proposed, the deadline actually quoted) — but ships **always\n  empty** in this release: the\n  per-item extraction pass that would fill it was implemented and then\n  withdrawn before merge so it could ship on a firm timing budget rather\n  than risk a slow scan; a follow-up will populate it.\n- **Reconnecting your mailbox with no flags — the exact command GAIA's own\n  error message told you to run — could silently wipe your permissions\n  instead of fixing them.** A bare `gaia connectors connect google` (or the\n  same reconnect from a first-time self-repair conversation) used to fall\n  back to identity-only sign-in scopes whenever it wasn't told exactly what\n  to ask for, overwriting a working mail-plus-calendar connection with\n  nothing usable. That path now fails with a clear, copy-pasteable command\n  instead of guessing, and every surface — the CLI, the Agent UI, this\n  package's own connector setup, and the in-chat self-repair flow — now asks\n  for the same scopes so none of them can quietly narrow what another one\n  granted. Separately, calendar access is now clearly **optional**: a mailbox\n  missing only calendar permission still triages, drafts, and sends normally,\n  and calendar tools name the exact scope to add instead of taking the whole\n  mailbox down with them (#2730).\n- **The agent can now tell you which inbound mail is waiting on your reply —\n  not just which of your own messages went unanswered.** Previously the agent\n  could only flag sent mail nobody replied to; a colleague's \"did you get a\n  chance to look at this? can we meet Thursday?\" was invisible to it. It now\n  also flags inbound messages that ask directly for a reply, a decision, or a\n  meeting time — but only when there's real corroboration that it's genuine\n  correspondence (an existing back-and-forth in the thread, or a sender\n  you've emailed before). A question mark or a convincing-looking sender name\n  is deliberately not enough on its own — both show up constantly in\n  marketing and cold-outreach mail, and a false \"someone is waiting on you\"\n  costs more trust than a missed one.\n- **Triggering an autonomy cycle while autonomy is switched off now tells you\n  so, instead of quietly reporting nothing happened.** `POST\n  /v1/email/agent/autonomy/run` used to return the same \"nothing to do\"\n  response whether autonomy was disabled or had genuinely run and found\n  nothing — there was no way to tell which. It now returns an error naming\n  the current level and how to turn autonomy back on.\n- **Asking the agent to draft a reply or forward now actually drafts one,\n  instead of asking you to write it.** The agent would correctly find the\n  right email, then ask you to supply the reply or forward text — the exact\n  thing you'd asked it to write. Nothing told it that composing the message\n  was its own job (that instruction only existed once it had learned your\n  writing style from enough sent mail, so it never applied to a fresh\n  mailbox). It now writes the reply or forward itself from the original\n  message plus whatever you specified (length, tone, points to hit), and\n  still uses your exact wording when you hand it over yourself. Sending is\n  unchanged — every draft still needs your confirmation before it goes out\n  (#2524).\n- **Six built-in skills, and the groundwork for treating a personal mailbox\n  differently from a work one — shipped switched off.** The skills (`personal`:\n  inbox triage, newsletter digests, trip itineraries; `work`: inbox triage,\n  meeting scheduling, action items, escalation) and the machinery that picks a\n  set from the kind of Microsoft account you connected are in the package, but\n  no set is declared, so none of it is active — see the first entry above.\n  Turning it on is a change inside the agent; nothing in your integration\n  changes either way (#2466).\n- **Opt-in preview: small on-device models can now decide phishing flags and\n  triage categories instead of keyword rules.** Turn it on with\n  `GAIA_EMAIL_USE_SLM=true` on the sidecar (or `use_slm=True` in config).\n  A compact classifier — running on the same local Lemonade server as the chat\n  model, so nothing leaves the machine — makes the phishing call, and a second\n  one labels the triage category, taking that decision away from the bigger LLM\n  (which is still consulted for the spam verdict when the rules can't settle it).\n  It is experimental, so it stays off unless you turn it on. If the\n  models are unavailable for any reason, triage falls back to exactly the\n  previous behavior. No API shape changed.\n- **A trashed email is recoverable any time it's still in Trash — not just for\n  a few seconds after you delete it.** The only way back used to be a short\n  undo window right after trashing; miss it, and the agent told you the\n  message was stuck, even though Gmail actually keeps Trash for 30 days. It\n  can now find the message and restore it any time it's still there. The\n  agent also stopped calling a trashed message \"archived\" in its confirmation\n  — trash and archive recover differently, so it now says exactly what it did.\n- **The agent no longer claims it can permanently delete email — because it\n  can't.** Permanently deleting a Gmail message needs a scope GAIA\n  deliberately never asks for (it would hand over delete access to your whole\n  mailbox for one rare action), so every attempt failed. Asked directly, the\n  agent used to say it could do it anyway. Now it says plainly it can only\n  move mail to Trash.\n- **Full autonomy now does more than archive, explains its decisions, and can be undone.**\n  Previously the proactive `earn_trust`/`full` loop only ever archived low-signal mail —\n  every other reversible action the trust model already declared (marking mail read,\n  starring, labeling) was unreachable, the run report never said *why* a message was held\n  back, and there was no way to undo an auto-executed action other than the archive-only\n  `undo_archive_batch` tool. Now: FYI mail is marked read instead of archived (it stays\n  visible, just no longer sits unread); `POST /v1/email/agent/autonomy/run` returns a new\n  `decisions[]` field explaining every candidate's outcome and reason, including \"held back\n  for confirmation\" and \"held back — provider-flagged IMPORTANT\"; and a new\n  `POST /v1/email/agent/autonomy/undo` reverses any auto-executed action and records the\n  correction against its trust scope, the same negative-feedback loop `undo_archive_batch`\n  already gave archives. The destructive floor (send/forward/permanent-delete/RSVP/quarantine)\n  is unaffected — it was already inviolable and stays that way at every level (#2529).\n- **The agent sets up your mailbox itself, in the conversation.** Before, hitting\n  the email agent without a working mailbox produced an error and a shell command\n  to go run somewhere else — a dead end for anyone in a terminal or chat window.\n  It now works out *which* of the four problems it actually has (nothing\n  connected, credentials stopped working, a missing permission, or connected but\n  not allowed for this agent), says something specific about that one, and offers\n  to fix it right there. The connected-but-not-allowed case is fixed with no\n  browser at all. Connecting Google still needs your own OAuth client ID and\n  secret — the agent now tells you that up front with a link, instead of failing\n  later (#2469).\n  Integrators: `can_answer_questions` is only understood from 2.6 onward, so\n  check `version()` before sending it — an older sidecar rejects the unknown\n  field outright rather than ignoring it.\n- **New: the agent can ask you a question mid-run** — schema 2.6, additive. A new\n  non-terminal SSE event `needs_input` carries a question, 2-4 labelled options\n  each with a description of what choosing it does, and a free-text escape;\n  `respondToQuery(runId, requestId, value)` (`POST\n  /v1/email/query/{run_id}/respond`) delivers the answer and the ORIGINAL stream\n  resumes. An unanswered question ends the run with an error rather than hanging.\n  Approvals (`needs_confirmation`) are unchanged: still terminal, still\n  deny-by-default (#2469).\n- **Work/school Outlook (Microsoft 365 / Entra ID) mailboxes now work, not just\n  personal Outlook.com.** The Microsoft connector previously signed in only\n  against the `consumers` tenant, so a corporate Microsoft 365 account was\n  rejected before GAIA ever saw a token. It now uses the `common` tenant by\n  default (both account types), overridable with `GAIA_MICROSOFT_TENANT`. A new\n  zero-setup device-code sign-in connects without an Azure app registration or\n  loopback redirect — from the CLI (`gaia connectors connect microsoft --device`)\n  or the Agent UI (a **Sign in with a code** button on the Microsoft tile). No\n  email-agent tool changed — the existing Outlook backend just reaches more\n  mailboxes (#1275).\n- **In the GAIA daemon deployment, the sidecar no longer holds long-lived OAuth\n  secrets.** Previously a sidecar read the mailbox connection straight from the\n  machine keyring. Now, under the Agent UI daemon, the daemon (the custody home)\n  owns the refresh token and forwards only **short-lived access tokens** to a new\n  sidecar intake (`POST /v1/connections/{provider}`, plus `GET`/`DELETE`) — the\n  sidecar never sees the refresh token, the daemon re-forwards on expiry and\n  withdraws on revocation, and only connectors **granted** to the email agent are\n  forwarded. Added as sidecar contract **2.5** (additive over 2.4; every 2.4\n  request/response shape is unchanged). This is **daemon-managed** — a standalone\n  integrator using this package is unaffected and keeps resolving the mailbox from\n  the local GAIA connector store exactly as before (#2154).\n- **The agent's autonomy commands now work against the shipped binary.** `gaia\n  email autonomy status/set-level/pause/resume/run/undo/kill/trust` call REST\n  routes (`/v1/email/agent/autonomy*`) that did not exist in any previously\n  published binary — a sidecar installed from 0.5.0 or earlier 404'd on every\n  one of them, with nothing telling the caller why. All eight subcommands now\n  reach a real route and get back a 200, or a correct 409 when autonomy is\n  off (#2894).\n- **Muting a sender no longer buries their genuinely urgent mail as\n  promotional.** The category override for a muted (low-priority) sender was\n  unconditional — every message from that sender was force-classified\n  PROMOTIONAL regardless of content, which also made it an autonomy\n  auto-archive candidate with no confirmation. \"I don't care about most of\n  this sender's mail\" is not \"this specific message is never urgent\" —\n  category is now always decided by content; muting only affects ordering\n  (#2774).\n- **Scanning a real Gmail inbox no longer fails outright on a rate limit.** A\n  scan batching 100 messages in one request reliably tripped Gmail's\n  per-user concurrency limit, and a single 429 discarded the other 99\n  already-successful results with the whole scan failing on\n  `CONNECTOR_ERROR`. Batches are now chunked to a measured-safe size, a 429\n  is retried with backoff, and a message still rate-limited after retrying\n  is dropped individually and reported — not thrown away with everything\n  else (#2727).\n- **A counting question about a long-bodied sender no longer overflows the\n  model's context and comes back empty.** Searching messages defaulted to\n  fetching full bodies, and a \"how many emails from X in the last two\n  weeks?\" question against a verbose sender could blow the context window\n  before the model produced an answer. The search now defaults to metadata\n  only (subject/from/date/snippet, no body) — a counting or listing question\n  never needed the body — cutting the result size by roughly an order of\n  magnitude (#2782).\n- **A fresh conversation's first inbox listing or search could overflow the\n  NPU profile's context window before you got a reply.** `listInbox` /\n  `searchMessages` capped each message's body independently but never\n  checked the COMBINED size of the result — a realistic 25-message inbox\n  built a response over the NPU profile's 32K-token budget on the very\n  first call, and the overflow sometimes surfaced as a silently truncated\n  count (10 requested, 8 returned) rather than an error. Both now shrink\n  every message's body together to fit the active device's budget; a\n  request too large even at the smallest usable body size fails with an\n  actionable error naming the limit instead of quietly returning less than\n  asked for (#2514).\n- **Calendar answers can no longer invent attendee names or invite\n  confirmations that aren't in the mailbox.** Asked \"did anyone send me a\n  meeting invite?\", the agent could answer \"yes\" with no message,\n  mutation, or attachment behind it — a real `organizer` field was\n  sometimes narrated as \"sent you an invite.\" Calendar listing and\n  conflict checks now surface each event's real `attendees` (an event with\n  none normalizes to `[]` instead of the field being omitted), and two new\n  checks catch an invite or attendee claim the tool result doesn't support\n  before it reaches you. Scoped to calendar attendee/invite claims only —\n  not a general claim about hallucination elsewhere (#2766).\n- **A reply, draft, or send could report failure even after it actually\n  succeeded, and retrying made it worse.** A transient local bookkeeping\n  write, unrelated to the real Gmail/Outlook call, could fail right after\n  the message was actually sent or the draft actually created — and that\n  bookkeeping failure was surfaced as if the whole action had failed.\n  Retrying then hit an already-consumed draft id. `draft()`/`send()`/forward\n  now report success whenever the real mail action succeeded regardless of\n  that local write, and retrying an already-sent draft gets a plain \"already\n  sent\" instead of a generic error (#2908).\n- **The triage card is now assembled from the scan's own data, not retyped\n  by the model.** The categorized breakdown the model used to compose\n  freehand — numbering, message counts, addresses — could drift from the\n  scan that produced it: a number pointing at the wrong message, an item\n  repeated or dropped, or a bare item count with no list at all. The card is\n  now rendered directly from the same `needs_you` data the scan already\n  computed — a template fill, not a generation — so a reference like\n  `archive 3` always names the message actually shown as 3; the model still\n  writes the opening sentence and nothing else. On a 55-item real inbox this\n  completed in under a minute end to end (#2858).\n- **The launch secret no longer sits in the sidecar's environment.** The\n  per-session auth token used to be handed to the sidecar as a bare environment\n  variable, visible to any local process that can inspect process environments.\n  A 0.6.0+ sidecar spawned by the GAIA daemon now receives it as an owner-only\n  (`0600`) file that is removed when the sidecar stops; the env channel\n  (`GAIA_EMAIL_SIDECAR_TOKEN`) keeps working for older binaries and for the npm\n  lifecycle, exactly as before.\n- **Asking \"what's on my calendar?\" no longer digs up years-old meetings.**\n  Listing calendar events without a date range used to return the oldest\n  instances of recurring series — events from years ago narrated as if they\n  were this week. An unbounded listing now defaults to the next 30 days\n  (starting now); passing explicit `time_min`/`time_max` bounds works exactly\n  as before.\n- **The plain-language agent loop is now part of the typed client.** 0.5.0's\n  streaming endpoint required hand-rolled `fetch` + SSE parsing; now\n  `client.query()` returns an async iterator of typed events (`status`, `token`,\n  `tool_call`, `tool_result`, `needs_confirmation`, `final`, `error` — plus a\n  visible `unknown` placeholder for event types added by a newer agent, never a\n  silent drop), and `client.cancelQuery(runId)` stops a run mid-way. You mint\n  `run_id`, so a run is cancellable from the instant you send it. A stream that\n  breaks mid-run throws instead of looking like success.\n- **The client now speaks contract 2.4.** `SCHEMA_VERSION` moved 2.3 → 2.4\n  (additive — every 2.3 request/response shape is unchanged). The startup\n  version handshake accepts any 2.x sidecar, so a 2.3-pinned client keeps\n  working against a 2.4 sidecar exactly as before; only the new `query()` /\n  `cancelQuery()` calls need a 2.4 (0.5.0+) agent binary.\n- **On NPU-capable machines, triage now runs on the NPU by default.** When\n  you haven't pinned a specific model, the agent checks whether the\n  Lemonade Server it's talking to has an AMD NPU and the NPU-optimized\n  model ready — if so, it uses that automatically for lower power draw;\n  otherwise it keeps using the existing GPU/CPU model, exactly as before.\n  `GET /v1/email/init` reports which one was picked. Accuracy/throughput\n  numbers for the NPU model aren't published yet — that measurement lands\n  in a follow-up release.\n\n## 0.5.0\n\n- **Ask the agent in plain language.** Send a free-form request (\"find today's\n  urgent mail and archive the promotions\") to a new streaming endpoint and the\n  agent works through it step by step with its tools, reporting progress as it\n  goes; a run can be cancelled mid-way. Anything that would actually send mail\n  still stops and routes you to the explicit draft-and-confirm flow. Not yet\n  wrapped by the typed client — call the endpoint directly (see `SPEC.md`).\n- **Iterate on the agent from source.** New `connectSidecar({ baseUrl })` attaches\n  the client to a server you run yourself, and `gaia-agent-email serve --reload`\n  (or `npx @amd-gaia/agent-email dev`) runs the agent's Python source with hot\n  reload — so you can fix a triage/draft bug and re-test in seconds instead of\n  waiting for a new binary. Additive — your existing calls are unchanged, and\n  shipping to production just swaps `connectSidecar` for `startSidecar`. Exports\n  the new `ConnectOptions` / `AttachedSidecar` types. Full walkthrough in\n  `SPEC.md` → *Fast local iteration*.\n- **Docs rewritten for humans.** The README, this changelog, and the evaluation\n  guide now lead with what the agent does in plain language; the deep technical\n  reference lives in `SPEC.md`.\n\n## 0.4.0\n\n- **Reply drafts come back as a ready-to-fill scaffold** (recipient + subject)\n  instead of an always-empty body. Triage sorts and summarizes but doesn't write\n  the reply text — so compose the body yourself and send it with `draft()` +\n  `send()`.\n- **The local agent now checks who's calling it.** Because it can send mail as you,\n  it now requires a private per-session key that your app gets automatically — so\n  another program on your machine, or a web page in your browser, can't quietly\n  reach it to draft or send.\n- **Draft in your own voice.** The agent can learn your writing style locally from\n  your Sent mail (top greetings, sign-offs, typical length — never the raw\n  content, and it stays on your device) and match it when drafting replies.\n- **Better spam detection that works beyond Gmail.** Spam is now judged by the\n  content itself, on-device, so it works for Outlook and any mailbox — not just\n  Gmail's own spam label.\n- **Follow-up tracking.** The agent can flag threads where you're still waiting on\n  a reply past a window you choose (default 3 days), most overdue first. It points\n  them out; it never sends a nudge for you.\n- **Schedule a send or snooze a message.** Ask the agent to \"send this tomorrow at\n  9am\" or push a message out of the inbox until a chosen time. Both are confirmed\n  up front and can be cancelled before they fire.\n- **Attachments.** Triage now sees attachments, and drafts and sends can include\n  files (up to 25 MB each). When you confirm a send, the attachments are locked to\n  what you approved — nothing can be swapped in or added after.\n- **Action items become a task list.** Items pulled from an email are saved\n  locally and linked back to the message, so re-triaging never creates duplicates.\n- **Daily inbox briefing.** The agent can produce a morning inbox summary on a\n  schedule with no prompt. Off by default; turn it on when you launch the agent.\n- **A readiness check before your first triage.** Ask the agent whether the local\n  model is actually up and get a clear yes/no with a hint on what to fix, instead\n  of hitting an error on the first request.\n- **Runtime memory toggle.** Turn the agent's memory (inbox profiling, learned\n  preferences) on or off without restarting it.\n- **Hold an ongoing conversation.** Beyond one-shot requests, the agent can be\n  driven as a stateful, streaming chat over its local API — the same thing the\n  GAIA Agent UI uses to power its email experience.\n\n## 0.3.0\n\n- **The eval score now measures what users feel.** Triage priority is ranked\n  (urgent > needs-reply > FYI), so the score credits an exact *or* one-off bucket\n  — a \"needs-reply\" called \"urgent\" is close, not a total miss. It measures 83.4 /\n  100, and every release has to clear the bar to ship.\n- **Triage many emails in one call.** New `triageBatch()` handles up to 100 emails\n  or threads at once instead of one request each; each item succeeds or fails on\n  its own, so check every result, not just the overall status.\n- **Search your inbox, view your calendar, and file messages — through the\n  package.** Read-only inbox search, calendar view/create/RSVP, and archive plus\n  phishing-quarantine (both reversible within 30 seconds) are now available to\n  apps embedding the agent, matching what the GAIA Agent UI can do.\n- **Inbox pre-scan.** Get the triage card (urgent / needs-action /\n  suggested-archive rows) for your recent inbox in one call.\n\n## 0.2.5\n\nSending from a mailbox connected with view-only permissions now gives a clear\nerror naming the missing mail-send permission, instead of a confusing server\nerror. The playground's connect flow now asks for send access up front, so\nconnect → send just works.\n\n## 0.2.4\n\nFirst fully-published release of this feature set. Ships the per-platform agent\ndownloads plus this client. (The combined all-platforms download is temporarily\ndisabled — it exceeded a hosting size limit; the individual downloads work.)\n\n## 0.2.3\n\nRe-cut of 0.2.2 after a publishing-infrastructure fix — the first fully-published\nrelease of this feature set.\n\n## 0.2.2\n\nPublishing-reliability fix so the download and npm publish complete. No change to\nhow the agent behaves.\n\n## 0.2.1\n\n- **One-command playground.** `npx @amd-gaia/agent-email playground` fetches the\n  agent, starts it, and opens a browser page to try it — no setup.\n- **Automatic cleanup.** The agent now shuts itself down when your app exits,\n  crashes, or is interrupted, so it never lingers holding a port.\n\n## 0.2.0\n\n- **Browser-safe client.** A separate `@amd-gaia/agent-email/client` import works\n  in a browser or Electron renderer (the main import stays Node-only, since it\n  downloads and launches the agent).\n\n## 0.1.0\n\n- Initial release: the typed email client, the build-time downloader, and the\n  helpers to launch and shut down the local agent.\n",
      "spec": "# @amd-gaia/agent-email — Technical reference\n\nDetailed reference for `@amd-gaia/agent-email`. For a quick start, see\n[`README.md`](./README.md); for an AI-assisted integration walkthrough, see\n[`SKILL.md`](./SKILL.md). This client pins `SCHEMA_VERSION` **2.14**, matching\nthe sidecar's current contract — every schema bump since 2.4 has been additive\n(see `contract.py`'s own per-version changelog for the full log), so nothing\nhere is a breaking upgrade for an existing integration.\n\n## Architecture\n\nThree tiers, all on the user's machine:\n\n- **Your app** (a Node process) depends on this package, fetches the sidecar\n  binary, and spawns it via the `.` entry. It does **not** attach to an\n  already-running GAIA instance — the package **launches and owns its own\n  sidecar** and tears it down on `shutdown()`.\n- **The sidecar** is a self-contained, PyInstaller-frozen `email-agent` binary\n  serving the email REST endpoints. **No Python is required on the host.**\n- **Lemonade Server** is the one external runtime dependency: the sidecar calls a\n  **local** Lemonade for the actual LLM inference. With none reachable,\n  `POST /v1/email/triage` returns HTTP 502.\n\nOnce a sidecar is running, any Node process can drive it over local HTTP. The\nsidecar serves **same-origin only and sends no CORS headers**, so a browser or\nElectron renderer reaches it through the app's main process, not a direct\ncross-origin fetch — see [Browser / Electron renderer](#browser--electron-renderer-client).\n\n## Concurrency & deployment\n\nRun **one sidecar per host**, spawned once at process start — not one per request.\nIt accepts concurrent HTTP requests, but inference runs on a **single local\nLemonade model slot**, so parallel `triage` calls serialize behind one another;\ncap inflight calls on your side rather than fanning out. The package does not\nsupervise or restart a crashed sidecar — watch `sidecar.child` `exit` and\nre-`startSidecar` if you need resilience. It **does** auto-reap the sidecar when\nyour process exits, crashes, or is interrupted (default `autoCleanup`); call\n`shutdown` for a graceful, awaited stop, or pass `autoCleanup: false` to manage\nsignals yourself.\n\n## Authentication\n\nThe sidecar binds `127.0.0.1` and can send mail as the user, so it authenticates\nits **caller** (#1706) — distinct from the draft→send `confirmation_token`, which\nbinds a send to one exact message but does not identify the caller.\n\n- **Per-session bearer token.** `spawnSidecar` / `startSidecar` mint a\n  cryptographically-random token, pass it to the sidecar over the private\n  `GAIA_EMAIL_SIDECAR_TOKEN` env channel, and bind it to `sidecar.client`. Every\n  `/v1/email/*` request must carry `Authorization: Bearer <token>` → otherwise\n  **401**. Construct-your-own clients pass `authToken` (from `sidecar.authToken`);\n  `generateSessionToken()` is exported for advanced flows. Exempt: `/health`,\n  `/version`, `/v1/email/health`, `/v1/email/version`, `/v1/email/spec`,\n  `/v1/email/playground`.\n  Sidecar binaries **0.6.0+** also accept `GAIA_EMAIL_SIDECAR_TOKEN_FILE` — the\n  path of a `0600`, owner-only file holding the token, so the secret never sits\n  in the process environment (readable via `/proc/<pid>/environ` / `ps eww`).\n  The GAIA daemon delivers the secret this way and treats the env channel as a\n  logged, deprecated compatibility leg for older binaries; a set path var whose\n  file is missing or empty fails sidecar startup loudly. The npm lifecycle\n  currently uses the env channel.\n- **Host allowlist** — non-loopback `Host` → **400** (DNS-rebinding).\n- **Origin rejection** — non-loopback browser `Origin` → **403** (drive-by page).\n  Non-browser clients send no `Origin` and are unaffected. No CORS is ever sent.\n\nRunning the sidecar by hand without `GAIA_EMAIL_SIDECAR_TOKEN` disables the token\ncheck (local development only, logged loudly); the Host/Origin controls still\napply. The shipped product always spawns with a token.\n\n## REST API\n\nThe code-derived, CI-guarded inventory of every capability surface (internal\nagent-loop tools, REST, MCP, eval coverage) is\n[`CAPABILITY_MATRIX.md`](https://github.com/amd/gaia/blob/main/hub/agents/email/python/CAPABILITY_MATRIX.md) —\nthe canonical cross-surface reference.\n\nEvery `/v1/email/*` request also requires the per-session bearer token (see\n[Authentication](#authentication)); the \"Auth\" column below covers the additional\nper-endpoint connector/token requirements. `EmailClient` is a typed wrapper over\nthe sidecar's HTTP surface. Methods:\n`triage`, `triageBatch`, `search`, `prescan`, `draft`, `send`, `confirmAction`,\n`archive`, `unarchive`, `quarantine`, `unquarantine`, `listCalendarEvents`,\n`previewCalendarEvent`, `createCalendarEvent`, `respondToCalendarEvent`, `health`,\n`version`, `emailHealth`, `emailVersion`, `spec`, `openapi`. `health`/`version` hit the **root** routes (the standalone\nsidecar); `emailHealth`/`emailVersion` hit the **`/v1/email`-scoped** mirrors (for\nwhen the router is mounted on a product app). Every non-2xx response throws\n`HttpError` (carrying `status`, `url`, `bodyText`) — never a silent empty/null\nresult.\n\n| Endpoint | Client method | Auth | What it needs |\n|----------|---------------|------|---------------|\n| `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. |\n| `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). |\n| `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). |\n| `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. |\n| `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. |\n| `POST /v1/email/draft` | `draft()` | **Standalone** | Nothing external — wraps your `(to, subject, body, attachments)` and returns a single-use confirmation token. |\n| `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. |\n| `POST /v1/email/confirm` | `confirmAction()` | **Standalone** | Nothing external — mints a single-use token for `\"archive\"`/`\"quarantine\"`, bound to that exact `(action, message_id)`. |\n| `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`. |\n| `POST /v1/email/unarchive` | `unarchive()` | **Connector** | A connected mailbox + the `batch_id`. **Ungated** (it restores). Window expired / unknown handle → `409`. |\n| `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). |\n| `POST /v1/email/unquarantine` | `unquarantine()` | **Connector** | A connected mailbox + the `action_id`. **Ungated** (it restores prior labels). Window expired / unknown → `409`. |\n| `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. |\n| `POST /v1/email/calendar/events/preview` | `previewCalendarEvent()` | **Standalone** | Nothing external — mints a single-use confirmation token bound to the event (calendar analogue of `draft`). |\n| `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. |\n| `POST /v1/email/calendar/events/respond` | `respondToCalendarEvent()` | **Connector** | A connected calendar. RSVPs `accepted`/`declined`/`tentative` to an existing invite. |\n| `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 `QueryEvent`s. Host mints `run_id`; context is pushed. See \"Canonical agent-loop query\" below. |\n| `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`. |\n| `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. |\n| `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.\" |\n| `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`. |\n| `GET /health` | `health()` | **Standalone** | Liveness only — does **not** check Lemonade/model. |\n| `GET /version` | `version()` | **Standalone** | Version negotiation. |\n| `GET /v1/email/health` | `emailHealth()` | **Standalone** | Router-scoped liveness (mounted-on-app case). |\n| `GET /v1/email/version` | `emailVersion()` | **Standalone** | Router-scoped version. |\n| `GET /v1/email/spec` | `spec()` | **Standalone** | Human-readable HTML endpoint page. |\n| `GET /openapi.json` | `openapi()` | **Standalone** | Machine-readable OpenAPI document. |\n\n`GET /docs` (Swagger UI) and `GET /redoc` are also served but are browser UIs, not\nwrapped by the client. **The standalone surface is `triage`, `draft`, `confirmAction`,\nand `previewCalendarEvent`** (plus the probes) — integrate and verify those flows with\nzero connector setup. The read-only `search` and `prescan` read the live inbox (a\nconnected mailbox, but no token); the mutating calls (`send`, `archive`, `quarantine`,\n`createCalendarEvent`) and the reversals/calendar views need a connected mailbox whose\nrelevant scope was granted.\n\n### Canonical agent-loop query (`POST /v1/email/query`, schema 2.4)\n\nThe v2 keystone (#2016): a natural-language request in, the agent reasons and chains its\ntools into a multi-step workflow, and the **seven canonical Server-Sent Event types** out\n(the frozen `/query` wire contract). Every v2 front-door (the Agent UI relay, the\n`gaia email` CLI, `gaia api`) relays to **this one loop**. Request body:\n\n```jsonc\n{\n  \"query\": \"Triage my inbox and draft replies to anything urgent.\",\n  \"run_id\": \"0f9c2b6e-2c4a-4b1e-9d6a-1e2f3a4b5c6d\", // host-minted UUIDv4\n  \"context\": [ { \"role\": \"user\", \"content\": \"earlier turn\" } ], // pushed slice\n  \"model\": \"Gemma-4-E4B-it-GGUF\",   // optional\n  \"provider\": \"lemonade\",           // optional; only 'lemonade' (local-only agent)\n  \"max_steps\": 20                    // optional\n}\n```\n\nThe **host mints `run_id`**, so the run is cancellable from the instant the request is\nsent (`POST /v1/email/query/{run_id}/cancel`, which stops tool execution between steps).\nContext is **pushed** in the body — the sidecar stays stateless. The response is\n`text/event-stream`; each `data:` line is one canonical event discriminated on `type`:\n\n| `type` | Payload | Meaning |\n|---|---|---|\n| `status` | `{ message }` | progress narration (also folds `step`/`thinking`/`plan`) |\n| `token` | `{ delta }` | an incremental chunk of assistant text |\n| `tool_call` | `{ tool, args }` | the agent is invoking a tool |\n| `tool_result` | `{ tool, render?, data }` | a tool returned; `render` names a typed card |\n| `needs_confirmation` | `{ run_id, action, summary }` | a gated step is awaiting approval — **terminal** under the stateless model |\n| `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) |\n| `final` | `{ answer, usage? }` | terminal — the assistant's answer |\n| `error` | `{ detail, status }` | terminal — an actionable failure, surfaced verbatim |\n\nThe stream ends with **exactly one `final` or `error`**.\n\n**Confirmation (stateless stub, epic decision D1):** a step that needs approval (a\ndestructive/external tool such as `send_now`) emits `needs_confirmation` and then the run\nends with a `final` refusal pointing at the deterministic fixed-function route — mint a\ntoken via `draft()`/`POST /v1/email/draft`, then `send()`/`POST /v1/email/send`.\nServer-side resume is not wired yet, so `confirm_url` is omitted.\n\n**Mid-run questions (schema 2.6, #2469):** set `can_answer_questions: true` on the\nrequest only when your UI can render a question and answer it — it defaults to\n`false`, and a caller that leaves it off gets an immediate refusal rather than a run\nparked on a question it cannot show. **Check the peer first:** the sidecar's request\nmodel is strict, so sending this field to a sidecar below 2.6 is a `422` on every\nrequest. Read `version()` (`apiVersion`) and omit the field below 2.6 — the\ninstalled sidecar is often older than the client you built against. the agent can ask the user something *while\nit runs* — most importantly to set up or repair mailbox access instead of ending the run\nwith a shell command for the user to go run elsewhere. It emits `needs_input` carrying\nthe question, 0-4 mutually exclusive `options` (each with a `label` to pick and a\n`description` of what picking it does) and an `allow_free_text` escape. The run PAUSES on\nthe open stream; `respondToQuery(runId, requestId, value)` delivers the answer and the\nsame stream continues. `sensitive: true` means the answer is a credential — mask it and\nnever log it. An unanswered question ends the run with an `error` after\n`timeout_seconds`; it never hangs.\n\n**Typed client:** `query()` wraps the stream as an async iterator of typed `QueryEvent`s\n(discriminated on `type`); `cancelQuery(runId)` wraps the cancel route and\n`respondToQuery(runId, requestId, value)` the resume route.\n\n```ts\nconst runId = crypto.randomUUID(); // host-minted (spec §2.3); also the cancel handle\nfor await (const ev of sidecar.client.query({\n  query: \"Triage my inbox and draft replies to anything urgent.\",\n  run_id: runId,\n  context: [], // pushed transcript slice; [] for a fresh conversation\n})) {\n  switch (ev.type) {\n    case \"status\":       spinner.text = ev.message; break;\n    case \"token\":        answer += ev.delta; break;\n    case \"tool_call\":    console.log(`→ ${ev.tool}`, ev.args); break;\n    case \"tool_result\":  renderCard(ev.render, ev.data); break;\n    case \"needs_confirmation\": /* run then ends with a final refusal (D1) */ break;\n    case \"needs_input\":  // the run is PAUSED here — answer and keep iterating\n      await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));\n      break;\n    case \"final\":        console.log(ev.answer); break;        // terminal\n    case \"error\":        console.error(ev.detail); break;      // terminal, verbatim\n    default:             console.warn(\"unsupported event\", ev); // additive future type\n  }\n}\n// Mid-run, from anywhere that knows runId:\n// await sidecar.client.cancelQuery(runId);\n```\n\nSemantics: exactly one terminal `final`/`error` ends the iterator — a terminal `error`\nevent is **yielded** (its `detail` is the actionable message), while transport/contract\nfailures **throw** (`HttpError` on a non-2xx; `QueryStreamError` on a non-SSE response,\na malformed event, or a stream that closes with no terminal event). An event `type`\noutside the canonical vocabulary is yielded as `{ type: \"unknown\", eventType, raw }` — surfaced,\nnever silently dropped (contract §7). The client's `timeoutMs` bounds time-to-first-response\nonly; a healthy run streams as long as the agent works (pass an `AbortSignal` via\n`query(req, { signal })` to abort the transport — and also call `cancelQuery` so the\nsidecar stops the loop, not just the socket).\n\n### Stateful agent surface (`/v1/email/agent/*`, 0.4.0)\n\nEverything above is **stateless** — each call analyzes the payload you send, with no\nmemory and no agent loop. The sidecar also hosts a **session-scoped, conversational\nagent** so a host can drive the full `EmailTriageAgent` (memory, personalization, and\nevery agent tool) over HTTP instead of importing it in-process. This is the surface the\nAgent UI uses to back its email experience with the packaged sidecar. It is **not wrapped\nby the typed npm client yet** — call it directly (e.g. `fetch`) or via the Agent UI.\nDistinct from `/v1/email/query` above: `/agent/*` is session-scoped (server-held memory +\nhistory), while `/query` is stateless with a host-minted `run_id` and pushed `context` and\nemits the canonical event vocabulary.\n\n| Endpoint | Notes |\n|---|---|\n| `POST /v1/email/agent/session` | Create/reset a session (`{ session_id, reset? }`) → `{ created, memory }`. Builds the agent (surfaces failures early). |\n| `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**. |\n| `POST /v1/email/agent/confirm-tool` | Approve/deny a gated tool the run is blocking on (`{ session_id, approved }`). |\n| `POST /v1/email/agent/cancel` | Cooperatively cancel the in-flight run. |\n| `DELETE /v1/email/agent/session/{id}` | Evict a session + tear down its agent. |\n| `GET /v1/email/agent/session/{id}/history` | Conversation so far (`turns[]`, oldest first). |\n| `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. |\n| `GET /v1/email/agent/memory/{id}` | Memory status without changing it. |\n| `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. |\n| `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**. |\n| `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). |\n| `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. |\n\nSessions are in-process and single-tenant (the sidecar hosts one user's agent); one turn\nruns at a time per session. Memory uses FAISS locally; embeddings still go over Lemonade\nHTTP, so the frozen binary stays free of torch/transformers.\n\n**Full autonomy (earn-trust).** At `earn_trust` the agent auto-executes only *reversible*\nactions — today `archive` (promotional/spam mail) and `mark_read` (FYI mail: useful context\nstays visible, but doesn't sit unread) — and only where your explicit preferences sanction it\n(a low-priority sender, or a category defaulted to archive) or a sender/category has crossed\nthe trust bar (`autonomy_trust_min_samples` decisions at ≥ `autonomy_trust_threshold`\naccuracy); everything else is proposed. The destructive floor — send, forward,\nRSVP, quarantine — **always requires confirmation, at every level**. There is no\npermanent-delete tool: Gmail gates real permanent delete behind a full-mailbox\nscope GAIA never requests, so the agent only ever offers reversible Trash.\nUndoing an auto-action — via `POST .../autonomy/undo`, or the conversational\n`undo_archive_batch` tool for a batch archive — feeds the trust ledger as a correction (a\nnegative outcome), so trust ratchets *down* on a mistake; positive-outcome accrual that would\nlet a scope cross the bar through earned trust is not yet wired. See\n`docs/plans/email-full-autonomy.mdx`.\n\n### Mailbox actions (archive / quarantine, schema 2.1)\n\n`archive` and `quarantine` mutate the live mailbox, so each is gated on a single-use\ntoken exactly like `send` — but minted by `confirmAction` (not `draft`), bound to the\n`(action, message_id)`. A token for one action/message cannot authorize a different\none. Both are reversible inside the 30s undo window:\n\n```ts\n// Archive (gated) → undo within the window (ungated):\nconst { confirmation_token } = await client.confirmAction({\n  action: \"archive\",\n  message_id: \"msg-123\",\n});\nconst { batch_id, post_archive_id } = await client.archive({\n  message_id: \"msg-123\",\n  confirmation_token,\n});\n// post_archive_id is the id valid NOW — Outlook mints a new one on the folder move.\nawait client.unarchive({ batch_id }); // restores to inbox; 409 if the window lapsed\n\n// Quarantine a phishing message (Gmail-only; refuses is_phishing:false and Outlook), then undo by action_id:\nconst t = await client.confirmAction({ action: \"quarantine\", message_id: \"msg-9\" });\nconst q = await client.quarantine({\n  message_id: \"msg-9\",\n  is_phishing: true,\n  confirmation_token: t.confirmation_token,\n});\nawait client.unquarantine({ action_id: q.action_id });\n```\n\n### Calendar (view / create / respond, schema 2.1)\n\n> **Confirmation gating — deliberate asymmetry.** `send` and calendar **create**\n> are token-gated (a payload-bound `confirmation_token` from `draft` /\n> `previewCalendarEvent`; no/invalid token → `403`). Calendar **respond** (RSVP) is\n> intentionally **not** token-gated, even though the in-process agent treats\n> `accept_invite` / `decline_invite` as confirmation-tier tools. The contract draws\n> the line at irreversibility: `send` and `create` are externally visible and not\n> cleanly undoable, whereas an RSVP only sets your own response status on an existing\n> invite and can be changed by responding again. The REST caller (the Agent UI's\n> accept/decline controls) is the human-in-the-loop for that reversible action.\n\n### Agent-loop capabilities not on the contract\n\nSome agent capabilities run **only in the agent tool loop** (chat / Agent UI /\n`gaia email`) and have **no REST endpoint**, so this package's `EmailClient`\ncan't drive them — they reach hosts through the agent chat surface until routes\nland in a future schema bump:\n\n- **Scheduled send + snooze (#1609):** `schedule_send`, `snooze_message`,\n  `cancel_scheduled_job`, `list_scheduled_jobs`. A send is user-confirmed at\n  creation, persisted as a mailbox draft plus a one-shot job in the agent's\n  SQLite, and fired by the agent's scheduler at/after its time.\n- **Voice / style-matched drafting (#1607):** `build_voice_profile` samples the\n  user's Sent mail into a **local** style profile (top greetings / sign-offs,\n  typical length, contraction & exclamation rate — derived features only, never\n  raw content, stored on-device), and the agent's system prompt injects that\n  guidance every turn so drafted reply bodies come out in the user's own voice\n  instead of a neutral scaffold; `clear_voice_profile` forgets it. Read-only\n  over Sent mail — nothing remote is mutated.\n- **Follow-up tracking (#1606):** `check_followups` scans every connected\n  mailbox's Sent folder and flags threads whose latest message is still the\n  user's own outbound mail past a configurable window (default 3 days), most\n  overdue first. **Detection only** — it never sends a nudge (any send stays\n  confirmation-gated).\n- **Waiting-on-you detection (#2581):** `list_waiting_on_you` is the inbound\n  counterpart to `check_followups` — it scans every connected mailbox's inbox\n  for messages that ask directly for a reply, decision, or meeting time AND\n  sit in a thread with genuine back-and-forth already in it (multiple prior\n  exchanges, or one genuinely substantive prior message — a single one-line\n  prior contact is not enough on its own). Corroboration is deliberately\n  scoped to THIS thread's own history only — having emailed the same address\n  before, in some other thread, does not corroborate anything; \"waiting on\n  your reply\" means you're in a conversation and it's your turn, which a\n  one-off prior contact elsewhere doesn't establish. Precision-first by\n  design: a bare `?` or a human-looking sender name is never enough — both\n  are common in adversarial marketing mail — and a message the category\n  heuristic confidently calls promotional never qualifies regardless of\n  corroboration. A sender the user has told to stop contacting them\n  (address-normalized, so a plus-tagged variant can't dodge it) is\n  suppressed unconditionally. **Detection only**, read-only against the\n  mailbox.\n\nNone of these are on the REST/MCP contract, so none of them moves `SCHEMA_VERSION`.\n\n### Readiness vs liveness\n\n`health()` is **liveness-only** — a green `/health` means \"the REST surface is up,\"\n**not** \"triage will work.\" On a fresh machine the binary boots fine, but the first\n`triage` returns **HTTP 502** until a local Lemonade Server is running and the\nconfigured model is pulled.\n\nThe authoritative readiness signal is **`GET /v1/email/init`** (#1795): it probes the\nwhole triage stack — Lemonade reachable **and** version-compatible **and** the triage\nmodel downloaded — and returns `200` when ready, `503` when not, with an actionable\n`hint`. The **`init()`** client method wraps it — returning the `InitResponse` on\nboth the ready (`200`) and not-ready (`503`) paths (branch on `.ready`), and, like\nevery `EmailClient` method, attaching the per-session bearer token (#1706) for you.\nA raw `fetch` works too (the `InitResponse` type is exported) but must attach it:\n\n```ts\nconst r = await fetch(\"http://127.0.0.1:8131/v1/email/init\", {\n  headers: { Authorization: `Bearer ${sidecar.authToken}` },\n});\nconst init = (await r.json()) as import(\"@amd-gaia/agent-email\").InitResponse;\nif (!init.ready) throw new Error(init.hint ?? \"email agent not ready to triage\");\n```\n\n`POST /v1/email/init` is the companion provisioning verb: it asks the running Lemonade\nto pull the model and **streams** `text/plain` progress. It cannot install Lemonade\nitself (a host prerequisite) — if Lemonade is unreachable it returns `503` and pulls\nnothing.\n\n### Request shapes\n\nRecipients and senders are **address objects**, not bare strings:\n`{ email: string, name?: string }`. This applies to `triage`'s `message.from` and\n`principal`, and to `draft`/`send`'s `to` (a non-empty array of them). Passing a\nplain string for `to` is a `422` validation error.\n\n`draft` proposes a reply and mints a single-use `confirmation_token` bound to that\nexact message; `send` echoes it back. A full round-trip:\n\n```ts\nconst { draft, confirmation_token } = await client.draft({\n  to: [{ email: \"you@example.com\" }],\n  subject: \"Re: Prod incident\",\n  body: \"On it — fix lands today.\",\n});\n// `draft` is { to, subject, body, attachments }; the token authorizes exactly\n// this payload.\nconst sent = await client.send({ ...draft, confirmation_token });\nconsole.log(sent.sent_id);\n```\n\n#### Attachments (schema 2.2, #1542)\n\n`draft` and `send` accept an optional `attachments` array of\n`{ filename, mime_type, content_base64 }` (standard base64, ≤ 25 MB decoded\neach). Validation is fail-loud (`422` for bad base64, a malformed MIME type, an\nempty file, or oversize — never a silent drop), and the confirmation token\nbinds to each attachment's filename, MIME type, **and content digest**: a\n`send` whose attachment set differs in any way from the confirmed draft is\nrejected with `403`. Note the send payload carries the full `content_base64` —\nspread the *request* you drafted with, not the metadata-only `draft` echo, when\nattaching files:\n\n```ts\nconst req = {\n  to: [{ email: \"you@example.com\" }],\n  subject: \"Re: Prod incident\",\n  body: \"Report attached.\",\n  attachments: [{\n    filename: \"incident-report.pdf\",\n    mime_type: \"application/pdf\",\n    content_base64: reportB64,\n  }],\n};\nconst { confirmation_token } = await client.draft(req);\nconst sent = await client.send({ ...req, confirmation_token });\n// sent.attachments echoes [{ filename, mime_type, size_bytes }] — metadata only.\n```\n\nOutlook mailboxes cap each attachment at **3 MB** (the Graph simple-attach\nlimit) — a larger file fails the send loudly rather than being truncated.\n\n### Triage response shape\n\n`triage` returns `{ schema_version, request_kind, result }`. The `result`\n(`EmailTriageResult`) is what you route on:\n\n| Field | Type | Notes |\n|-------|------|-------|\n| `category` | `\"URGENT\" \\| \"NEEDS_RESPONSE\" \\| \"FYI\" \\| \"PROMOTIONAL\" \\| \"PERSONAL\"` | The five buckets — **uppercase wire strings** (`res.result.category === \"URGENT\"`). |\n| `is_spam`, `is_phishing` | `boolean` | Independent signals (a message can be neither, either, or both). |\n| `summary` | `string` | Plain-text summary of the message/thread. |\n| `action_items` | `ActionItem[]` | Each `{ description, due_hint?, type?: \"text\" \\| \"link\", url? }`; may be empty. |\n| `suggested_action` | `\"reply\" \\| \"none\" \\| \"archive\"` | `\"reply\"` for URGENT/NEEDS_RESPONSE, `\"archive\"` for PROMOTIONAL, else `\"none\"`. |\n| `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. |\n| `usage` | `TriageUsage \\| null` | LLM token/latency metrics; `null` on the heuristic-only path. |\n| `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). |\n\nThe full request/response types are exported from the package (`src/types.ts`) for\nexact field-level reference.\n\n### Action-item task persistence (additive, #1605)\n\nBeyond returning `action_items` inline, `triage` / `triageBatch` persist each\nextracted item as a task row in the sidecar's local SQLite\n(`~/.gaia/email/state.db`), linked back to the source via the request's\n`message_id` (or `thread_id` for a thread). Persistence is de-duplicated per\nmessage on the normalized description, so re-triaging the same message never\ncreates duplicate tasks. Results with no `message_id` are not persisted (no\nsource to link back to). This is a **side-effect only** — the wire response is\nbyte-for-byte what it was before; there is no read/complete task endpoint on\nthis contract yet (that surface arrives with GAIA's cross-agent task store,\namd/gaia#1521).\n\n### Batch triage shape (additive, #1887)\n\n`triageBatch` takes `{ schema_version?, items, context? }` where `items` is 1–100\n`EmailInput` objects (the same `SingleEmailInput` / `ThreadInput` shapes `triage`\naccepts, discriminated on `kind`), and `context` — when present — applies to **all**\nitems. It returns `{ schema_version, results }` with one `BatchItemResult` per item,\norder-preserved (1:1 with `items`):\n\n| Field | Type | Notes |\n|-------|------|-------|\n| `index` | `number` | 0-based position in the request `items` array. |\n| `result` | `EmailTriageResult \\| null` | Set when the item succeeded (same shape as `triage`'s `result`). |\n| `error` | `BatchItemError \\| null` | Set (with a `message`) when the item failed. Exactly one of `result` / `error` is set. |\n\n**HTTP 200 with every item errored is a valid response** — a per-item failure does\nnot fail the request, so always inspect each `results[].error`, never just the HTTP\nstatus. A `502` means Lemonade was unreachable before any item ran (the whole batch\nfails). The single `triage()` endpoint and its types are unchanged; `MAX_BATCH_SIZE`\nis exported for the 100-item cap (over-cap → `422`).\n\n### Inbox search shape\n\n`search({ query?, labels?, max_results? })` lists messages from the connected\nmailbox and returns `{ schema_version, query, count, messages, next_page_token }`.\nIt is **read-only** — no body is read in full, nothing is modified, no confirmation\ntoken is involved. Both `query` and `labels` are optional: a `query` searches **all\nmail** (Gmail search semantics), `labels` filter to those labels, and with **neither**\nit lists the INBOX. `max_results` is `1–100` (default `25`); each match is hydrated\nwith a per-message fetch, so the cap bounds that fan-out. To page, pass the\nresponse's `next_page_token` back as the request's `page_token`. Each `messages[]`\nitem:\n\n| Field | Type | Notes |\n|-------|------|-------|\n| `id` | `string` | Provider message id (opaque) — pass to the agent/triage path to read in full. |\n| `thread_id` | `string \\| null` | Provider thread id. |\n| `subject` | `string` | Subject line. |\n| `from` | `string` | Raw `From` header (e.g. `\"Sarah Chen <sarah@example.com>\"`) — a **string**, not an address object, unlike triage's `from`. |\n| `to` | `string` | Raw `To` header. |\n| `date` | `string` | Raw `Date` header. |\n| `snippet` | `string` | Provider-supplied short preview. |\n| `label_ids` | `string[]` | Label ids on the message. |\n\n```ts\nconst { messages } = await client.search({ query: \"is:unread\", max_results: 20 });\nfor (const m of messages) console.log(m.subject, \"—\", m.from);\n```\n\n## Lifecycle helpers\n\n`startSidecar(opts)` does spawn → `waitForHealth` → `checkVersion` in one call and\nshuts down on any failure so a failed start never leaks a process. For finer\ncontrol, the steps are exported individually:\n\n- `fetchBinary(opts)` → download + verify + install; returns `{ binaryPath, sha256, cached, ... }`.\n- `resolveBinaryPath({ resourcesDir })` → locate a fetched binary (throws `BinaryNotFoundError` if absent).\n- `spawnSidecar({ binaryPath, host?, port?, extraArgs? })` → spawn with `--host 127.0.0.1 --port <p>` (default port **8131**).\n- `waitForHealth(baseUrl, { timeoutMs })` → poll `/health`; throws `HealthTimeoutError` on timeout (never assumes ready).\n- `checkVersion(client, { expectedApiVersion })` → throws `VersionMismatchError` if the sidecar's apiVersion **MAJOR** differs (a higher MINOR is accepted).\n- `verifySha256(buf, expected, label)` → throws `IntegrityError` on mismatch.\n- `shutdown(sidecar)` → kill the **whole process tree** (`taskkill /F /T` on Windows; detached process-group kill on POSIX). The default auto-reaper does the same on process exit/crash/signal, so only a hard `SIGKILL` of the host can still orphan the child.\n- `connectSidecar({ baseUrl, authToken?, timeoutMs?, healthTimeoutMs?, verifyVersion?, expectedApiVersion?, signal? })` → **attach mode**: `waitForHealth` + (default) `checkVersion` against a server this package did **not** spawn, returning an `AttachedSidecar` (`{ host, port, baseUrl, client, authToken? }` — no `child`). Spawns nothing and owns no lifecycle, so there is nothing to `shutdown()`. Pass an `AbortSignal` as `signal` to 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).\n\n### Fast local iteration (dev mode)\n\nThe published flow fetches and spawns a **frozen** binary — there is no source to\nedit when you hit a bug. To iterate on the agent, run its **Python source** and\nattach this client instead. The frozen binary is that source frozen (PyInstaller\nfreezes `packaging/server.py`, a thin re-export of `gaia_agent_email.server`), so\nthe `/v1/email/*` contract is byte-for-byte identical — **only the base URL\ndiffers from production.**\n\n```bash\npip install -e hub/agents/email/python     # editable: your edits take effect live\ngaia-agent-email serve --reload            # source server, auto-reload, token off for dev\n```\n\n```ts\nimport { connectSidecar } from \"@amd-gaia/agent-email\";\nconst dev = await connectSidecar({ baseUrl: \"http://127.0.0.1:8131\" });\nawait dev.client.triage({ payload: { /* … */ } });\n// edit Python → auto-reload → re-run. `npx @amd-gaia/agent-email dev` launches the\n// serve process for you (`--python <path>` to use a specific venv).\n```\n\nThe `serve` CLI (`gaia_agent_email.server:main`) accepts `--host`, `--port`\n(rejects the reserved 4001), `--reload` (import-string app + watches the package\ndir; add `--reload-dir` for your core checkout), `--dev` (implies `--reload`),\n`--skill-set <name>` (accepted but currently unusable — the agent declares no\n[skill sets](#skill-sets-2466), so any value errors at startup), and\n`--print-openapi`. Running without `GAIA_EMAIL_SIDECAR_TOKEN` disables the caller\ntoken (local dev only, logged loudly); Host/Origin protection still applies.\nAuto-reload resets in-process `/v1/email/agent/*` sessions — irrelevant to the\nstateless `triage`/`draft`/`send` surface.\n\n## CLI\n\n```bash\nnpx @amd-gaia/agent-email playground          # fetch + run the sidecar, open the playground\nnpx @amd-gaia/agent-email fetch --out resources\nnpx @amd-gaia/agent-email version             # show manifest + current platform\nnpx @amd-gaia/agent-email help\n```\n\n`playground` is the zero-to-running shortcut: it `fetchBinary`s into a temp cache\n(`--out` to override), `startSidecar`s on `--port` (default 8131), opens the default\nbrowser to `/v1/email/playground` (`--no-open` to skip), and runs until Ctrl+C.\nThe command owns the sidecar lifecycle itself (`autoCleanup: false`) and shuts it\ndown on `SIGINT`/`SIGTERM`/`SIGHUP` or on any startup error. Lemonade still has to\nbe running for live triage — the page itself reports if it isn't.\n\n`fetch` is the supported, build-time path. It resolves\n`${process.platform}-${process.arch}`, downloads that platform's artifact from the\nbase URL in `binaries.lock.json`, **verifies its SHA-256 against the lock and fails\nloudly on any mismatch**, writes it to `--out`, and `chmod +x`'s it on POSIX.\n\n| Flag | Meaning |\n|------|---------|\n| `--out <dir>` | Resources dir to write the verified binary into (**required**) |\n| `--base-url <url>` | Override the download base URL (defaults to the lock's `baseUrl`) |\n| `--platform <key>` | Override platform key (e.g. `linux-x64`); default is the host |\n| `--force` | Re-download even if a verified binary already exists |\n\n**SHA-256 is mandatory.** There is no \"use it anyway\" path — a corrupt, truncated,\nor tampered download is rejected before it can ever be spawned, and the bad file is\nnot left on disk.\n\n## Connectors & auth\n\nAn endpoint that works on the **content you pass in the request** is **standalone**\n— it needs nothing but the local Lemonade LLM. An action that **reads from or acts\non the live Gmail/Outlook mailbox or calendar** requires the **Google or Microsoft\nconnector** (OAuth), configured in GAIA under *Settings → Connectors*.\n\n**Mail is required; calendar is requested but optional (#2730).** Every connect\npath (GAIA's Agent UI, the CLI, and this sidecar's own `/configure` route) asks\nfor the full mail + calendar scope union up front, so accepting everything at\nonce never means a second consent round-trip. But only the mail scopes\n(`gmail.modify`/`gmail.send` on Google, `Mail.ReadWrite`/`Mail.Send` on\nOutlook) gate whether the mailbox works at all — a connection that declined\ncalendar, or was granted before calendar scopes existed, still triages, drafts,\nand sends. Calendar tools (`listCalendarEvents`, `createCalendarEvent`,\n`respondToCalendarEvent`) are the only ones that require the calendar scopes,\nand they fail loudly — naming the exact missing scope and the reconnect\ncommand — rather than silently no-opping. This is the request/enforce split:\nwhat's *asked for* at consent time is wider than what's *required* to mint a\nworking token.\n\n`send` resolves its OAuth token from the **local GAIA connector store**\n(`gaia.connectors`) on the host — `EmailSendRequest` has **no `access_token`\nfield** (`provider` is only a routing hint). There is **no way to pass or forward a\nconnection through this package's client API**, so connector-backed calls only work\non a machine where the mailbox is already connected in GAIA. Triage and draft, which\nneed no connector, work anywhere.\n\n### OAuth forward-out (GAIA daemon deployment, sidecar contract 2.5)\n\nIn the **GAIA Agent UI daemon** deployment (not this standalone client), the\ndaemon is the custody home for OAuth: it owns the long-lived refresh token and\nforwards **short-lived access tokens** to the sidecar's intake — `POST\n/v1/connections/{provider}` (with `GET /v1/connections` and `DELETE\n/v1/connections/{provider}`), added additively as sidecar contract **2.5** (#2154).\nThe sidecar answers mailbox calls with the forwarded token and **never receives\nthe refresh token or the OAuth client secret**; the daemon re-forwards on expiry\nand withdraws on revocation/uninstall. Forwarding honors the per-agent grant model\n— only connectors granted to the email agent are forwarded, and a\nmissing/expired/scope-short credential is a loud, actionable error, never a silent\nempty token.\n\nThese routes are **daemon-managed**: a standalone integrator using this package\ndoes not call them, and the \"no way to forward through the client API\" rule above\nis unchanged. A sidecar boots into forwarded mode only when the daemon sets the\nprivate `GAIA_EMAIL_FORWARDED_CREDENTIALS` env channel on spawn; otherwise it uses\nthe local connector store exactly as before.\n\nAs of `SCHEMA_VERSION` 2.2 this package's REST API exposes the read-only inbox\n**search** and **pre-scan** (`search` / `prescan`), the **archive** and\nphishing-**quarantine** mailbox actions plus their undo (`confirmAction` / `archive` /\n`unarchive` / `quarantine` / `unquarantine`), calendar **view / create / respond**\n(`listCalendarEvents` / `previewCalendarEvent` / `createCalendarEvent` /\n`respondToCalendarEvent`), and **attachments** on triage/draft/send (#1542).\nThe full GAIA email agent does more on the live mailbox\n(label, move, mark spam) and calendar (detect / conflicts); those remaining actions are\nconnector-gated by definition and are **not exposed through this package's REST API\nyet**.\n\n## Skill sets (#2466)\n\n**Status: disabled. The agent loads zero skills.** It bundles six **Agent Skills**\nand the machinery to activate one named **set** of them per launch, but the\n`skill_sets:` and `default_skill_set:` blocks in `gaia-agent.yaml` are commented\nout pending an eval run that shows the skills improve triage. Concretely, on the\nshipped binary:\n\n- `active_skill_set` is `None` and `loaded_skills` is empty; no skill text reaches\n  the system prompt.\n- A personal and a work mailbox get identical behaviour.\n- `--skill-set` / `GAIA_EMAIL_SKILL_SET` **fail loudly** at startup — the agent\n  declares no sets, so there is no valid name to pass: *\"requested skill set\n  'personal', but this agent declares no skill sets — Agent Skills are switched\n  off in this build. Drop the option, or uncomment the 'skill_sets:' and\n  'default_skill_set:' blocks in gaia-agent.yaml.\"* That is the\n  no-silent-fallbacks rule working, not a bug.\n- The bulk-triage result envelope is back to its full **6144 tokens**\n  (16384 − 9216 − 1024), the pre-skills value; the `personal` set had cut it to\n  4810 and `work` to 4070.\n- Nothing else moves: same endpoints, same tools, same permissions, same\n  `SCHEMA_VERSION`.\n\nRe-enabling is uncommenting those two manifest blocks — both together, since a\nnon-empty `skill_sets:` without a `default_skill_set:` is a parse error. The rest\nof this section describes what the machinery does *when enabled*.\n\n> **Two different files in this package are named `SKILL.md`. They are not the\n> same kind of artifact.**\n>\n> - [`SKILL.md`](./SKILL.md), beside this file, is the **integration playbook** —\n>   instructions for an AI coding assistant helping a developer wire this npm\n>   package into an app.\n> - `gaia_agent_email/skills/<name>/SKILL.md`, inside the sidecar, are **Agent\n>   Skills** — instructions the *email agent itself* would load into its own\n>   prompt at runtime (none load today, per the status above).\n>\n> Different audience, different format. Everything in this section is about the\n> second kind; nothing here changes the integration playbook.\n\n### The bundled skills\n\nEach is a Markdown procedure (`skills/<name>/SKILL.md` in the sidecar). All six\nstill ship in the binary; none currently loads:\n\n| Skill | What it makes the agent better at |\n|-------|-----------------------------------|\n| `inbox-triage` | Sorting an inbox into what needs a reply, what needs a decision, and what is just noise. |\n| `newsletter-digest` | Condensing newsletters and bulk mail into one short digest, then clearing them out. |\n| `travel-itinerary` | Assembling scattered booking confirmations into one chronological itinerary. |\n| `meeting-scheduling` | Turning meeting requests into calendar decisions — accept, decline, or propose another time. |\n| `action-item-extraction` | Pulling the concrete commitments out of a thread: who owes what, by when. |\n| `escalation-routing` | Deciding what needs attention now, what can wait, and what belongs to someone else. |\n\n### The two sets (currently commented out)\n\nThese are the sets `gaia-agent.yaml` declares when the blocks are uncommented;\n**exactly one would be active per launch**:\n\n| Set | Skills |\n|-----|--------|\n| `personal` (`default_skill_set`) | `inbox-triage`, `newsletter-digest`, `travel-itinerary` |\n| `work` | `inbox-triage`, `meeting-scheduling`, `action-item-extraction`, `escalation-routing` |\n\n`inbox-triage` is in **both** — sets **overlap**, they do not partition. (A skill\nthat should load for every set belongs in the manifest's top-level `skills:` list\ninstead; this agent declares none.)\n\n### Resolution order (inert while the blocks are commented out)\n\n1. **Explicit request** — the `--skill-set` flag or `GAIA_EMAIL_SKILL_SET`. Wins\n   over everything.\n2. **The agent's selector** — `EmailTriageAgent.select_skill_set()` maps the\n   connected mailbox's account type onto a set: `personal` → `personal`, `work` →\n   `work`.\n3. **`default_skill_set`** from the manifest (`personal`), used when the account\n   type is unknown.\n\nAn **undeclared set name never falls back** — it raises at startup naming the valid\nsets, per GAIA's no-silent-fallbacks rule. With no sets declared *every* name is\nundeclared, which is why `--skill-set` currently always errors.\n\n### How the account type is derived\n\nAt connect time GAIA classifies a **Microsoft** account from the `tid` (tenant id)\nclaim of its OAuth `id_token`: the well-known consumers tenant means a personal\naccount (Outlook.com / Hotmail / Live), any other tenant id means a work or school\n(Entra ID) account. The result is stored on the connection and exposed as\n`account_type` (`\"personal\"` / `\"work\"`) by the GAIA connector store.\n`GAIA_EMAIL_ACCOUNT_TYPE` still pins this value directly and still rejects an\ninvalid one, but with no sets declared the pin currently **selects nothing** —\nthere is no set for it to hand off to.\n\nThree consequences worth knowing:\n\n- **Gmail has no equivalent claim**, so a Gmail-only mailbox has no account type to\n  read. The kind is genuinely **unknown**; once sets are re-enabled, the manifest's\n  `default_skill_set` applies here — not because anything is inferred from the\n  mailbox, but because that is the declared default, and the resolution is logged.\n  Nothing guesses; nothing is silent.\n- **GAIA splits Microsoft into two connectors** — `microsoft` (personal,\n  `consumers` authority) and `microsoft_work` (work/school, Entra ID) — and both\n  are now in this agent's `REQUIRED_CONNECTORS`\n  ([#2629](https://github.com/amd/gaia/issues/2629)). The derivation reads\n  whichever connector is connected, so once sets are re-enabled the work path\n  resolves automatically for either mailbox kind.\n- **The kind is recorded when the connection is made.** A Microsoft mailbox\n  connected before this feature shipped carries no `account_type` until it is\n  reconnected, so it too resolves through the default.\n- **No new permission or scope** is involved — the claim is already in the token\n  the connect flow receives.\n\n### Configuration\n\nWith the blocks commented out, a value passed via `--skill-set` or\n`GAIA_EMAIL_SKILL_SET` fails loudly at startup (see Status above), and\n`GAIA_EMAIL_ACCOUNT_TYPE` is accepted but currently selects nothing. The table and\nexample below describe the full surface for when skill sets are re-enabled:\n\n| Surface | Values | Effect |\n|---------|--------|--------|\n| `--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. |\n| `GAIA_EMAIL_SKILL_SET` | a declared set name | Same effect, as an env var. Backs `EmailAgentConfig.skill_set`. |\n| `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. |\n\nFrom this package, either one reaches the sidecar through `startSidecar`:\n\n```ts\nconst sidecar = await startSidecar({\n  binaryPath,\n  port: 8131,\n  extraArgs: [\"--skill-set\", \"work\"],        // the CLI flag …\n  // env: { GAIA_EMAIL_SKILL_SET: \"work\" }, // … or the env var. Equivalent.\n});\n```\n\n### What skill sets do NOT change\n\nThe bundled skills are **instruction-only**: none declares `tools:` or\n`permissions:`, so activating a set would change only what the agent knows how to\ndo well, never what it is *able* to do. Disabling them therefore removes no\ncapability:\n\n- The agent's tool count is unchanged (59), and so is every tool's behaviour.\n- The REST and MCP contracts are unchanged — no new endpoints, no schema bump, and\n  `SCHEMA_VERSION` does not move.\n- The connector surface and the permission model are unchanged.\n\nRelocating the agent's tool implementations into skills is separate future work\n(#2672) and has **not** happened.\n\n## Browser / Electron renderer (`./client`)\n\nThe default entry (`.`) pulls in Node built-ins (`node:fs`, `node:child_process`,\n`node:crypto`) to fetch and spawn the binary, so it can't be bundled for a browser\nor an Electron renderer. The browser-safe `./client` subpath re-exports only\nzero-Node-dependency symbols — `EmailClient`, every error class, `SCHEMA_VERSION`,\nand all request/response types — so it *bundles* for a renderer.\n\nBut the sidecar serves **same-origin only and sends no CORS headers**, so a\nrenderer on a different origin cannot `fetch` `http://127.0.0.1:8131` directly. Two\nworking patterns:\n\n- **Electron (recommended):** spawn and own the sidecar in your **main** process\n  (the `.` entry), and expose `triage`/`draft` to the renderer over your own IPC.\n- **Same-origin / proxied:** use `./client` from a page that already shares the\n  sidecar's origin, or behind a proxy you control.\n\n```ts\nimport { EmailClient } from \"@amd-gaia/agent-email/client\";\n\n// Same-origin or proxied path only — not a cross-origin fetch at 127.0.0.1:8131.\nconst client = new EmailClient({ baseUrl: \"http://127.0.0.1:8131\" });\nconst res = await client.triage({ payload: { /* … */ } });\n```\n\n## Module format\n\nThe package is **ESM-only** (`\"type\": \"module\"`; no CommonJS build). Import it with\n`import …`. From a CommonJS module, use a dynamic import instead of `require`:\n\n```js\nconst { startSidecar } = await import(\"@amd-gaia/agent-email\");\n```\n\nPlain JavaScript works — the package ships compiled JS in `dist/`; TypeScript is\nthe authoring language, not a consumer requirement. The bundled `.d.ts` files give\neditors autocomplete but your code never imports them.\n\n## Types\n\nTypeScript types in `src/types.ts` mirror two Python sources of truth:\n\n- `contract.py` — the triage request/response contract plus the schema-2.1\n  additions (inbox search, mailbox actions, calendar, pre-scan), the schema-2.2\n  attachment models (`AttachmentMeta` / `OutgoingAttachment`), and the schema-2.3\n  triage draft scaffold (`DraftScaffold`).\n- `api_routes.py` — the local draft/send confirmation handshake models, the\n  readiness-preflight envelope (`InitResponse` / `InitLemonadeStatus` /\n  `InitModelStatus`, #1795), and the scheduled-briefing response\n  (`EmailBriefingResponse`, #1608).\n- `query_routes.py` + the frozen `/query` SSE contract\n  (`docs/spec/agent-ui-query-sse-contract.md`) — the schema-2.4 agent-loop query:\n  `EmailQueryRequest` / `QueryContextItem`, the seven `QueryEvent` shapes (plus\n  the `unknown` placeholder for additive future types), and `QueryCancelResponse`.\n\nEvery schema since 2.4 has been additive over the one before it (see\n`contract.py`'s own per-version changelog for the exact field-level diff of\neach): OAuth-forward `/v1/connections` (2.5, #2154), mid-run `needs_input` +\n`/query/{run_id}/respond` (2.6, #2469), the read-only attention view (2.8,\n#2582), `EmailPreScanResult.total_inbox` (2.9, #2638/#2643),\n`AttentionCoverage.message_errors` (2.10, #2716), the pre-scan `needs_you`\nworklist view (`NeedsYouItem[]`) plus the filtered-remainder `BulkSummary`\n(2.11, #2743, see below), `EmailQueryRequest.session_id`: an optional\nconversation id that resolves the same agent across turns sharing it,\ninstead of a throwaway per-call agent (2.12, #2829),\n`PreScanItem.is_phishing`/`is_spam` plus\n`EmailPreScanResult.suspicious`/`suspicious_total`: the phishing/spam-flagged\nsubset of `actionable`, captured before its own cap so a flagged message\nranked past it is never silently dropped from the count (2.13, #2900), and —\ncurrent, `SCHEMA_VERSION = \"2.14\"` — a third mailbox provider value,\n`microsoft_work` (work Microsoft 365 / Entra, distinct from the personal\n`microsoft` Outlook.com connector), now valid wherever a provider string is\naccepted or returned (#2629).\n\nThey are hand-written (vs. generated from `/openapi.json`) because the contract is\nsmall and version-gated, keeping the published package free of a typegen build\nstep. The runtime `checkVersion` guard catches contract drift loudly; the server\nexposes `GET /openapi.json` if you prefer to regenerate.\n\n> Wire note: `EmailMessage.from` is the JSON key on the wire (Python aliases its\n> `from_` field to `from`), so the TS interface uses `from` directly.\n\n## Platforms\n\nFully supported: `win32-x64`, `linux-x64`, `darwin-arm64` (Apple Silicon). Intel\nmacOS (`darwin-x64`) is a **best-effort** target — built when the release can, and\nomitted with a clear \"no binary for darwin-x64\" install error otherwise. Each\nbinary is built natively (PyInstaller does not cross-compile); `binaries.lock.json`\nmaps every available platform to its artifact filename, SHA-256, and size.\n\n## License\n\nCopyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.\n\nSPDX-License-Identifier: MIT\n",
      "skill": "---\nname: integrate-agent-email\ndescription: 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.\n---\n\n# Integrating @amd-gaia/agent-email\n\n`@amd-gaia/agent-email` embeds the GAIA email agent in a JS/TS app. It triages,\ndrafts, and sends email **locally on AMD Ryzen AI** — no cloud LLM. This package is\nthe **client**: it downloads a frozen native **sidecar** binary, spawns it, and\ntalks to it over local HTTP. There is **no Python** and no separate GAIA install.\n\nFollow these steps to wire it into an app.\n\n> **This file is NOT one of the agent's own skills.** It is the integration\n> playbook — how *you* wire this npm package into an app. The sidecar separately\n> bundles six **Agent Skills** at `gaia_agent_email/skills/<name>/SKILL.md`, which\n> are instructions the *email agent itself* would load into its own prompt at\n> runtime — currently **disabled**, so none of them loads. Same filename,\n> different artifact: don't load those into your assistant, and don't ship this\n> one as an agent skill. See\n> [Skill sets](#skill-sets--disabled-in-this-release) below.\n\n## 1. Install\n\n```bash\nnpm install @amd-gaia/agent-email\n```\n\nThe package is **ESM-only** (`\"type\": \"module\"`). Use `import`, not `require`. From\na CommonJS file, use `await import(\"@amd-gaia/agent-email\")`.\n\n## 2. Pick the right entry point\n\n- **Node / main process** → the default entry `@amd-gaia/agent-email`. It can\n  fetch the binary and spawn/own the sidecar (uses `node:fs`, `node:child_process`).\n- **Browser / Electron renderer** → the `@amd-gaia/agent-email/client` subpath. It\n  has zero Node built-ins and only talks to an already-running sidecar over HTTP.\n\nThe desktop pattern: spawn the sidecar **once** from the Node/main process, then\ndrive it from the renderer via `./client`.\n\n## 3. Fetch the binary and start the sidecar (Node)\n\n```ts\nimport { fetchBinary, startSidecar, shutdown } from \"@amd-gaia/agent-email\";\n\n// Build time (or first run): download + SHA-256-verify the platform binary.\nconst { binaryPath } = await fetchBinary({ outDir: \"resources\" });\n\n// Runtime: spawn -> wait for /health -> version-check, in one call.\nconst sidecar = await startSidecar({ binaryPath, port: 8131 });\n\n// ... use sidecar.client ...\n\nawait shutdown(sidecar); // graceful stop — auto-cleanup also reaps on exit\n```\n\n- `fetchBinary` writes a verified binary into `outDir`. SHA-256 is mandatory; a\n  bad download is rejected and not left on disk. Run it at build time or guard it\n  to run once.\n- `startSidecar` throws if the binary can't start, never becomes healthy, or the\n  contract MAJOR version mismatches — and cleans up so a failed start leaks nothing.\n- The sidecar is auto-reaped when your process exits, crashes, or is signalled\n  (default `autoCleanup`), so a missed `shutdown` won't orphan the frozen binary's\n  child. `shutdown(sidecar)` is the graceful, awaited stop; `autoCleanup: false` opts out.\n\n## 4. Call the typed client\n\n```ts\nconst res = await sidecar.client.triage({\n  payload: {\n    kind: \"single\",\n    principal: { email: \"me@example.com\" },\n    message: {\n      message_id: \"m1\",\n      from: { name: \"Sarah Chen\", email: \"sarah@example.com\" },\n      subject: \"Prod incident follow-up\",\n      body: \"Please review the report and reply by Friday.\",\n    },\n  },\n});\nconsole.log(res.result.category, res.result.summary);\n```\n\nTo classify many messages at once, use `triageBatch` — an `items` array (1–100) in,\na parallel `results` array out, order-preserved. It's additive (the single `triage`\nabove is unchanged). Per-item failures isolate, so an HTTP 200 can still carry\nerrored items — inspect each `results[].error`, never just the status:\n\n```ts\nconst batch = await sidecar.client.triageBatch({\n  items: [\n    { kind: \"single\", principal: { email: \"me@example.com\" },\n      message: { message_id: \"m1\", from: { email: \"sarah@example.com\" },\n        subject: \"Prod incident\", body: \"Reply by Friday.\" } },\n  ],\n});\nfor (const r of batch.results) {\n  if (r.error) console.warn(`item ${r.index} failed: ${r.error.message}`);\n  else console.log(`item ${r.index}:`, r.result!.category);\n}\n```\n\nThe interface:\n\n| Call | Needs | Notes |\n|------|-------|-------|\n| `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. |\n| `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`). |\n| `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. |\n| `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`](./CHANGELOG.md). |\n| `draft(req)` | Nothing external | Returns a single-use confirmation token. Optional `attachments` (schema 2.2): `{ filename, mime_type, content_base64 }` each, ≤ 25 MB decoded. |\n| `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). |\n| `confirmAction(req)` | Nothing external | Mints a single-use token for `\"archive\"`/`\"quarantine\"`, bound to the `(action, message_id)`. |\n| `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). |\n| `unarchive(req)` | A connected mailbox | Restores within the 30s window (ungated — pass `batch_id`); expired/unknown → 409. |\n| `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). |\n| `unquarantine(req)` | A connected mailbox | Restores prior labels within the 30s window (ungated — pass `action_id`); expired/unknown → 409. |\n| `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. |\n| `previewCalendarEvent(req)` | Nothing external | Mints a single-use confirmation token bound to the event (calendar analogue of `draft`). |\n| `createCalendarEvent(req)` | Preview token + connected calendar | Token gate fires first: no/invalid token → 403, then the calendar checks. |\n| `respondToCalendarEvent(req)` | Connected calendar | RSVP `accepted`/`declined`/`tentative` to an existing invite. |\n| `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. |\n| `cancelQuery(runId)` | Nothing external | Cancel an in-flight `query()` run between steps (pass the `run_id` you minted). Not in flight → 404. |\n\n**Build the standalone surface (`triage`, `draft`, `confirmAction`,\n`previewCalendarEvent`) with zero connector setup.** The read-only `search` and\n`prescan` read the live inbox (a connected mailbox, no token); `send`, the mailbox\nactions (`archive` / `quarantine`), and the calendar actions (view / create / respond)\nneed a connected mailbox whose relevant scope was granted. Mint the gate token with\n`draft` (for `send`), `confirmAction` (for `archive` / `quarantine`), or\n`previewCalendarEvent` (for `createCalendarEvent`); `archive` and `quarantine` are\nreversible inside a 30s window via the ungated `unarchive` / `unquarantine`. Every\nnon-2xx response throws `HttpError` (`status`, `url`, `bodyText`) — handle it; there is\nno silent null.\n\n**Scheduled daily briefing (#1608, REST-only):** the sidecar can run `prescan` on a\ndaily timer with no prompt. Off by default — launch with\n`startSidecar({ env: { GAIA_EMAIL_BRIEFING_ENABLED: \"true\" } })` (fire time\n`GAIA_EMAIL_BRIEFING_TIME`, 24h local `HH:MM`, default `08:00`), then pull the latest\nrun from `GET /v1/email/briefing` with plain `fetch` (no client wrapper yet). 404\nuntil the first scheduled run; an invalid env value fails sidecar startup loudly.\n\n## 5. From a renderer (Electron / browser)\n\nThe sidecar serves **same-origin only — no CORS**. A renderer on a different origin\n**cannot** fetch `http://127.0.0.1:8131` directly; the browser blocks it. So:\n\n- **Recommended:** spawn the sidecar in the Electron **main** process (step 3) and\n  expose `triage`/`draft` to the renderer over your own IPC. Don't call the sidecar\n  from the renderer directly.\n- The `./client` entry (zero Node built-ins) is only usable from a **same-origin or\n  proxied** page:\n\n```ts\nimport { EmailClient } from \"@amd-gaia/agent-email/client\";\n// Pass the sidecar's session token (from sidecar.authToken in the main process,\n// forwarded over IPC) — without it every /v1/email/* call is 401.\nconst client = new EmailClient({ baseUrl: \"http://127.0.0.1:8131\", authToken });\n```\n\n## Canonical agent-loop query (`POST /v1/email/query`, schema 2.6)\n\nThe v2 keystone (#2016): NL request in, the agent reasons and chains its tools, the\n**canonical Server-Sent Event types** out — `status` / `token` / `tool_call` /\n`tool_result` / `needs_confirmation` / `needs_input` / `final` / `error`, terminated by\nexactly one `final` or `error`. This is the one loop every v2 front-door relays to. The **host\nmints `run_id`** and **pushes** the transcript slice in `context`, so the sidecar\nstays stateless. The typed client wraps it (#2097): `query()` returns an async\niterator of typed `QueryEvent`s; `cancelQuery(runId)` stops the run between steps:\n\n```ts\nconst runId = crypto.randomUUID(); // host-minted; also the cancel handle\nfor await (const ev of sidecar.client.query({\n  query: \"Triage my inbox\",\n  run_id: runId,\n  context: [], // pushed transcript slice; [] for a fresh conversation\n})) {\n  switch (ev.type) {\n    case \"status\":       console.log(ev.message); break;\n    case \"token\":        process.stdout.write(ev.delta); break;\n    case \"tool_call\":    console.log(`→ ${ev.tool}`, ev.args); break;\n    case \"tool_result\":  console.log(`← ${ev.tool}`, ev.data); break;\n    case \"needs_confirmation\": break; // run then ends with a final refusal (D1)\n    case \"needs_input\":               // PAUSED — answer, then keep iterating\n      await sidecar.client.respondToQuery(runId, ev.request_id, await askUser(ev));\n      break;\n    case \"final\":        console.log(ev.answer); break;   // terminal\n    case \"error\":        console.error(ev.detail); break; // terminal, verbatim\n    default:             console.warn(\"unsupported event\", ev); // future additive type\n  }\n}\n// Mid-run, from anywhere that knows runId:\n// await sidecar.client.cancelQuery(runId);\n```\n\nRules an integration must respect:\n\n- **Mint `run_id` yourself** (`crypto.randomUUID()`) and keep it — it is the cancel\n  handle, valid from the instant the request is sent.\n- **Exactly one terminal event.** A terminal `error` is *yielded* (surface `detail`\n  verbatim); transport/contract failures *throw* (`HttpError` non-2xx,\n  `QueryStreamError` for a non-SSE response / malformed event / stream that closes\n  without a terminal). Never treat iterator completion without a `final` as success —\n  the client already throws for you.\n- **Gate `can_answer_questions` on the peer's version.** Call `version()` first:\n  a sidecar below `apiVersion` 2.6 does not know the field and answers `422` to\n  every request carrying it — including `false`. Omit it below 2.6 and treat\n  mid-run questions as unavailable.\n- **Declare `can_answer_questions` honestly.** It defaults to `false`. Set it\n  `true` only when a human is watching a UI that renders the question; a one-shot\n  or batch job must leave it off, and then gets an immediate actionable refusal\n  instead of a run parked on a question nobody can see.\n- **Answer `needs_input`, do not restart.** The run is parked on the SAME stream.\n  Call `respondToQuery(runId, ev.request_id, value)` and keep iterating the existing\n  iterator — issuing a fresh `query()` abandons the paused run. `value` is an\n  option's `value` (its `label` also works) or free text when `allow_free_text`.\n  Render every option's `description`: the label alone does not tell the user what\n  they are agreeing to. When `sensitive` is set, mask the input and never log it.\n  Ignoring the question is safe but wasteful — the run ends with an `error` after\n  `timeout_seconds`.\n- **Handle the `default` branch.** A `type` outside the canonical vocabulary arrives as\n  `{ type: \"unknown\", eventType, raw }` — render an \"unsupported event\" placeholder or\n  log it; it is never silently dropped.\n- **Long runs are normal.** `timeoutMs` bounds time-to-first-response only. To abort\n  from the client side pass `query(req, { signal })` AND call `cancelQuery` so the\n  sidecar stops the loop, not just the socket.\n\nA confirmation-requiring step (a destructive tool such as `send_now`) emits\n`needs_confirmation` then ends with a `final` refusal pointing at the fixed-function\nroute — mint a token via `draft()`, then `send()` (stateless stub, epic decision D1;\n`confirm_url` omitted). That is an **approval** and stays terminal and deny-by-default;\na **question** (`needs_input`) is the resumable one. Do not treat them alike.\n\n**Mailbox setup is the agent's job now (#2469).** When the agent has no usable mailbox —\nnot connected, credentials broken, missing a scope, or connected-but-not-granted — it\nasks the user about that specific problem via `needs_input` and fixes it, rather than\nreturning an error telling them to run a CLI command. Two cases are worth knowing:\nthe connected-but-not-granted case needs no browser at all (a local permission write),\nand connecting Google still requires the user to supply their own OAuth client ID and\nsecret, so expect a `sensitive: true` question on that path.\n\n**Mail-required, calendar-optional (#2730).** Every setup/reconnect path — this\nself-repair flow included — requests the full mail + calendar scope union at\nconsent time, but only the mail scopes gate whether the flow reports success. A\nuser who declines calendar still ends up with a working mailbox; calendar\ntools raise their own actionable error, naming the exact scope, the first time\none is actually called. Do not \"fix\" a self-repair flow that requests only\nmail scopes — that narrower request is the bug this issue removed, not a\nsimplification to reintroduce.\n\n## Stateful agent surface (`/v1/email/agent/*`, 0.4.0)\n\nEverything above is **stateless** — you send a payload, the sidecar analyzes it, no\nmemory, no conversation. The sidecar also hosts a **session-scoped, conversational\nagent** that runs the full `EmailTriageAgent` (memory, personalization, every agent\ntool) over HTTP. This is the surface the Agent UI uses. It is **not wrapped by the\ntyped `EmailClient` yet** — call it directly with `fetch` against the sidecar's\n`baseUrl`:\n\n```js\nconst base = \"http://127.0.0.1:8131\";\n// 1. Start a session (builds the agent; reports memory availability).\nawait fetch(`${base}/v1/email/agent/session`, {\n  method: \"POST\", headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify({ session_id: \"s1\" }),\n});\n\n// 2. Run a turn — the reply streams back as Server-Sent Events.\nconst res = await fetch(`${base}/v1/email/agent/query`, {\n  method: \"POST\", headers: { \"content-type\": \"application/json\", accept: \"text/event-stream\" },\n  body: JSON.stringify({ session_id: \"s1\", message: \"Triage my inbox\" }),\n});\nconst reader = res.body.getReader(); const dec = new TextDecoder(); let buf = \"\";\nfor (;;) {\n  const { value, done } = await reader.read(); if (done) break;\n  buf += dec.decode(value, { stream: true });\n  let i; while ((i = buf.indexOf(\"\\n\\n\")) >= 0) {\n    const line = buf.slice(0, i).split(\"\\n\").find(l => l.startsWith(\"data: \"));\n    buf = buf.slice(i + 2);\n    if (!line) continue;\n    const ev = JSON.parse(line.slice(6));           // {type: \"thinking\"|\"step\"|\"permission_request\"|\"run_complete\"|...}\n    if (ev.type === \"permission_request\") {         // a gated tool (send/forward/delete/...) is waiting\n      await fetch(`${base}/v1/email/agent/confirm-tool`, {\n        method: \"POST\", headers: { \"content-type\": \"application/json\" },\n        body: JSON.stringify({ session_id: \"s1\", approved: true }),\n      });\n    }\n    if (ev.type === \"run_complete\") console.log(\"answer:\", ev.answer);\n  }\n}\n```\n\nOther endpoints: `POST /cancel`, `DELETE /session/{id}`, `GET /session/{id}/history`,\nand the runtime memory toggle `POST /memory` + `GET /memory/{id}` (enabling memory that\nwas never initialized returns **409**, never a silent no-op). One turn at a time per\nsession — an overlapping `/query` returns **409**. See `SPEC.md` for the full table.\n\n### Full autonomy (`/v1/email/agent/autonomy/*`)\n\nThe agent can run **proactively** at the `earn_trust` level: it archives low-signal\n(promotional/spam) mail and marks FYI mail read on its own **where your explicit\npreferences already sanction it** (a low-priority sender, or a category you default to\narchive) or a sender/category has earned enough trust, and **always asks before anything\ndestructive** (send / forward / RSVP / quarantine). There is no permanent-delete — the\nagent only ever moves mail to Trash, which is always reversible. Reply drafting is not yet\nwired into this proactive loop (the policy layer supports it, but no candidate reaches it\ntoday).\nTurn it on and inspect the earned trust:\n\n```js\n// Turn on full autonomy (levels: off | suggest | earn_trust | full; \"off\" = kill switch)\nawait fetch(`${base}/v1/email/agent/autonomy`, {\n  method: \"POST\", headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify({ session_id: \"s1\", level: \"earn_trust\" }),\n});\n\n// Run one observe→decide→act cycle now (the daemon/scheduler drives this in production)\nconst r = await fetch(`${base}/v1/email/agent/autonomy/run`, {\n  method: \"POST\", headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify({ session_id: \"s1\", max_messages: 25 }),\n});\nconst report = await r.json();\n// { level, executed:[…], proposals:[…], decisions:[…], skipped }\n// decisions[] explains EVERY candidate considered: { message_id, tool, action, outcome, reason, sender }\n\n// Inspect the earned-trust ledger — autonomy is never a black box\nconst status = await (await fetch(`${base}/v1/email/agent/autonomy/s1`)).json();\n// { level, enabled, trust_min_samples, trust_threshold, trusted_scope_count, scopes:[…] }\n```\n\nThe agent **learns from your corrections**: undoing an auto-executed action —\n`POST /v1/email/agent/autonomy/undo` with `{ session_id, action_id }` from the `executed[]`\nentry, or the conversational `undo_archive_batch` tool for a batch archive — is captured as\na negative outcome that pulls the sender/category back below the trust bar. (Positive-outcome\naccrual — trust *rising* as suggestions are accepted or left standing — is not yet wired, so\ntoday the ledger only ratchets trust down.) Every auto-action is reversible with undo. A bad\n`level` returns **400**; an unknown session returns **404**; undoing an unknown/expired\n`action_id` returns **409**; `/run` while the level is `off` returns **409** too — it refuses\nrather than returning the same 200 shape a real, found-nothing cycle would (#2528).\n\nThe Python host also ships a thin-client CLI over this same surface:\n`gaia email autonomy {status|set-level|pause|resume|run|trust|kill}` (#2516).\n\n## Skill sets — disabled in this release\n\nThe sidecar bundles six Agent Skills (`personal`: `inbox-triage`,\n`newsletter-digest`, `travel-itinerary`; `work`: `inbox-triage`,\n`meeting-scheduling`, `action-item-extraction`, `escalation-routing`), but the\nagent's manifest currently declares **no sets**, so **none of them loads**. A\npersonal and a work mailbox get identical behaviour. This is deliberate: the\nskills are held back until an eval run shows they improve triage.\n\nWhat that means for your integration:\n\n- **Do not pass `--skill-set` or `GAIA_EMAIL_SKILL_SET`.** Any value fails at\n  startup with `... but this agent declares no skill sets — Agent Skills are\n  switched off in this build.` There is no working name. This is fail-loud\n  behaviour, not a bug to work around.\n- `GAIA_EMAIL_ACCOUNT_TYPE` still validates but selects nothing.\n- Nothing in the API changes either way — same endpoints, same tools, same\n  permissions. Re-enabling happens inside the agent's `gaia-agent.yaml`; your\n  code does not change.\n\n## Running in a server / long-lived app\n\n- **`fetchBinary` is a build step**, not per request (network + SHA verify). Run it\n  once; `resolveBinaryPath` at runtime.\n- **Spawn once at boot**, hold the `Sidecar` handle for the process lifetime — never\n  per request.\n- **Low concurrency.** One local Lemonade model slot, so parallel `triage` calls\n  serialize. Cap inflight calls.\n- **Cleanup is automatic** (default `autoCleanup`): the sidecar's child is reaped on\n  exit/crash/signal. Call `shutdown` for a graceful stop, or `autoCleanup: false` to\n  wire signals yourself. The package does not restart a crashed sidecar.\n\n## Fast local iteration (when you need to fix the agent, not just call it)\n\nThe steps above spawn a **frozen** binary — you can't edit it. To debug or improve\nthe agent, run its **Python source** and attach the same client. The frozen binary\nis that source frozen, so the contract is identical; only the base URL changes.\n\n```bash\npip install -e hub/agents/email/python     # editable install\ngaia-agent-email serve --reload            # source server on 127.0.0.1:8131, auto-reload\n```\n\n```ts\nimport { connectSidecar } from \"@amd-gaia/agent-email\";\n// Attaches (health + version check), spawns nothing, token off in dev:\nconst dev = await connectSidecar({ baseUrl: \"http://127.0.0.1:8131\" });\nawait dev.client.triage({ payload: { /* … */ } });\n// Edit the Python under gaia_agent_email/, save → reload → re-run. Seconds.\n```\n\n`npx @amd-gaia/agent-email dev` launches the `serve` process for you\n(`--python <path>` to use a specific venv). There's no `child` on the returned\nhandle and nothing to `shutdown()` — you own the `serve` process (Ctrl+C). Switch\nback to production by using `startSidecar` (frozen binary) instead of\n`connectSidecar`; the client calls are unchanged.\n\n## Prerequisites — the agent needs a local model\n\nThe sidecar runs the LLM via **Lemonade Server**, which this package does **not**\ninstall. Before `triage`/`draft`/`send` succeed, the host must have:\n\n1. A running Lemonade Server (`lemonade-server serve`).\n2. The model pulled (`gaia init` installs Lemonade and downloads the default model).\n\nUntil then the binary boots, but the first `triage` returns **HTTP 502**.\n\n## Gotchas (read before debugging)\n\n- **Every `/v1/email/*` call needs the session token** (#1706). `sidecar.client`\n  carries it automatically; a client you construct yourself must pass `authToken`\n  (from `sidecar.authToken`) or every call is **401**. Non-loopback `Host` → 400,\n  non-loopback browser `Origin` → 403. `/health` · `/version` · `/v1/email/spec` ·\n  `/v1/email/playground` are exempt.\n- **`health()` is liveness-only.** A green `/health` means the REST surface is up,\n  NOT that triage will work. For real readiness call **`init()`** (`GET\n  /v1/email/init`, #1795) — it probes Lemonade + the triage model and returns the\n  `InitResponse` on both the ready (`200`) and not-ready (`503`) paths, so branch on\n  `.ready` / read `.hint`. `POST /v1/email/init` streams a model-pull (no wrapper yet).\n- **HTTP 502 from `triage`** → Lemonade isn't running/reachable, or the model isn't\n  pulled. It is not a bug in this package.\n- **Addresses are objects, not strings.** `to` (and `triage`'s `from` / `principal`)\n  are `{ email, name? }`; `to` is a non-empty array of them. A plain string → 422.\n- **`send` needs the draft `confirmation_token`** (missing/invalid → 403), but it\n  takes **no OAuth token** — the mailbox is resolved from the host's GAIA connector\n  store (no mailbox connected → 503). The read-only `search` / `prescan` resolve the\n  mailbox the same way (503 with none, 400 with 2+). Triage and draft need no connector.\n- **Attachments bind to the token** (schema 2.2). Re-send the exact `attachments`\n  array you drafted with — the metadata-only `draft` echo has no `content_base64`,\n  so spreading the echo into `send` loses the files. A swapped/extra/missing\n  attachment → 403; bad base64, a malformed MIME type, or > 25 MB decoded → 422;\n  Outlook additionally rejects files over 3 MB (Graph simple-attach limit).\n- **`archive` / `quarantine` are gated like `send`**, but their token comes from\n  `confirmAction` (not `draft`) and is bound to the `(action, message_id)` — a token\n  for one can't authorize the other. Undo with `unarchive` (pass the returned\n  `batch_id`) / `unquarantine` (pass the `action_id`) **within 30s**; past the window\n  the reversal returns **409** (restore manually in the mail client). For Outlook,\n  use the `post_archive_id` from the archive response — the folder move changes the id.\n- **Cleanup is automatic by default** — the sidecar is reaped on exit/crash/signal;\n  only `autoCleanup: false` (or a hard `SIGKILL` of your process) can orphan the\n  child. `shutdown` stays the graceful stop.\n- **OAuth forward-out is daemon-only (sidecar contract 2.5, #2154).** The\n  `/v1/connections/{provider}` intake exists for the GAIA Agent UI daemon to\n  forward short-lived access tokens to the sidecar (the sidecar never holds the\n  refresh token). A standalone integrator using this package does **not** call it —\n  keep resolving the mailbox from the host's GAIA connector store as before. There\n  is no `client.forwardConnection()` method, by design.\n- **Some capabilities are agent-loop-only — no REST endpoint, no client method.**\n  Scheduled send / snooze (#1609), **voice / style-matched drafting** (#1607 —\n  `build_voice_profile` learns a local style profile from Sent mail so drafts\n  come out in the user's own voice), **follow-up tracking** (#1606 —\n  `check_followups` flags sent mail still awaiting a reply, detection only),\n  and **waiting-on-you detection** (#2581 — `list_waiting_on_you` flags\n  INBOUND mail awaiting the user's reply; it only qualifies a message that has\n  both a genuine ask/meeting-time signal and corroboration that it's real\n  correspondence) all run in the agent tool loop. The REST contract has no\n  routes for them yet, so don't look for `client.scheduleSend()` /\n  `client.snooze()` / a voice, follow-up, or waiting-on-you method — they\n  don't exist (and none of these moves `SCHEMA_VERSION`).\n- **`--skill-set` / `GAIA_EMAIL_SKILL_SET` always fail right now.** Agent Skills\n  are disabled in this release, so the agent declares no sets and every name is\n  invalid. Don't wire either into your spawn options.\n- **ESM-only.** `require(\"@amd-gaia/agent-email\")` fails; use `import` / dynamic\n  `import()`.\n\n## Verify the integration\n\nA green path looks like: `fetchBinary` succeeds → `startSidecar` resolves →\n`client.triage(...)` returns a `result` with a `category` and `summary`. If\n`triage` 502s, start Lemonade and pull the model, then retry — the rest of your\nintegration is fine.\n\nTo eyeball the agent by hand without writing any code, run\n`npx @amd-gaia/agent-email playground` — it fetches the binary, starts the sidecar,\nand opens an interactive page where you can fire triage/draft and see a stack-health\ncheck.\n\nFor the full endpoint list, lifecycle internals, and connector details, see\n`SPEC.md` next to this file.\n",
      "evaluation": "# How the Email Triage agent is evaluated\n\nShort version: we measure how reliably the agent sorts email into the right\npriority, using a fixed set of labeled emails and comparing its answer to the\ncorrect one. The current result is on the\n[**Scorecard**](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SCORECARD.md)\ntab. This page explains what that number means and how it's measured — in plain\nterms first, with the technical recipe at the end.\n\n## What we measure\n\nThe agent sorts each email into one of five buckets — **urgent**,\n**needs-reply**, **FYI**, **promotional**, or **personal** — so nothing important\ngets buried. The eval checks how often it puts an email in the **right bucket, or\na close one**.\n\nWhy \"or a close one\"? Priority is a ranking (urgent > needs-reply > FYI >\npromotional). Calling a *needs-reply* email *urgent* is a near miss, not a\ndisaster — you still see it. Calling it *promotional* is a real miss — it gets\nburied. So the headline score gives full credit for the exact bucket **or** the\none next to it, and no credit for anything further off. That's what the\n**84.53 / 100** headline means: on most emails, the agent lands on the right\npriority or right next to it.\n\nAlongside the headline we also report the stricter \"exact bucket\" rate, how many\ntruly-urgent emails it catches (so a model can't cheat by calling everything\nurgent), and a couple of others. Only the headline counts toward the published\nscore; the rest are there for transparency.\n\n## What it's tested on\n\n- A **balanced set of ~250 labeled emails** drawn from a real vendor mailbox\n  dataset — not emails we made up to make the agent look good, and balanced across\n  the five buckets so every category (including the rare *personal* one) is\n  measured fairly.\n- **No real personal data** ever enters the test set — that's a deliberate policy.\n- The whole run is **on-device**: the agent uses a local AI model to classify each\n  email, and the scoring is a simple, exact comparison to the known-correct label\n  (no cloud, no second AI \"judge\", no API key). That keeps the numbers stable and\n  cheap to re-run.\n\nThere's also a separate, optional check that rates how well the agent drafts\nreplies *in your voice* — that one uses an AI judge and is reported on its own; it\ndoes not affect the 84.53 triage score.\n\n## Can you trust the number?\n\nYes, and you can re-run it yourself. Every published score is stamped with the\nexact command, model, and dataset that produced it, and the test emails are\nrebuilt deterministically from one committed source file — so the score is\nreproducible, not a one-off. Each release has to clear a minimum bar before it can\nship, and the score is re-measured whenever the agent's behavior or the dataset\nchanges.\n\n## Reproducing it yourself\n\nYou need a source checkout of [amd/gaia](https://github.com/amd/gaia) and **AMD\nRyzen AI hardware** (the npm package ships neither the test corpus nor the eval\nharness). The exact, version-stamped command lives in the\n[Scorecard's *Reproduction* section](https://github.com/amd/gaia/blob/agent-pkg-email-v0.6.0/hub/agents/email/npm/SCORECARD.md#reproduction)\n— it's auto-generated so it always matches the published number. Run that block;\nit installs the eval tools, starts a local model server, rebuilds the test emails\nfrom the committed seed, and runs the benchmark (~17 minutes on a 4B model).\n\n<details>\n<summary>Technical detail (for maintainers)</summary>\n\n- **Harness:** `gaia eval benchmark` (`src/gaia/eval/benchmark.py`) drives the\n  unchanged agent over a `FakeGmailBackend` synthetic inbox; scoring is exact\n  label-matching in `src/gaia/eval/quality_metrics.py` (no LLM judge, no\n  `ANTHROPIC_API_KEY`).\n- **Dataset:** committed source of truth is\n  `tests/fixtures/email/vendor_corpus_seed.jsonl`; `generate_mbox.py` builds the\n  gitignored `synthetic_inbox.mbox` + `ground_truth.json` from it\n  (`--verify` checks they're in sync). Full schema/provenance/PII policy in\n  `tests/fixtures/email/_schema.md`.\n- **Metrics:** the aggregate is `within_one_bucket_accuracy` (weight 1.0);\n  `category_accuracy`, `urgent_recall`, `urgent_vs_not_accuracy`, and\n  `personal_recall` are reported at weight 0. Formula + worked recomputation are in\n  `SCORECARD.md`.\n- **Running it:** set `GAIA_AGENT_TOOL_TIMEOUT=1800` (full-corpus triage is one\n  long tool call); run evals **serially** (two `gaia eval` runs against one\n  Lemonade server race-evict each other's model); use `--experiments 3` for\n  run-to-run variance (mean/stdev/95% CI).\n- **CI:** `test_email_agent_eval.yml` (nightly, report-mode on the self-hosted AMD\n  `stx` pool) and `email_scorecard_refresh.yml` (manual dispatch only; a full-corpus\n  run regenerates `SCORECARD.md`, a subset run smoke-tests the pipeline without\n  committing). The drafting eval needs `ANTHROPIC_API_KEY`; absent → loud skip,\n  never a pass.\n</details>\n",
      "capability_matrix": "<!-- Generated by packaging/capability_matrix.py -- do not edit by hand. -->\n# Email Agent Capability Matrix\n\nCode-derived surface inventory for the GAIA Email Triage agent (#2013). Regenerate with:\n\n```\npython hub/agents/email/python/packaging/capability_matrix.py\n```\n\n## Definitions\n\n- **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.\n- **no quality eval sentinel**: `no quality eval (contract-tested only)` -- the op is contract/shape-tested only; no judged quality bar exists for it.\n\n## Capability matrix\n\n27 exposed ops (23 REST functional + 4 MCP) and their eval coverage:\n\n| Op | Surface | Eval coverage |\n|---|---|---|\n| `/v1/connections` | REST | no quality eval (contract-tested only) |\n| `/v1/connections/{provider} (DELETE)` | REST | no quality eval (contract-tested only) |\n| `/v1/connections/{provider} (POST)` | REST | no quality eval (contract-tested only) |\n| `archive` | REST | no quality eval (contract-tested only) |\n| `attention` | REST | no quality eval (contract-tested only) |\n| `briefing` | REST | briefing |\n| `calendar/events (GET)` | REST | no quality eval (contract-tested only) |\n| `calendar/events (POST)` | REST | no quality eval (contract-tested only) |\n| `calendar/events/preview` | REST | no quality eval (contract-tested only) |\n| `calendar/events/respond` | REST | no quality eval (contract-tested only) |\n| `confirm` | REST | no quality eval (contract-tested only) |\n| `draft` | REST | drafting |\n| `draft_reply` | MCP | drafting |\n| `prescan` | REST | no quality eval (contract-tested only) |\n| `quarantine` | REST | no quality eval (contract-tested only) |\n| `query` | REST | no quality eval (contract-tested only) |\n| `query/{run_id}/cancel` | REST | no quality eval (contract-tested only) |\n| `query/{run_id}/respond` | REST | no quality eval (contract-tested only) |\n| `search` | REST | no quality eval (contract-tested only) |\n| `send` | REST | no quality eval (contract-tested only) |\n| `send_email` | MCP | no quality eval (contract-tested only) |\n| `triage` | REST | quality |\n| `triage/batch` | REST | quality |\n| `triage_email` | MCP | quality |\n| `triage_email_batch` | MCP | quality |\n| `unarchive` | REST | no quality eval (contract-tested only) |\n| `unquarantine` | REST | no quality eval (contract-tested only) |\n\n## Surface totals\n\n- Internal `@tool` agent-loop functions: **66**\n  - `briefing_tools`: 3\n  - `calendar_tools`: 6\n  - `connection_tools`: 1\n  - `delete_tools`: 4\n  - `followup_tools`: 1\n  - `onboarding_tools`: 2\n  - `organize_tools`: 15\n  - `phishing_tools`: 2\n  - `preference_tools`: 8\n  - `profile_tools`: 1\n  - `read_tools`: 9\n  - `ref_resolve`: 1\n  - `reply_tools`: 5\n  - `schedule_tools`: 4\n  - `summarize_tools`: 1\n  - `voice_tools`: 2\n  - `waiting_on_you_tools`: 1\n- REST functional verbs: **23** (26 total operations in the frozen contract, including health/version/init probes)\n- MCP tools: **4**\n  - `draft_reply`\n  - `send_email`\n  - `triage_email`\n  - `triage_email_batch`\n- Eval suites: **6**\n  - `action_items`: enforce=False, acceptance_enforce=None, wired=True\n  - `briefing`: enforce=False, acceptance_enforce=None, wired=True\n  - `drafting`: enforce=False, acceptance_enforce=None, wired=True\n  - `followups`: enforce=False, acceptance_enforce=None, wired=False\n  - `perf`: enforce=False, acceptance_enforce=None, wired=True\n  - `quality`: enforce=False, acceptance_enforce=True, wired=True\n- Additionally served but **out of the frozen contract** (footnote context, not guarded machinery): `agent_routes.py` 12 session routes (includes the autonomy control surface, #2529), `connector_routes.py` 4 OAuth routes, `server.py` 2 inline probes -- ~43 total routes served by the sidecar.\n\n## MCP Scope Decision\n\nTools: `draft_reply`, `send_email`, `triage_email`, `triage_email_batch`\n\nMCP 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'.\n\n## Eval Enforcement Status & Follow-up Plan\n\n### `action_items` (enforce=False, wired=True)\n\nExtraction-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.\n\n### `briefing` (enforce=False, wired=True)\n\nJudge-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.\n\n### `drafting` (enforce=False, wired=True)\n\nJudge-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.\n\n### `followups` (enforce=False, wired=False)\n\nDetection-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.\n\n### `perf` (enforce=False, wired=True)\n\nStrix 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.\n\n### `quality` (enforce=False, wired=True)\n\nTriage 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.\n\nWiring `followups` into CI (report script + workflow step) is tracked in #2040.\n\n",
      "scorecard": "# Email Triage — Eval Scorecard v0.5.0\n\n**Aggregate score: 84.53** (out of 100)\n\n## Recipe\n\n| Field | Value |\n|-------|-------|\n| Dataset | [tests/fixtures/email/ground_truth.json](tests/fixtures/email/ground_truth.json) |\n| 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 |\n| Dataset size | 299 labeled examples |\n| Test cases run | 250 |\n| 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 (|rank diff|<=1) — what users feel (nothing urgent buried). Reported secondaries (not in the aggregate): urgent-vs-not binary accuracy, urgent recall (anti-gaming floor), and exact 4-way category_accuracy. The corpus uses the schema-2.0 taxonomy aligned with the agent's output labels (#1874); averaged over 3 run(s) for run-to-run variance/CI (#1894) |\n\n## Metrics\n\n  - **within_one_bucket_accuracy**: 0.8453 × 1.0\n  - **urgent_vs_not_accuracy**: 0.7987 × 0.0\n  - **urgent_recall**: 1.0000 × 0.0\n  - **personal_recall**: 0.3636 × 0.0\n  - **category_accuracy**: 0.7813 × 0.0\n  - **draft_approval_rate**: 0.6111 × 0.0\n\n## Aggregate score recomputation\n\nFormula: `round(100 × Σ(weightᵢ × valueᵢ) / Σ(weightᵢ), 2)`\n\nWorked example:\n\n```\nround(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\n```\n\nA reader can reproduce this value from the `aggregate.components` in the front\nmatter alone — no eval-harness access needed.\n\n## Reproduction\n\nRun the following commands from the repository root:\n\n```sh\n# Prerequisites: install the eval extras and start a Lemonade Server\n# with the model on AMD Ryzen AI hardware (Strix Halo recommended).\nuv pip install -e \".[dev,eval,api]\"\nlemonade-server serve   # in a separate shell; must stay running\n\n# Step 0: build the corpus from the committed seed. The mbox +\n# ground_truth are GENERATED artifacts (gitignored), so a fresh\n# checkout must materialise them before the benchmark can read them.\npython tests/fixtures/email/generate_mbox.py\n\n# Step 1: run the benchmark (requires the Lemonade Server above with the\n# model loaded; AMD Ryzen AI / Strix Halo recommended)\nPYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \\\nGAIA_AGENT_TOOL_TIMEOUT=1800 \\\nPYTHONPATH=\"$(pwd)\" \\\ngaia eval benchmark \\\n    --model Gemma-4-E4B-it-GGUF \\\n    --mbox-path tests/fixtures/email/synthetic_inbox.mbox \\\n    --ground-truth tests\\fixtures\\email\\ground_truth.json \\\n    --limit 250 \\\n    --output-dir /tmp/email-eval\n\n# Step 2: generate this scorecard from the benchmark output\nPYTHONPATH=\"$(pwd)\" \\\npython hub/agents/email/python/packaging/gen_scorecard.py \\\n    --benchmark-dir /tmp/email-eval \\\n    --ground-truth tests\\fixtures\\email\\ground_truth.json \\\n    --limit 250\n\n# Background, dataset details, a worked example, and metric\n# definitions: see EVALUATION.md (next to this scorecard).\n```\n\nSee [eval-scorecard docs](https://amd-gaia.ai/docs/reference/eval-scorecard) and the [`adding-eval-scorecard` skill](.claude/skills/adding-eval-scorecard/SKILL.md) for the full setup guide.\n\n## Environment\n\n| Field | Value |\n|-------|-------|\n| gaia_commit | eca42a0e |\n| lemonade_version | 10.10.0 |\n| model | Gemma-4-E4B-it-GGUF |\n| ctx_size | 16384 |\n| hardware | AMD Ryzen AI MAX+ (Strix Halo) |\n\n## Category breakdown (pooled across all 3 runs)\n\n_Each of the 250 test cases is scored once per run, so the totals below sum to test_cases_run × 3._\n\n| Category | Total | Correct | Accuracy |\n|----------|-------|---------|----------|\n| fyi | 162 | 124 | 0.7654 |\n| needs_response | 162 | 162 | 1.0000 |\n| personal | 99 | 36 | 0.3636 |\n| promotional | 165 | 112 | 0.6788 |\n| urgent | 162 | 152 | 0.9383 |\n\n**Top confusions:**\n\n  - personal → needs_response: 44\n  - promotional → urgent: 40\n  - fyi → needs_response: 38\n  - personal → urgent: 16\n  - promotional → needs_response: 13\n\n## Performance\n\n_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`)._\n\n| Metric | Value |\n|--------|-------|\n| ttft_s | 24.673 |\n| throughput_tps | 23.767 |\n| pipeline_s | 6926.411 |\n| total_input_tokens | 316983.667 |\n| total_output_tokens | 169056.667 |\n| tokens_per_triage | 1906.033 |\n| llm_classified_count | 250.0 |\n| emails_per_run | 250 |\n\n## Capability quality\n\n_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/`._\n\n| Capability | Metric | Value |\n|------------|--------|-------|\n| spam | precision | 0.1078 |\n| spam | recall | 0.3333 |\n| spam | f1 | 0.1629 |\n| action_items | precision | 0.0000 |\n| action_items | recall | 0.0000 |\n| action_items | f1 | 0.0000 |\n| briefing | approval | 0.0000 |\n| briefing | must_include_recall | 0.0500 |\n| briefing | faithful | 1.0000 |\n| briefing | hallucination_free | 1.0000 |\n",
      "npm_package": "@amd-gaia/agent-email",
      "playground_url": "http://127.0.0.1:8131/v1/email/playground",
      "eval_scorecard_url": "https://agent-hub.kalin-bb5.workers.dev/agents/email/0.6.0/SCORECARD.md",
      "eval_score": 84.53
    }
  ]
}