{
  "schema_version": 1,
  "generated_at": "2026-09-16T17:04:59.090Z",
  "agents": [
    {
      "id": "agent-ui",
      "name": "GAIA Agent UI",
      "description": "Desktop app for chatting with GAIA agents, with documents and RAG",
      "category": "tools",
      "latest_version": "0.24.1",
      "icon": "monitor",
      "language": "typescript",
      "type": "app",
      "author": "AMD",
      "security_tier": "experimental",
      "download_size_bytes": 129847325,
      "tags": [
        "desktop",
        "ui",
        "chat",
        "electron",
        "rag"
      ],
      "tools_count": 0,
      "models": [],
      "min_gaia_version": "0.22.0",
      "permissions": [],
      "deprecated": false,
      "requirements": {
        "min_memory_gb": 2,
        "min_disk_gb": 0,
        "min_context_size": 0,
        "platforms": [
          "win-x64",
          "linux-x64",
          "darwin-arm64"
        ],
        "npu": "optional",
        "gpu_vram_gb": 0
      },
      "readme": "",
      "changelog": "",
      "spec": "",
      "skill": "",
      "evaluation": "",
      "capability_matrix": "",
      "scorecard": "",
      "npm_package": "@amd-gaia/agent-ui"
    },
    {
      "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",
      "type": "agent",
      "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,
      "eval_score_version": "0.5.0"
    },
    {
      "id": "gaia",
      "name": "GAIA",
      "description": "The flagship GAIA agent — conversation, document Q&A, data analysis, and web research, with persistent memory and add-on skills",
      "category": "general",
      "latest_version": "0.2.0",
      "icon": "sparkles",
      "language": "python",
      "type": "agent",
      "author": "AMD",
      "security_tier": "verified",
      "download_size_bytes": 140015368,
      "tags": [
        "general",
        "chat",
        "rag",
        "memory",
        "skills",
        "research"
      ],
      "tools_count": 78,
      "models": [
        "Gemma-4-E4B-it-GGUF"
      ],
      "min_gaia_version": "0.24.1",
      "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/gaia\n\nOne command gets you a working GAIA:\n\n```bash\nnpx @amd-gaia/gaia\n```\n\nThat fetches the two binaries GAIA needs — the agent sidecar and the terminal UI —\nverifies both against a checksum manifest that ships inside this package, and drops\nyou into the terminal UI. No Python to install, no repo to clone, no build step.\nLocal inference is the default. The TUI can explicitly select Fireworks AI or AMD\nLLM Gateway through Lemonade; remote chat sends conversation history to that provider. The\nterminal UI's `--use-claude` flag, which would send a conversation to the\nAnthropic API, is refused for the agent this package installs — see\n[`SPEC.md` §5.5](./SPEC.md#55-other-transports).\n\nThe terminal UI you get here is the published **`terminal-hub`** component — the\nexact same binary a full GAIA install runs as `gaia tui`, not a separate build. So\nhowever you arrive at the terminal UI, it behaves identically.\n\n## What it actually does\n\n1. **Resolves your platform** — `win32-x64`, `darwin-arm64`, `darwin-x64`,\n   `linux-x64` (plus `linux-arm64` / `win32-arm64` for the terminal UI).\n2. **Reads `binaries.lock.json`**, the checksum manifest published with this exact\n   package version. It records, per binary, which hub lane it comes from and what\n   it must hash to.\n3. **Downloads and SHA-256 verifies both binaries.** A hash that does not match is\n   a hard failure — the download is deleted and the run stops. There is no\n   \"continue anyway\" path and no unverified fallback.\n4. **Launches the terminal UI**, which brings up the GAIA daemon and the agent\n   sidecar and hands you the chat. Its exit code becomes ours.\n\n## Requirements\n\n- **Node.js 18+** (for the built-in `fetch`).\n- **[Lemonade Server](https://amd-gaia.ai/docs/reference/dev)** running locally —\n  it hosts the model the agent thinks with. GAIA tells you if it isn't up.\n- The `gaia` Python CLI **0.23.1 or newer** on `PATH` for the daemon the terminal\n  UI starts. Earlier cores start a daemon that has no entry for this agent, so the\n  UI comes up with nothing behind it. Install it with\n  `curl -fsSL https://amd-gaia.ai/install.sh | sh` (Windows:\n  `irm https://amd-gaia.ai/install.ps1 | iex`).\n\n## Supported platforms\n\nThe terminal UI is Go and cross-compiles everywhere. The agent sidecar is a frozen\nPython build, produced on the machine it targets, and **has no arm64 Linux or arm64\nWindows build**. On those two platforms `npx @amd-gaia/gaia` stops with an error\nnaming your platform and the ones that do work — it will not start a UI with no\nagent behind it.\n\n| Platform key   | Agent sidecar | Terminal UI |\n| -------------- | :-----------: | :---------: |\n| `win32-x64`    |       ✅      |     ✅      |\n| `darwin-arm64` |       ✅      |     ✅      |\n| `darwin-x64`   |       ✅      |     ✅      |\n| `linux-x64`    |       ✅      |     ✅      |\n| `linux-arm64`  |       —       |     ✅      |\n| `win32-arm64`  |       —       |     ✅      |\n\n`npx @amd-gaia/gaia version` prints this matrix, plus the version and source URL of\neach binary, for the release you have installed.\n\n## Commands\n\n```\ngaia [run] [options] [-- <tui args>]   Fetch + verify both binaries, then launch the TUI\ngaia fetch [options]                   Download + verify only; print JSON and exit\ngaia serve [options]                   Run the agent sidecar alone (REST API, no TUI)\ngaia version                           Print the lock manifest and this host's platform\ngaia help                              Show help\n```\n\nAnything after a bare `--` goes to the terminal UI untouched:\n\n```bash\nnpx @amd-gaia/gaia -- --debug\n```\n\nCommon options:\n\n| Flag                  | Meaning                                                       | Accepted by |\n| --------------------- | ------------------------------------------------------------- | ----------- |\n| `--base-url <url>`    | Override the download base URL from `binaries.lock.json`. Must be `https:` | `run`, `fetch`, `serve` |\n| `--allow-insecure-base-url` | Permit a non-`https` `--base-url` (a trusted local mirror) | `run`, `fetch`, `serve` |\n| `--sidecar-dir <dir>` | Where to install the agent sidecar (default `~/.gaia/agents/gaia`) | `run`, `fetch`, `serve` |\n| `--cache-dir <dir>`   | Where to cache the terminal UI binary                          | `run`, `fetch` |\n| `--force`             | Re-download even when a verified binary is already cached      | `run`, `fetch`, `serve` |\n| `--platform <key>`    | Fetch for another platform                                     | `fetch` |\n| `--component <name>`  | Fetch only `sidecar` or `tui`                                  | `fetch` |\n| `--port <n>`          | Sidecar bind port (default `8141`)                             | `serve` |\n\nA flag a command does not read is **refused**, not ignored — `gaia run --port\n9000` exits 2 rather than silently coming up on the default port.\n\nSet `DEBUG=gaia` for download, spawn, and sidecar output on stderr. Diagnostics\nnever touch stdout, which the terminal UI owns.\n\n## Where things land\n\n| What            | Path                                     |\n| --------------- | ---------------------------------------- |\n| Agent sidecar   | `~/.gaia/agents/gaia/gaia-agent[.exe]`   |\n| Install record  | `~/.gaia/agents/gaia/.installed`         |\n| Terminal UI     | `~/.gaia/npm-cache/gaia-<version>/gaia-tui[.exe]` |\n\nThe sidecar goes into the GAIA daemon's own cache directory on purpose: the daemon\nis what spawns and supervises it, and it does its own SHA-256 check on the way. By\nputting an already-verified binary there we save a second download rather than\nracing one.\n\nThe `.installed` record next to it is what the daemon and the terminal UI read to\nknow the agent is installed — without it the UI would run the sidecar as its own\nstdio child instead of letting the daemon supervise it. It is rewritten even when\nthe binary was already cached, so an install left by an earlier version repairs\nitself the next time you run. A `--platform` fetch stages a binary for a\n*different* machine, so it deliberately leaves no record.\n\nThe terminal UI is installed as `gaia-tui`, **never** as `gaia` — a file named\n`gaia` in a cache directory would shadow the `gaia` shim npm puts on your `PATH`.\n\n## Ports\n\n| Service       | Port                              |\n| ------------- | --------------------------------- |\n| Agent sidecar | `8141` on `127.0.0.1`             |\n| GAIA daemon   | assigned at start, recorded in `~/.gaia/host/instance.json` |\n\nPort **4001 is reserved repo-wide** and is refused with an error if you pass it.\nBoth services bind loopback only — this agent speaks for your documents and memory\nand has no business on a LAN interface.\n\n## Running the sidecar on its own\n\n`gaia serve` skips the terminal UI and gives you the REST surface directly, for\nintegrating GAIA into your own app:\n\n```bash\nnpx @amd-gaia/gaia serve --port 8141\ncurl http://127.0.0.1:8141/health\n```\n\nIt waits for `GET /health`, checks the contract version, and tears the whole\nprocess tree down on Ctrl+C. See [`SPEC.md`](./SPEC.md) for the endpoints.\n\nThe sidecar normally requires a per-session bearer token on `/v1/gaia/*`, but\nneither `serve` nor `startSidecar` mints one, so both leave it in dev mode — the\ntoken check off, `Host`/`Origin` still enforced. This agent has shell and file\ntools, so before you expose it to anything, supply a token of your own: see\n[`SPEC.md` §5.4](./SPEC.md#54-caller-authentication).\n\n## Programmatic use\n\n```ts\nimport { randomUUID } from \"node:crypto\";\nimport { fetchAll, startSidecar, shutdown } from \"@amd-gaia/gaia\";\n\nconst { sidecar } = await fetchAll();               // both binaries, SHA-256 verified\nconst proc = await startSidecar({ binaryPath: sidecar.binaryPath });\n\nconst sessionId = randomUUID(); // reuse across the whole conversation, see below\n\nconst res = await fetch(`${proc.baseUrl}/v1/gaia/query`, {\n  method: \"POST\",\n  headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify({\n    query: \"summarize my notes\",\n    run_id: randomUUID(),\n    session_id: sessionId,\n    context: [],\n  }),\n});\n\nawait shutdown(proc);\n```\n\n`/v1/gaia/query` streams Server-Sent Events terminated by exactly one `final` or\n`error`. `fetchAll()` also returns the TUI's path if you would rather launch that.\n\n**Reuse the same `session_id` for every turn in a conversation.** It is what\nlets a document you had it index, or a skill you had it load, survive to the\nnext question — drop it (or mint a new one per call) and the agent still\nanswers, but it forgets everything from the previous turn. See\n[`SPEC.md` §5.2](./SPEC.md#52-session_id-and-agent-retention) for the retry\nand eviction behavior.\n\nEvery failure throws a typed error (`IntegrityError`, `PlatformError`,\n`HealthTimeoutError`, `VersionMismatchError`, `BinaryNotFoundError`) with a message\nthat names what failed and what to do about it.\n\n## Where the binaries come from\n\nThe two binaries ship from two different places, and `binaries.lock.json` records a\nversion and a source URL for each:\n\n| Binary        | Published as                              | Built by                         |\n| ------------- | ----------------------------------------- | -------------------------------- |\n| Agent sidecar | the `gaia` agent, at this package's version | this package's release           |\n| Terminal UI   | the `terminal-hub` component, at its own version | the core GAIA release       |\n\nThe terminal UI is **consumed, not rebuilt**. It is byte-for-byte the `gaia tui`\nbinary a core install ships, so there is no second copy that could lag behind or\nbehave differently — which is the entire reason it is sourced this way.\n\n## Integrity\n\n`binaries.lock.json` is the single source of truth for what gets downloaded and\nwhat it must hash to. The release pipeline regenerates it from the artifacts\nactually being served — the sidecars it just published, and the terminal-hub\nartifacts it downloaded and cross-checked against the hub's own recorded hashes.\n\nBetween releases the lock carries `PENDING-replace-with-real-sha256` placeholders.\n**A placeholder blocks the fetch outright** — before any network call — so an\nunverifiable binary can never be downloaded, let alone executed. If you need to run\nagainst a locally built binary, build it yourself and point the lifecycle helpers\nat it directly; the fetcher will not be talked into it.\n\n## Links\n\n- Guide: <https://amd-gaia.ai/docs/guides/gaia>\n- Technical reference: [`SPEC.md`](./SPEC.md)\n- Changes: [`CHANGELOG.md`](./CHANGELOG.md)\n- Issues: <https://github.com/amd/gaia/issues>\n\nMIT licensed. © 2024-2026 Advanced Micro Devices, Inc.\n",
      "changelog": "# Changelog\n\nAll notable changes to `@amd-gaia/gaia` are documented here. The format follows\n[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this package adheres\nto [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [0.1.1] — unreleased\n\nFirst working release. `npx @amd-gaia/gaia` is now the single command that gets a\nuser running GAIA: it fetches and verifies everything GAIA needs and drops them\ninto the terminal UI. Before this there was no packaged path at all — the flagship\nagent had to be run from a repo checkout with a Python environment, and reaching\nthe terminal UI meant building it from source.\n\n### Fixed\n\n- Windows npm launchers now find the Python daemon CLI even when npm passes the\n  package script as argv[1], preserving unrelated tools in shared PATH directories.\n\n### Added\n\n- **Image generation, reachable out of the box.** \"Draw me a red bicycle\" now\n  generates a PNG with local Stable Diffusion and reports the path; previously\n  the tools existed behind a flag nothing turned on, so the agent just said it\n  couldn't. Adds `generate_image`, `list_sd_models`, and `get_generation_history`\n  (70 tools → 73) plus an `image_gen` bundle so per-turn selection can find them.\n  Generating swaps the resident model, so the next reply waits for the chat model\n  to reload. The `image-gen` starter skill covers prompt expansion and iterating\n  on the previous image.\n- TUI provider setup for Local, Fireworks AI, and AMD LLM Gateway, with masked\n  runtime API keys, discovered models, and remote-inference status.\n\n- **A project map at task start.** In a code repository the agent now opens\n  every task knowing the directory shape, the likely entry points, which\n  commands are installed, and the three platform differences that change\n  command syntax — instead of discovering each one through a failed tool call.\n  Capped at 600 prompt tokens. If the repository has no code index the map\n  starts one in the background; `GAIA_PROJECT_MAP_AUTO_INDEX=0` turns that off,\n  and `GAIA_PROJECT_ROOT` picks the project when the working directory is not\n  it. See SKILL §11.\n- **`503` from `/query` at session capacity.** When every retained session\n  slot is busy and none is idle enough to evict, starting a new session\n  returns `503` with the reason in `detail` — retryable, distinct from a\n  bug-shaped `500`. See SPEC §5.2.\n- **Three further client-visible refusals from `/query`.** Reusing a `run_id`\n  that is still in flight gets `409` — it used to replace the live run, leaving\n  it with no way to be cancelled. Supplying a `model` that differs from the one\n  the `session_id` was built with gets `409` rather than silently answering on\n  the old model. A request with an absent or empty `Host` header gets `400`\n  rather than being served, closing a DNS-rebinding check that failed open.\n  See SPEC §5 and §5.2.\n- **Per-turn skill-body selection.** A loaded skill stays loaded, but its body\n  only renders in the prompt on turns whose query matches its description; the\n  rest collapse to a one-line menu the model re-activates with `load_skill`.\n  `GAIA_DYNAMIC_SKILLS=0` turns the selection off, `GAIA_DYNAMIC_SKILLS_TAU`\n  overrides the match threshold, and an embedder outage disables it for the\n  session (every body renders — capability is never lost to a failed match).\n- **Per-turn tool selection, now on by default for the flagship `full`\n  profile.** The model is sent at most 26 of its 71 tools on any one call — a\n  fixed core plus whichever cohesion bundles the query matched — instead of the\n  whole registry every time. No capability is lost: `load_tools` is an escape\n  hatch the model calls mid-turn to pull in a bundle the selector missed.\n  `GAIA_DYNAMIC_TOOLS=0` turns the selection off, `GAIA_DYNAMIC_TOOLS_MAX`\n  moves the cap and `GAIA_DYNAMIC_TOOLS_TAU` the match threshold.\n- **One bundled skill ships enabled: `gaia-voice`.** It is a manifest `skills:`\n  entry, so it is always on and rendered in full on every LLM call — 676 tokens\n  of every prompt, and it declares no tools. It is the agent's honesty floor\n  (don't claim work you didn't do, don't present empty output as a result,\n  don't substitute a near-miss and report success), which is why it is not in an\n  opt-in bundle the way a task skill is. No *skill set* ships enabled: the\n  manifest's `skill_sets:` / `default_skill_set:` blocks stay commented out until\n  an eval measures what loading several bodies costs. See SKILL.md §10.\n- **A materially smaller fixed prompt on every call.** That always-on\n  `gaia-voice` guidance was rewritten from 2,129 tokens to 676 with all of its\n  behavioural rules intact. It had been a rationale document — every rule\n  followed by the incident that motivated it — and the model needs the rule,\n  not the incident report.\n- **`gaia run` (the default command)** — resolves the host platform, fetches and\n  SHA-256 verifies both binaries, then launches the terminal UI and propagates its\n  exit code. Arguments after a bare `--` are forwarded to the TUI verbatim.\n- **Dual-binary delivery.** The package installs two published artifacts: the\n  frozen agent sidecar (`gaia-agent`), published by this package's own release,\n  and the terminal UI (`gaia-tui`), which is the published **`terminal-hub`**\n  component. The TUI is consumed, not rebuilt — it is byte-for-byte the binary a\n  full GAIA install runs as `gaia tui`, so an npm user and a core user cannot end\n  up on terminal UIs that behave differently. A second build under this package's\n  own lane would have been the same bytes at a different version under a third\n  naming convention, and the two would have drifted.\n- **`binaries.lock.json` `schemaVersion` 3.0** — a component-keyed checksum\n  manifest where **each component carries its own `componentVersion`, `baseUrl`\n  and `platforms`**. Component-first rather than the email agent's flat `binaries`\n  map because the two differ in every dimension: hub lane, version, and platform\n  coverage (terminal-hub covers arm64 Linux and arm64 Windows; the PyInstaller\n  sidecar does not). A single shared base URL cannot address two lanes, so a\n  `1.x`- or `2.x`-shaped lock is rejected at load with an error naming the schema.\n- **Terminal-hub artifact naming is handled in data.** That lane names its Windows\n  builds `gaia-win-x64.exe` / `gaia-win-arm64.exe`, while platform keys come from\n  `process.platform` and say `win32`. The lock keeps the `win32-*` key and carries\n  the hub's spelling in `filename`, so nothing branches on platform to construct a\n  URL. The mapping is asserted on both sides (`TUI_ARTIFACT_NAMES` in\n  `src/platform.ts` and in the lock generator) because a wrong name there is not a\n  build failure anywhere — it is a 404 on a user's first run.\n- **Mandatory SHA-256 verification.** Every download is hashed and compared\n  against the lock before it is written. A mismatch deletes the download and\n  raises `IntegrityError` naming expected vs actual. A placeholder hash blocks the\n  fetch before any network call. There is no flag that relaxes either.\n- **`gaia fetch`** — download and verify without launching; prints JSON. Supports\n  `--component` and `--platform` for cross-platform staging in CI.\n- **`gaia serve`** — run the agent sidecar alone on `127.0.0.1:8141` for\n  integrators who want the REST surface without a daemon or a UI. Health-polls\n  `GET /health`, checks the contract version, and tree-kills on exit. Port `4001`\n  is refused.\n- **`gaia version`** — prints, per component, its version, the URL it is fetched\n  from, and its platform matrix.\n- **Programmatic exports** — `fetchAll`, `startSidecar`, `shutdown`, `runTui`, the\n  platform helpers, and the typed error classes, for embedding GAIA in another\n  app.\n- **The `.installed` record is written after a verified sidecar install.**\n  Staging the binary was only half an install: the daemon and the terminal UI\n  both key \"this agent is installed\" on `~/.gaia/agents/gaia/.installed`, and\n  without it the UI ran the REST sidecar as its own stdio child and the chat\n  filled with uvicorn's startup log. It is written on a cache hit as well as a\n  fresh download, so an install left by an earlier version repairs itself, and\n  only for this host's own platform — a `--platform` fetch stages a binary for a\n  different machine and records nothing. See SPEC §4.1.\n- **`--allow-insecure-base-url`** — opt-in for a non-`https` `--base-url`, for a\n  trusted local mirror.\n\n### Changed\n\n- **A `LEMONADE_BASE_URL` that already carries a path is now used exactly as\n  written.** Previously any URL not ending in `/api/v1` had that suffix appended,\n  so a reverse proxy configured as `https://proxy.example/lemonade` was silently\n  rewritten to `https://proxy.example/lemonade/api/v1`. It now resolves to\n  `https://proxy.example/lemonade` unchanged, and only a bare origin with no path\n  at all gains `/api/v1`. If a proxied install starts returning `404` after\n  upgrading, append the API path to the variable yourself.\n\n### Fixed\n\n- **A second `gaia serve` no longer reports success against a server it does not\n  own.** With the port already taken, the incumbent answered `/health` while our\n  own sidecar was still unpacking, so the start \"succeeded\", printed a ready URL\n  for someone else's server, and the child then died of `EADDRINUSE`. The port is\n  now checked before anything is spawned, and a taken one fails naming the port\n  and how to find the process holding it.\n- **Importing the package no longer changes a host app's error handling.** The\n  crash and signal handlers reaped every sidecar *before* checking whether the\n  host had its own handler, so an exception the host handled killed the sidecar\n  and the host's next request got an unexplained `ECONNREFUSED`.\n- **`gaia run` no longer breaks the terminal UI's `PATH`.** Hiding our own `gaia`\n  shim removed the whole bin directory, which on a Homebrew or pipx layout also\n  took `python3`, `lemonade-server`, and the real `gaia` with it — so the UI\n  reported a missing CLI that we had hidden. A shared directory now moves to the\n  end of `PATH` instead of being dropped.\n- **Flags a command does not read are refused, not ignored.** `run --port 9000`\n  parsed fine and came up on the default port; likewise `serve --component` and\n  `serve --cache-dir`.\n- **A failed install says what to do.** A download that could not be moved into\n  place — usually a running sidecar holding the file on Windows — printed a raw\n  stack trace.\n- **`taskkill` failures name their reason.** Its exit code and stderr were\n  discarded, so \"Access is denied\" surfaced ten seconds later as a generic\n  timeout.\n- **A sidecar that survives shutdown is still reaped at exit.** `shutdown`\n  de-registered it before killing it, so one that survived both kill windows\n  became a permanent orphan holding the port.\n- **`serve` removes its signal handlers once it stops**, so Ctrl+C keeps working\n  afterwards, and a repeat Ctrl+C during a slow teardown now says what it is\n  waiting for instead of looking frozen.\n- **A non-JSON reply from a sidecar probe raises a typed error** rather than a\n  bare `SyntaxError` — the case where a proxy answers `200` with an HTML page.\n- **`--port` no longer accepts what `Number()` would coerce**, so `--port 0x2710`\n  is rejected instead of quietly binding 10000.\n\n### Security\n\n- **A desktop notification can no longer run code.** On Windows, `notify_desktop`\n  rendered its message box by pasting the title and body into a PowerShell command\n  string, so a `'` in either — text the model picks, and prompt-injected content\n  can steer it — closed the string literal and the remainder ran as PowerShell.\n  The command is now a fixed script that reads both values from the child's\n  environment, and the tool now needs your approval before it runs, like the\n  other tools that spawn a process (SKILL.md §8).\n- **`resolveSidecarPath` / `resolveTuiPath` verify the binary before returning a\n  path that gets spawned.** Both fed `spawn()` from a predictable cache path with\n  no integrity check, so anything able to write `~/.gaia/agents/gaia/` got code\n  run — despite the package documenting the SHA verify as its security boundary.\n  They now re-hash against `binaries.lock.json`; pass `{ verify: false }` for a\n  binary you built yourself. Note this makes them proportional to the binary's\n  size — resolve once at startup, not per request.\n- **`--base-url` requires `https`** unless `--allow-insecure-base-url` is passed.\n  The pinned SHA already made a plaintext mirror non-exploitable, but tampering\n  surfaced as a confusing `IntegrityError` instead of the transport failure it is.\n- **A ~200MB artifact is never held in memory whole.** Downloads stream to disk,\n  and the cache-hit check that re-hashes an already-installed binary — which runs\n  on every `gaia run` — reads it in bounded chunks. Both hashes are computed\n  incrementally, and a download is still verified *before* the file is moved into\n  place.\n\n### Fixed\n\n- **Esc stops a running turn in the terminal UI without killing the agent.**\n  The TUI used to kill the agent process, and on the released one-file binary\n  that killed only the launcher: the cancelled tool call ran to completion and\n  the surviving process consumed the next message. The first Esc now sends the\n  agent a `cancel` control message, so the turn ends and the session keeps its\n  loaded skills, \"always\" grants, history and bypass mode. A second Esc stops\n  the whole process tree.\n- **A restart after a hard stop no longer turns bypass permissions back on.**\n  The replacement agent is launched in the session's current permission mode\n  instead of from the original flags, and the TUI says what the restart lost.\n\n### Notes\n\n- The sidecar is installed into `~/.gaia/agents/gaia/`, the GAIA daemon's own\n  cache directory. The daemon spawns and supervises the sidecar; putting an\n  already-verified binary where it looks turns its fetch into a cache hit instead\n  of a second large download. `run` therefore does not spawn a sidecar itself —\n  the terminal UI reaches agents through the daemon relay and never holds a\n  sidecar token, so a second process would only contend for the port. `serve` is\n  the direct path for callers who do want to own it.\n- The TUI is installed as `gaia-tui`, never as `gaia`, so it cannot shadow the\n  `gaia` bin shim npm places on `PATH` — the terminal-hub artifact itself is named\n  `gaia-<platform>`, which is why the lock separates `filename` from `executable`.\n- Because the TUI comes from the `terminal-hub` lane, this package cannot be\n  released until that component is published at the version the lock pins. The\n  release fails loudly naming the required version; it never falls back to\n  building its own TUI. Each terminal-hub artifact is additionally cross-checked\n  against the hub's own server-side SHA-256 before its hash enters the lock.\n- Requires Node.js 18+ (built-in `fetch`), a running Lemonade Server for\n  inference, and the `gaia` Python CLI 0.23.1+ on `PATH` for the daemon the TUI\n  starts. 0.23.1 is the first core whose daemon knows how to supervise this\n  agent; on 0.23.0 the UI starts with nothing behind it.\n- The sidecar has no arm64 Linux or arm64 Windows build. On those platforms the\n  run stops with an error naming the platform and the supported set rather than\n  launching a UI with no agent behind it.\n- `gaia_agent` enforces a per-session caller-auth bearer on every `/v1/gaia/*`\n  request, plus a loopback `Host` allowlist and non-loopback `Origin` rejection.\n  This package mints no token, so a sidecar it spawns comes up in dev mode (token\n  check skipped, loudly warned, Host/Origin still enforced) — pass your own\n  through `spawnSidecar`'s `env` to turn it on. See SPEC §5.4.\n- Tracks sidecar contract `apiVersion` **2.12**; a differing major raises\n  `VersionMismatchError`.\n",
      "spec": "# `@amd-gaia/gaia` — technical reference\n\nVersion **0.1.1**. Companion to [`README.md`](./README.md), which is the\nuser-facing doc. This file specifies the wire and file formats the package\ndepends on and the guarantees it makes.\n\n---\n\n## 1. Scope\n\n`@amd-gaia/gaia` is a **binary delivery and process-lifecycle package**. It ships\nno agent logic. It owns:\n\n- resolving the host platform to a lock key,\n- downloading and **SHA-256 verifying** two published binaries — one from this\n  package's own hub lane, one from the `terminal-hub` component's,\n- caching them in stable, versioned locations,\n- launching the terminal UI, or the agent sidecar directly.\n\nIt does **not** own the GAIA daemon's lifecycle, the sidecar's supervision, or\nany agent behaviour. See §6 for where those boundaries sit.\n\n---\n\n## 2. `binaries.lock.json`\n\nShips inside the published package (`files` includes it). It is the single source\nof truth for what is downloaded and what it must hash to.\n\n### 2.1 Schema (`schemaVersion` `3.0`)\n\n```jsonc\n{\n  \"schemaVersion\": \"3.0\",\n  \"agentVersion\": \"0.1.1\",\n  \"components\": {\n    \"sidecar\": {\n      \"componentVersion\": \"0.1.1\",\n      \"baseUrl\": \"https://hub.amd-gaia.ai/agents/gaia/0.1.1\",\n      \"platforms\": { \"<platformKey>\": { /* entry */ } }\n    },\n    \"tui\": {\n      \"componentVersion\": \"0.23.0\",\n      \"baseUrl\": \"https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0\",\n      \"platforms\": { \"<platformKey>\": { /* entry */ } }\n    }\n  }\n}\n```\n\nA component lane:\n\n| Field              | Type     | Meaning                                                    |\n| ------------------ | -------- | ---------------------------------------------------------- |\n| `componentVersion` | `string` | That component's own released version                       |\n| `baseUrl`          | `string` | Where **that component's** artifacts are served from        |\n| `platforms`        | `object` | Platform key → entry                                        |\n\nAn entry:\n\n| Field        | Type     | Meaning                                                     |\n| ------------ | -------- | ----------------------------------------------------------- |\n| `filename`   | `string` | Artifact name as published under **its component's** `baseUrl` |\n| `executable` | `string` | Basename it is written as on disk (with the platform extension) |\n| `sha256`     | `string` | Lowercase hex SHA-256 the download must match                |\n| `size`       | `number` | Informational. **Not** enforced                              |\n\n**Why per-component, and why `3.0`.** `2.0` had one top-level `baseUrl` shared by\nboth components. That stopped being true once the TUI became the published\n`terminal-hub` component (§2.2): the two live in different hub lanes at different\nversions. A shared base URL cannot express that, so `2.x` is rejected at load with\nan error naming the schema — never read as `3.x` with a missing field.\n\nThe email agent's lock is `schemaVersion` `1.0`, a flat `binaries` map, because it\ndelivers one binary from one lane. A `1.x`-shaped lock is likewise rejected.\n\n### 2.2 Where each component comes from\n\n| Component | Hub lane                              | Published by            |\n| --------- | ------------------------------------- | ----------------------- |\n| `sidecar` | `agents/gaia/<agentVersion>/`         | this package's release  |\n| `tui`     | `agents/terminal-hub/<componentVersion>/` | the core GAIA release |\n\nThe TUI is **consumed, not built here**. It is the same `tui/cmd/gaia` binary the\ncore release publishes as the `terminal-hub` component and a core install runs as\n`gaia tui` — so behaviour is identical by construction rather than by convention.\nBuilding a second copy under this package's lane would put the same bytes at a\ndifferent version under a third naming convention, and the two would drift.\n\nThe consequence is a real release dependency: this package cannot ship until\n`terminal-hub` is published at the version its lock pins. The release fails loudly\nnaming that version; it never falls back to building its own TUI.\n\nThe `tui` lane's origin is pinned in the lock rather than derived from the release\npipeline's hub-origin variable, so the release verifies the exact URL the shipped\nlock will send users to. A release pointed at a non-default origin therefore moves\nthe sidecar lane and **not** the TUI lane; re-point `components.tui.baseUrl` too if\nyou need both.\n\n### 2.3 Platform keys\n\n`` `${process.platform}-${process.arch}` `` — e.g. `win32-x64`, `darwin-arm64`,\n`linux-x64`. This matches `gaia.daemon.sidecars.platform.current_platform_key()`,\nwhich normalises Python's `sys.platform` / `platform.machine()` into the same\nnamespace so the daemon and this package agree on a cache key.\n\n**The `win32` ↔ `win` mapping.** The `terminal-hub` lane names its Windows\nartifacts `gaia-win-x64.exe` / `gaia-win-arm64.exe` (Go's `GOOS` vocabulary), while\nour keys come from `process.platform`, which says `win32`. The lock keeps the\n`win32-*` key and carries the hub's spelling in `filename`:\n\n| Platform key   | `tui` `filename`         |\n| -------------- | ------------------------ |\n| `win32-x64`    | `gaia-win-x64.exe`       |\n| `win32-arm64`  | `gaia-win-arm64.exe`     |\n| `darwin-x64`   | `gaia-darwin-x64`        |\n| `darwin-arm64` | `gaia-darwin-arm64`      |\n| `linux-x64`    | `gaia-linux-x64`         |\n| `linux-arm64`  | `gaia-linux-arm64`       |\n\nThe mapping therefore lives in **data**, not in a code path — nothing branches on\nthe platform to build a name. `TUI_ARTIFACT_NAMES` (in `src/platform.ts` and in\n`packaging/gen_binaries_lock.py`) is the authority both the shipped lock and the\nrelease pipeline are checked against, because a wrong name here is not a build\nfailure anywhere: it is a 404 on a user's first run.\n\n### 2.4 Platform coverage\n\n| Platform key   | `sidecar` | `tui` |\n| -------------- | :-------: | :---: |\n| `win32-x64`    |    yes    |  yes  |\n| `darwin-arm64` |    yes    |  yes  |\n| `darwin-x64`   |    yes    |  yes  |\n| `linux-x64`    |    yes    |  yes  |\n| `linux-arm64`  |  **no**   |  yes  |\n| `win32-arm64`  |  **no**   |  yes  |\n\n`terminal-hub` publishes all six. The sidecar is a PyInstaller freeze, produced on\nthe platform it targets, and there is no arm64 Linux or arm64 Windows freeze.\nResolving `sidecar` on those two keys raises `PlatformError` naming the platform\nand the supported set — it is not silently skipped, and the TUI is not launched\nwithout an agent behind it.\n\n### 2.5 Placeholder hashes\n\nBetween releases every `sha256` is `PENDING-replace-with-real-sha256`. The release\npipeline regenerates the file with real hashes: for the sidecar, computed from the\nartifacts it just published; for the TUI, computed from the `terminal-hub`\nartifacts it downloaded and cross-checked against that lane's own recorded hashes\n(`agents/terminal-hub/manifest.json`, which the hub computes server-side at publish\ntime). Both are then re-fetched from the public origin and re-hashed before the\nrelease is allowed to ship.\n\n`isPlaceholderSha()` treats a value as a placeholder when it is all zeros or\ncontains `PENDING` (case-insensitive). A placeholder **blocks the fetch before any\nnetwork call** with a `PlatformError`. There is no flag, env var, or option that\nrelaxes this.\n\n---\n\n## 3. Integrity\n\nThe SHA-256 check is the package's security boundary.\n\n- Downloaded bytes are hashed in memory and compared against the lock **before\n  anything is written to the cache path**.\n- On mismatch: `IntegrityError`, message naming expected vs actual, nothing left\n  on disk.\n- Writes go to `<path>.download.<pid>` and are `rename`d into place, so a crash\n  mid-write never leaves a partial file that a later run treats as verified.\n- A cache hit requires re-hashing the on-disk file and matching the lock.\n  A cached file whose bytes drifted is re-downloaded, not reused.\n- POSIX installs `chmod 0o755` after the rename.\n\nThere is no unverified path, no \"warn and continue\", and no way to disable the\ncheck.\n\n---\n\n## 4. Filesystem layout\n\n| Component | Default directory              | Executable          |\n| --------- | ------------------------------ | ------------------- |\n| `sidecar` | `~/.gaia/agents/gaia/`         | `gaia-agent[.exe]`  |\n| `tui`     | `~/.gaia/npm-cache/gaia-<agentVersion>/` | `gaia-tui[.exe]` |\n\nThe sidecar directory is a **cross-repo contract** with\n`gaia.daemon.sidecars.fetch.default_cache_dir(\"gaia\")`. The daemon spawns and\nsupervises the sidecar and does its own SHA-256 check on the binary it finds\nthere; installing an already-verified binary at that path turns the daemon's fetch\ninto a cache hit instead of a second multi-hundred-megabyte download.\n\n### 4.1 The `.installed` sentinel\n\nInstalling the sidecar also writes `~/.gaia/agents/gaia/.installed`, the record\nthe daemon and the TUI both read to answer \"is this agent installed?\". It is\nwritten on a **cache hit as well as a fresh download**, so an install staged by\nan earlier release repairs itself on the next run, and only when the fetch\nproduced a runnable local install — that is, for the `sidecar` component (the\nTUI is not a hub agent) **and** for this host's own platform. A `--platform`\nfetch stages a binary for a different host; recording it would hand the daemon a\nwrong-architecture executable that re-hashes correctly and then fails to exec.\n\n```json\n{\n  \"id\": \"gaia\",\n  \"version\": \"<sidecar componentVersion>\",\n  \"language\": \"python\",\n  \"installed_at\": \"<ISO-8601 UTC>\",\n  \"artifact_sha256\": \"<the verified SHA-256>\",\n  \"path\": \"<install directory>\",\n  \"artifact_kind\": \"binary\",\n  \"executable\": \"gaia-agent[.exe]\"\n}\n```\n\nThe shape is a cross-repo contract with `InstalledAgent.to_dict()` in\n`gaia.hub.installer`. Three fields are load-bearing:\n`gaia.daemon.sidecars.fetch._hub_installed_binary` ignores the install unless\n`artifact_kind` is `binary` and `executable` and `artifact_sha256` are non-empty,\nand it **re-hashes the binary and raises `IntegrityError` if the file no longer\nmatches `artifact_sha256`** — so the recorded hash is the one actually verified,\nnever the lock's nominal value. `executable` must be a bare filename;\n`installer.read_sentinel` discards a sentinel whose executable contains a path\nseparator, which reads as \"never installed\".\n\nWithout the sentinel the daemon sees no install and the TUI falls back to running\n`gaia-agent` as its own stdio child — spawning the REST sidecar over stdio and\nfilling the chat with uvicorn's startup log.\n\nThe TUI directory is keyed by `agentVersion` — this package's version, not the\ncomponent's — so a bump of either never reuses the previous release's executable.\n\nThe TUI executable is renamed to `gaia-tui` on install, **never** `gaia`: the\n`terminal-hub` artifact is `gaia-<platform>` and npm installs a `gaia` bin shim on\n`PATH`, so keeping the hub's name would shadow the shim. `filename` (what is\ndownloaded) and `executable` (what is written) are separate fields for exactly this\nreason, and `test/lock.test.ts` asserts it for every platform.\n\nBoth defaults are overridable (`--sidecar-dir`, `--cache-dir`).\n\n---\n\n## 5. Sidecar HTTP surface\n\nServed by `gaia_agent.server`. Bound to `127.0.0.1` only.\n\nEvery request must carry a loopback `Host` header. A `Host` that is non-loopback,\nabsent or empty is refused with `400` before routing — that check is what defeats\nDNS rebinding, so it fails closed rather than serving a request that simply omits\nthe header.\n\n| Property           | Value                                   |\n| ------------------ | --------------------------------------- |\n| Default port       | `8141` (`DEFAULT_PORT` in `server.py`)  |\n| Reserved port      | `4001` — refused with a `RangeError`    |\n| Contract version   | `API_VERSION = \"2.12\"`                  |\n| Agent id / prefix  | `gaia` → `/v1/gaia/...`                 |\n\n### 5.1 Endpoints\n\n| Method | Path                             | Purpose                                        |\n| ------ | -------------------------------- | ---------------------------------------------- |\n| `GET`  | `/health`                        | Liveness. `{ \"status\": \"ok\", \"service\": \"gaia-agent-gaia\" }` |\n| `GET`  | `/version`                       | Contract probe. `{ \"apiVersion\", \"agentVersion\" }` |\n| `GET`  | `/v1/gaia/version`               | The TUI's negotiation probe                    |\n| `GET`  | `/v1/gaia/init`                  | Readiness detail (Lemonade, model, connectors) |\n| `POST` | `/v1/gaia/query`                 | The streaming surface (`text/event-stream`)    |\n| `POST` | `/v1/gaia/query/{run_id}/cancel` | Cancel a run by its host-minted `run_id`       |\n| `POST` | `/v1/gaia/query/{run_id}/respond`| Answer a mid-run question                      |\n\n`/health` is liveness only. It says nothing about whether Lemonade is up or a\nmodel is loaded — `/v1/gaia/init` answers that.\n\n### 5.2 `session_id` and agent retention\n\n`POST /v1/gaia/query` accepts an optional `session_id` in the request body.\n**Pass it on every call in a conversation, and reuse the same value for the\nwhole conversation.** Contract ≥ 2.12 resolves `session_id` to a *retained*\nagent instead of a throwaway built fresh per call — indexed documents and\n`load_skill` state only survive between turns when the same `session_id`\nthreads them together.\n\nA retained skill stays *loaded* but its body is not necessarily in the prompt\nevery turn: the agent selects per turn which loaded bodies match the query and\ncollapses the rest to a one-line menu entry (re-activated by calling\n`load_skill` again). `GAIA_DYNAMIC_SKILLS=0` disables the selection;\n`GAIA_DYNAMIC_SKILLS_TAU=<float>` overrides its threshold; an embedder outage\ndisables it for the session and every body renders. Omitting it is a valid, explicit one-shot: nothing\npersists past that single turn, and the agent is not told otherwise.\n\nA retained session also carries a **project map** — up to 600 prompt tokens of\ndirectory shape, entry points, installed commands and platform quirks, present\nwhenever the agent's working directory resolves to a code repository (a VCS\ndirectory or a recognised manifest at its root). `GAIA_PROJECT_ROOT=<path>`\npicks the project when the working directory is not it. If that repository has\nno code index, the first turn starts one in a background thread;\n`GAIA_PROJECT_MAP_AUTO_INDEX=0` disables that. Neither affects the wire\ncontract — they change what the agent knows and what the first turn costs.\n\nA second `/query` for a `session_id` that already has a turn in flight gets\n`409 Conflict` — cancel the running turn or wait for it, then retry. A\n`/query` that needs a **new** session while every retained slot is busy and\nnone is idle enough to evict gets `503` with the reason in `detail` — a\ntemporary, retryable condition, not a bug: wait for a turn to finish (or\nclose an idle session) and retry. A\n`session_id` can also be evicted from the retention table under an idle\ntimeout (**4 hours** without a turn) or an LRU cap on concurrent sessions\n(**100**) — the two knobs that decide when the `503` above can happen at all\n(`session_registry.py`). Eviction is idle-only: a conversation still in use is\nnever timed out mid-flight. A `/query` that lands on an\nevicted id gets a fresh agent (the conversation is not blocked) but the\nresponse stream's first event is a `{\"type\":\"status\",\"status\":\"warning\",...}`\ntelling the caller that per-turn state — most visibly any loaded skill — did\nnot survive and should be reloaded.\n\nA second `/query` reusing a `run_id` that is still in flight gets `409` —\n`run_id` is caller-minted, so mint a fresh UUID per request; reusing one would\nleave the earlier run with no way to be cancelled. A `/query` supplying a `model`\nthat differs from the one its `session_id` was built with also gets `409`: only\nagent construction reads a model, so the request cannot be honoured on the\nretained agent. Omit `model` to continue on the session's current one, or start a\nnew `session_id` to switch.\n\n### 5.3 Version gate\n\n`checkVersion()` reads `/version` and compares the **major** of `apiVersion`\nagainst this package's `API_VERSION`. A differing major is a breaking contract\nchange and raises `VersionMismatchError`. A higher minor with the same major is a\nbackward-compatible addition and is accepted.\n\n### 5.4 Caller authentication\n\nLoopback binding is not access control: this sidecar exposes shell and file\ntools, so an unauthenticated port lets any page the user visits drive it. Three\ncontrols guard it, all in `gaia_agent.caller_auth`, which binds this sidecar's\nenv-var names and exempt paths onto the shared mechanism in\n`gaia.sidecar.caller_auth`:\n\n1. **Per-session bearer token.** Every `/v1/gaia/*` request must carry\n   `Authorization: Bearer <token>`. The spawning parent supplies it one of two\n   ways: `GAIA_GAIA_SIDECAR_TOKEN_FILE` — the path to a `0600`, owner-only file\n   holding the token, which is the preferred channel because the secret never\n   sits in the environment — or `GAIA_GAIA_SIDECAR_TOKEN`, the token itself, the\n   legacy delivery. A request without it is **401**, with a `detail` naming both\n   env vars.\n2. **Host allowlist.** The `Host` header must be present *and* loopback\n   (`127.0.0.1`, `localhost`, `::1`); anything else — including an absent or\n   empty header — is **400**. This is what defeats DNS rebinding, where the\n   rebound request arrives with `Host: evil.com`, so it fails closed rather\n   than letting a caller opt out by omitting the header.\n3. **Origin rejection.** A request carrying a non-loopback browser `Origin` is\n   **403**. Non-browser clients send no `Origin` and are unaffected.\n\n`/health`, `/version`, and `/v1/gaia/version` are **token-exempt** — they are\nthe probes a host polls during the attach handshake, before any token is in\nplay, and none of them exposes user data or accepts work. Host and Origin still\napply to them, so `waitForHealth` and `checkVersion` work against a\ntoken-protected sidecar without knowing the token.\n\n**Dev mode.** If neither env var is set the token check is skipped and the\nsidecar logs a loud warning saying so; Host and Origin are still enforced. That\nis the state a sidecar this package spawns comes up in, because `spawnSidecar`\nmints nothing — pass your own token through `spawnSidecar`'s `env` option to\nturn the check on. The shipped product does not rely on dev mode: the daemon\nmints a per-session token and passes it as `GAIA_GAIA_SIDECAR_TOKEN_FILE`\n(`gaia.daemon.sidecars.spec` mirrors both names as plain strings so core never\nimports this wheel).\n\n### 5.5 Other transports\n\nThe HTTP server above is the surface this package drives, and everything in\nthis document describes it. It is not the agent's only transport:\n`gaia_agent.stdio` runs the same agent over newline-delimited JSON on\nstdin/stdout — no port, no token, no discovery file. The terminal UI reaches it\nthat way when it spawns the agent as a subprocess; an agent **installed** from\nthe hub, which is what this package delivers, is supervised by the daemon over\nthe HTTP surface above instead (§6.1).\n\nIt emits the identical canonical event vocabulary, but its input channel accepts\na JSON line carrying a `gaia_control` key, which gives it something HTTP does\nnot have: a back-channel that can answer a confirmation prompt *while* a turn is\nin flight, and stop that turn (`cancel`) without ending the process — so loaded\nskills, \"always\" grants, history and the bypass mode survive a cancel. It also\ntakes `--bypass-permissions` (start with gating off) and\n`--use-claude` / `--claude-model` (route chat to the Anthropic API instead of\nlocal Lemonade; embeddings stay on Lemonade either way). None of that is\nreachable over `/v1/gaia/query`.\n\nThe stdio TUI also supports Local, Fireworks AI, and AMD LLM Gateway through\nLemonade. `/model` lists downloaded local and discovered cloud chat models;\n`/model <id>` switches the live client while preserving conversation and skills.\nCloud IDs (`fireworks.*`, `amd.*`) route through Lemonade without local model\nloading. Model-state status events identify the provider and remote inference.\nThe TUI's `/provider` panel configures credentials directly with local Lemonade;\nkeys never travel through stdio queries. These controls are not exposed over\n`/v1/gaia/query`. Lemonade may independently be configured to route a model\nremotely, so a local server URL alone does not establish local inference.\n\n---\n\n## 6. Process ownership\n\nTwo distinct paths, deliberately:\n\n### 6.1 `gaia run` — the normal path\n\n`run` fetches, verifies, installs, and **execs the TUI**. It does not spawn a\nsidecar.\n\nThat is not an omission. The TUI reaches agents through the GAIA daemon's relay\n(`/v1/<agent>/*`) and holds only the daemon client token, never a sidecar bearer —\n`tui/internal/daemon` states this as an invariant. The TUI start-or-attaches the\ndaemon under an advisory lock, and the daemon spawns and supervises the sidecar\nfrom `~/.gaia/agents/gaia/`. A sidecar spawned here would be a second process the\nTUI never talks to, competing for port `8141`.\n\nSo `run`'s contribution to the sidecar is putting a **verified** binary where the\ndaemon looks, and recording the install in `.installed` (§4.1) so the daemon and\nthe TUI both see it. Daemon and sidecar lifecycle belong to the daemon.\n\n### 6.2 `gaia serve` — the direct path\n\n`serve` fetches the sidecar and spawns it itself, for integrators who want the\nREST surface without a daemon or a UI. This path owns the process fully:\nport pre-flight → spawn → `waitForHealth` → `checkVersion` → run until\ninterrupted → tree-kill.\n\nThe port is checked **before** the spawn, and a taken port raises\n`PortInUseError` without starting anything. That ordering is the guarantee: the\nfrozen sidecar spends seconds unpacking before it attempts its bind, while an\nincumbent answers `/health` in milliseconds — so a post-spawn check alone would\nsee a healthy port and a still-alive child, and hand back a handle for a server\nit does not own. `assertOurs` and the re-probe after a failed health wait remain\nas backstops for the narrower window where something binds after the pre-flight.\n\n### 6.3 Tree-kill\n\nThe sidecar is a PyInstaller one-file build: it unpacks and spawns a child uvicorn\nprocess that `child.kill()` on the parent does **not** reap, leaving port `8141`\nbound. Every teardown kills the whole tree:\n\n- **POSIX** — spawned `detached` so the child leads its own process group;\n  `process.kill(-pid, SIGTERM)`, escalating to `SIGKILL` after the timeout.\n- **Windows** — `taskkill /PID <pid> /T /F`.\n\n`autoCleanup` (default `true`) also reaps on `exit`, `SIGINT`, `SIGTERM`,\n`SIGHUP`, `uncaughtException`, and `unhandledRejection`. A `SIGKILL` of the parent\nis the one case no in-process handler can catch.\n\n`startSidecar()` shuts the sidecar down before rethrowing on any failure, so a\nfailed start never leaks a process.\n\n---\n\n## 7. Exit codes\n\n| Code    | Meaning                                                                 |\n| ------- | ----------------------------------------------------------------------- |\n| `0`     | Success                                                                 |\n| `1`     | A typed failure: `IntegrityError`, `PlatformError`, `HealthTimeoutError`, `VersionMismatchError`, `BinaryNotFoundError`, `PortInUseError`, `SidecarExitedError`, `MalformedResponseError`, or an unexpected error |\n| `2`     | Usage error: unknown command, invalid `--port`, or a flag the command does not read (`run --port`, `serve --component`, `serve --cache-dir`, `run`/`serve` `--platform`), an unknown `--component`, or a non-https `--base-url` without `--allow-insecure-base-url` |\n| *other* | From `run`: the TUI's own exit code, propagated verbatim                 |\n\nA TUI killed by a signal propagates as `128 + signum` (the shell convention), so a\nCtrl+C is distinguishable from a clean `0`.\n\n---\n\n## 8. Errors\n\nAll extend `GaiaError`, so `instanceof GaiaError` catches any of ours.\n\n| Class                  | Raised when                                                    |\n| ---------------------- | -------------------------------------------------------------- |\n| `IntegrityError`       | A download's SHA-256 does not match the lock, or a resolved binary's does not |\n| `PlatformError`        | Unsupported platform, missing/incomplete entry, placeholder hash, malformed lock |\n| `HealthTimeoutError`   | The sidecar did not report healthy within the timeout          |\n| `VersionMismatchError` | `apiVersion` major differs from this package's                 |\n| `BinaryNotFoundError`  | A binary is absent from disk when spawning                     |\n| `PortInUseError`       | The bind port was already taken; nothing was spawned            |\n| `SidecarExitedError`   | The spawned sidecar died while the port answered — something else owns it |\n| `HttpError`            | A non-2xx from a sidecar probe                                 |\n| `MalformedResponseError` | A 2xx from a sidecar probe whose body is not JSON            |\n\nPer the repo's no-silent-fallbacks rule, every message names what failed, what to\ndo, and where to look next.\n\n---\n\n## 9. Timeouts\n\n| Operation             | Default   | Why                                              |\n| --------------------- | --------- | ------------------------------------------------ |\n| Download (per binary) | `300000`ms | The frozen sidecar is a large artifact           |\n| Health wait           | `60000`ms  | A cold one-file build unpacks before it binds    |\n| Health poll interval  | `250`ms    |                                                  |\n| `/health` probe       | `1000`ms   |                                                  |\n| `/version` probe      | `5000`ms   |                                                  |\n| Shutdown grace        | `5000`ms   | Then `SIGKILL` / forced                          |\n\n---\n\n## 10. Public API\n\nExported from the package root — see `src/index.ts`.\n\n**Fetch:** `fetchAll`, `fetchBinary`, `verifySha256`, `fileSha256`,\n`binaryExists`, `defaultCacheDir`, `daemonSidecarCacheDir`,\n`INSTALLED_SENTINEL_NAME`, `SIDECAR_AGENT_ID`.\n\n**Lifecycle:** `spawnSidecar`, `startSidecar`, `waitForHealth`, `checkVersion`,\n`health`, `version`, `shutdown`, `runTui`, `resolveSidecarPath`, `resolveTuiPath`,\n`sidecarExecutableName`, `tuiExecutableName`.\n\n**Platform:** `currentPlatformKey`, `loadLock`, `resolveEntry`, `componentLock`,\n`componentBaseUrl`, `platformsFor`, `defaultLockPath`, `isPlaceholderSha`,\n`COMPONENTS`, `SCHEMA_MAJOR`, `TUI_ARTIFACT_NAMES`,\n`SUPPORTED_SIDECAR_PLATFORMS`, `SUPPORTED_TUI_PLATFORMS`, `SUPPORTED_PLATFORMS`.\n\n**Constants:** `AGENT_ID`, `API_VERSION`, `DEFAULT_HOST`, `DEFAULT_PORT`,\n`RESERVED_PORT`.\n\n`resolveSidecarPath` / `resolveTuiPath` re-verify the file's SHA-256 against\n`binaries.lock.json` before returning a path that is about to be spawned — the\ncache path is predictable, so anything able to write it would otherwise get code\nrun. That makes them **O(size of the binary)**, not a cheap path join: resolve\nonce at startup, not per request. Pass `{ verify: false }` for a binary you built\nyourself, which no lock describes, and `{ lock }` to reuse an already-loaded lock.\n\n---\n\n## 11. Logging\n\n`DEBUG=gaia` (or `DEBUG=*`) enables debug output. **Everything goes to stderr** —\nstdout belongs to the TUI once it is exec'd, and to machine-readable JSON for\n`fetch` / `version`.\n",
      "skill": "---\nname: integrate-gaia\ndescription: Use when integrating the @amd-gaia/gaia npm package — running GAIA's flagship agent, or embedding its local sidecar into a Node, TypeScript, or Electron app. Covers the one-command path, the SHA-256 integrity gate, platform coverage, starting the sidecar, the /v1/gaia/query SSE contract, and the gotchas that will bite you.\n---\n\n# Integrating `@amd-gaia/gaia`\n\n`@amd-gaia/gaia` delivers **two binaries** and owns their process lifecycle: the\nfrozen **agent sidecar** (`gaia-agent`) and the Go **terminal UI** (`gaia-tui`).\nIt ships no agent logic of its own, and it **builds neither binary at install\ntime** — both are published artifacts it downloads and verifies. The terminal UI\nis the published `terminal-hub` component, the same binary a full GAIA install\nruns as `gaia tui`, so its behaviour cannot differ from that one. Everything runs on the local machine against\na local model server — nothing you type or index leaves it.\n\nTwo ways in:\n\n- **`npx @amd-gaia/gaia`** — fetch, verify, launch the terminal UI. What a human runs.\n- **The programmatic exports** — fetch, spawn the sidecar, drive `POST /v1/gaia/query`\n  yourself. What you use when embedding GAIA in 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 package into an app. The agent separately loads\n> **Agent Skills** into its own prompt at runtime from\n> `gaia_agent/skills/<name>/SKILL.md`. Same filename, different artifact —\n> don't ship this one as an agent skill. See\n> [Skills](#10-skills--one-always-on-the-rest-opt-in).\n\n## 1. Install\n\n```bash\nnpx @amd-gaia/gaia          # no install step; fetches what it needs on first run\nnpm install @amd-gaia/gaia  # when you want the programmatic exports\n```\n\nThe package is **ESM-only** (`\"type\": \"module\"`) and needs **Node 18+** for the\nbuilt-in `fetch`. Use `import`, not `require`; from CommonJS use\n`await import(\"@amd-gaia/gaia\")`.\n\n> `@amd-gaia/gaia` **publishes with this release** — it is not on npm yet. Until\n> the release tag lands, `npx @amd-gaia/gaia` will not resolve. Run the agent from\n> a source checkout in the meantime (see\n> [the guide](https://amd-gaia.ai/docs/guides/gaia)).\n\n## 2. What `npx @amd-gaia/gaia` actually does\n\n1. Resolves the host platform key (`` `${process.platform}-${process.arch}` ``).\n2. Reads `binaries.lock.json`, the checksum manifest published with this exact\n   package version. Each component records its own hub lane, version, artifact\n   name and hash — they do not share a base URL.\n3. Downloads **both** binaries, each from its own lane, and **SHA-256 verifies\n   each against the lock**.\n4. Installs the sidecar into `~/.gaia/agents/gaia/` and the TUI into\n   `~/.gaia/npm-cache/gaia-<version>/`.\n5. Writes `~/.gaia/agents/gaia/.installed` — the record the daemon and the TUI\n   both read to decide the sidecar is installed. Written on a cache hit too, so\n   an install staged by an earlier release repairs itself.\n6. Execs the TUI, whose exit code becomes ours.\n\n`run` deliberately does **not** spawn a sidecar. The TUI reaches agents through\nthe GAIA daemon's relay and never holds a sidecar token, and the daemon is what\nspawns and supervises the sidecar — from exactly the directory step 4 wrote to.\nA second sidecar started here would only fight the daemon's own for port 8141. Use\n`gaia serve` when you want to own the process.\n\nOther commands: `gaia fetch` (download + verify, print JSON, exit),\n`gaia serve` (sidecar alone), `gaia version` (per-component version, source URL,\nand platform matrix). Anything after a bare `--` goes to the TUI verbatim.\n\n### Where each binary comes from\n\n| Component | Hub lane                                  | Artifact names            |\n| --------- | ----------------------------------------- | ------------------------- |\n| `sidecar` | `agents/gaia/<agentVersion>/`             | `gaia-agent-<platformKey>[.exe]` |\n| `tui`     | `agents/terminal-hub/<componentVersion>/` | `gaia-<goPlatform>[.exe]` |\n\nTwo things follow from that, and both bite if you assume otherwise:\n\n- **The two components version independently.** `lock.agentVersion` is this\n  package's version; `components.tui.componentVersion` is the terminal-hub release\n  it consumes. They will not match.\n- **The TUI's artifact names use `win-x64` / `win-arm64`, not `win32-*`.** Platform\n  *keys* stay in Node's namespace (`process.platform` says `win32`); only the\n  `filename` crosses over. Never build a TUI URL by interpolating a platform key —\n  read `filename` from the lock entry.\n\n## 3. The integrity gate — it will stop you, by design\n\nThe SHA-256 check is this package's security boundary, and there is no flag,\nenv var, or option that relaxes it.\n\n- Bytes are hashed **in memory and compared before anything is written** to the\n  cache path. A mismatch raises `IntegrityError` naming expected vs actual and\n  leaves nothing on disk.\n- A **placeholder hash blocks the fetch before any network call** — between\n  releases every `sha256` in the lock is `PENDING-replace-with-real-sha256`, and\n  a value that is all zeros or contains `PENDING` (case-insensitive) is treated\n  as a placeholder. You get a `PlatformError`, not a download.\n- A cache hit **re-hashes the on-disk file**. A cached binary whose bytes drifted\n  is re-downloaded, not reused.\n\nIf you need to run against a locally built binary, build it and point\n`startSidecar` / `runTui` at it directly. The fetcher will not be talked into it.\n\n## 4. Platform coverage — the sidecar has two gaps\n\n`terminal-hub` publishes the TUI for all six targets. The sidecar is a PyInstaller\nfreeze built on the platform it targets, and there is **no arm64 Linux and no arm64\nWindows sidecar build**.\n\n| Platform key   | Sidecar | TUI |\n| -------------- | :-----: | :-: |\n| `win32-x64`    |   yes   | yes |\n| `darwin-arm64` |   yes   | yes |\n| `darwin-x64`   |   yes   | yes |\n| `linux-x64`    |   yes   | yes |\n| `linux-arm64`  | **no**  | yes |\n| `win32-arm64`  | **no**  | yes |\n\nResolving the sidecar on those two keys raises `PlatformError` naming the\nplatform and the supported set. It is not silently skipped, and the TUI is never\nlaunched with no agent behind it. `npx @amd-gaia/gaia version` prints the matrix\nfor the version you have.\n\n## 5. Prerequisite — a local Lemonade server\n\nThe agent thinks with a model hosted by **Lemonade Server**, which this package\ndoes not install. Required before any query succeeds:\n\n1. Lemonade **10.2.0 or newer**, running (`lemonade-server serve`).\n2. The default model downloaded (`gaia download Gemma-4-E4B-it-GGUF`, or\n   `gaia init`).\n\nDo not guess — ask the sidecar. `GET /v1/gaia/init` is a read-only preflight\n(it never pulls or loads) that probes Lemonade, compares its version to the\nfloor, and checks the model is present:\n\n```bash\ncurl -s http://127.0.0.1:8141/v1/gaia/init\n```\n\nIt answers **200** when ready and **503** when not, with the **same body shape\neither way** — so branch on `.ready` and render `.hint`, never on the status code\nalone:\n\n```jsonc\n{\n  \"ready\": false,\n  \"lemonade\": { \"reachable\": false, \"base_url\": \"…\", \"version\": null,\n                \"min_version\": \"10.2.0\", \"compatible\": null },\n  \"model\":    { \"id\": \"Gemma-4-E4B-it-GGUF\", \"present\": false,\n                \"loadable\": null, \"ctx_size\": null },\n  \"hint\": \"Local Lemonade Server is not reachable at … — start it with `lemonade-server serve`, or set LEMONADE_BASE_URL to a running server.\"\n}\n```\n\n`lemonade.compatible: null` is **indeterminate**, not a pass — the version could\nnot be parsed. Render it as unknown.\n\n`GET /health` is liveness only. A green `/health` means the REST surface is up;\nit says nothing about whether a query will work.\n\n## 6. Start the sidecar\n\n```ts\nimport { fetchAll, startSidecar, shutdown } from \"@amd-gaia/gaia\";\n\n// Fetch + SHA-256 verify both binaries. Build step, not per request.\nconst { sidecar, tui } = await fetchAll();\n\n// Spawn -> poll /health -> check the contract apiVersion, in one call.\nconst proc = await startSidecar({ binaryPath: sidecar.binaryPath });  // port 8141\n\n// ... drive proc.baseUrl ...\n\nawait shutdown(proc);   // tree-kill; auto-cleanup also reaps on exit\n```\n\n- `fetchAll(opts?)` returns `{ sidecar, tui, lock }`. Each result carries\n  `binaryPath`, `platformKey`, `sha256`, `cached`, `url`. For one component use\n  `fetchBinary({ component: \"sidecar\" | \"tui\", outDir })`.\n- `startSidecar` throws if the binary can't start, never becomes healthy\n  (`HealthTimeoutError`, 60 s default — a cold one-file build unpacks first), or\n  reports an `apiVersion` whose **major** differs from this package's\n  (`VersionMismatchError`). On any failure it shuts the sidecar down before\n  rethrowing, so a failed start never leaks a process.\n- **Tree-kill is not optional.** The frozen sidecar spawns a child uvicorn\n  process that `child.kill()` on the parent does not reap, which leaves port 8141\n  bound. `shutdown` kills the group (POSIX `SIGTERM` to `-pid`, escalating to\n  `SIGKILL`; Windows `taskkill /T /F`). `autoCleanup` (default `true`) also reaps\n  on `exit`, `SIGINT`/`SIGTERM`/`SIGHUP`, `uncaughtException`, and\n  `unhandledRejection`. A `SIGKILL` of *your* process is the one case nothing\n  in-process can catch.\n- **Mint a caller token, or you are running the sidecar unauthenticated.** The\n  sidecar requires `Authorization: Bearer <token>` on every `/v1/gaia/*`\n  request, and skips the check only when neither token env var is set — dev\n  mode, which is what `startSidecar` gives you, because this package mints\n  nothing. Loopback binding is not the boundary: this agent has shell and file\n  tools. Mint your own and pass it through the `env` option (`StartOptions`\n  extends `SpawnOptions`, so it merges over `process.env`):\n\n  ```ts\n  import { randomBytes } from \"node:crypto\";\n  const token = randomBytes(32).toString(\"hex\");\n  const proc = await startSidecar({\n    binaryPath: sidecar.binaryPath,\n    env: { GAIA_GAIA_SIDECAR_TOKEN: token },   // or ..._TOKEN_FILE, a 0600 path\n  });\n  ```\n\n  Then send `authorization: \"Bearer \" + token` on every `/v1/gaia/*` call.\n  `/health`, `/version`, and `/v1/gaia/version` are exempt, so `startSidecar`'s\n  own health-and-version handshake works either way. **If you did not spawn the\n  sidecar** — you are talking to one the GAIA daemon started — the token is the\n  daemon's, delivered to the sidecar as `GAIA_GAIA_SIDECAR_TOKEN_FILE` (a `0600`\n  file path); read it from there, don't invent one. A 401 whose `detail` names\n  both env vars means you sent the wrong token or none.\n\nOr skip the code entirely and let the CLI own it:\n\n```bash\nnpx @amd-gaia/gaia serve --port 8141\ncurl http://127.0.0.1:8141/health\n```\n\n## 7. Call `POST /v1/gaia/query`\n\nThis is the whole agent surface. There is **no typed query client** in this\npackage — call it with plain `fetch`. Contract version **2.12**; the stream is\n`text/event-stream` terminated by **exactly one** `final` or `error`.\n\nRequest body (`extra: \"forbid\"` — an unknown field is a **422**, not ignored):\n\n| Field | Required | Notes |\n|---|---|---|\n| `query` | yes | Non-empty. |\n| `run_id` | yes | **You mint it**, and it must be a UUID (non-UUID → 422). It is the cancel handle, valid from the instant the request is sent. |\n| `context` | yes | Transcript slice, pushed in the body — may be `[]`, never absent. Each item `{ role, content }`; `role` ∈ `user` / `assistant` / `system` / `tool`. |\n| `session_id` | no | Contract ≥ 2.12. **Pass it.** The agent persists its indexed-document set per session — without it, it forgets a document between the turn that indexed it and the next question. |\n| `can_answer_questions` | no | Set `false` for one-shot / batch runs so the agent resolves ambiguity itself instead of parking on a question nobody can see. |\n| `model` | no | Overrides the model id — only when the run builds a fresh agent. On a retained `session_id` the agent already exists, so a model that differs from the one it was built with is a **409**, not an override. |\n| `provider` | no | Local inference only — anything but `\"lemonade\"` is a **400**. |\n| `max_steps` | no | ≥ 1. |\n\n```ts\nimport { randomUUID } from \"node:crypto\";\n\nconst runId = randomUUID();\nconst res = await fetch(`${proc.baseUrl}/v1/gaia/query`, {\n  method: \"POST\",\n  headers: {\n    \"content-type\": \"application/json\",\n    accept: \"text/event-stream\",\n    authorization: `Bearer ${token}`,   // required unless the sidecar is in dev mode — §6\n  },\n  body: JSON.stringify({\n    query: \"Summarize the PDFs in ~/Documents/reports\",\n    run_id: runId,\n    context: [],\n    session_id: \"s1\",\n    can_answer_questions: false,\n  }),\n});\n\nconst reader = res.body!.getReader();\nconst dec = new TextDecoder();\nlet buf = \"\";\nouter: for (;;) {\n  const { value, done } = await reader.read();\n  if (done) break;\n  buf += dec.decode(value, { stream: true });\n  let i: number;\n  while ((i = buf.indexOf(\"\\n\\n\")) >= 0) {\n    const frame = buf.slice(0, i);\n    buf = buf.slice(i + 2);\n    const line = frame.split(\"\\n\").find((l) => l.startsWith(\"data: \"));\n    if (!line) continue;                  // \":\" frames are keepalive comments\n    const ev = JSON.parse(line.slice(6));\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;   // see §8 — a refusal follows\n      case \"needs_input\": /* answer it — see below */ break;\n      case \"final\":       console.log(ev.answer); break outer;   // terminal\n      case \"error\":       console.error(ev.detail); break outer; // terminal\n      default:            console.warn(\"unsupported event\", ev); // future additive type\n    }\n  }\n}\n```\n\nThe canonical event shapes, as emitted:\n\n| Event | Shape |\n|---|---|\n| `status` | `{ type, message }` — progress and reasoning narration |\n| `token` | `{ type, delta }` — answer text to append |\n| `tool_call` | `{ type, tool, args }` |\n| `tool_result` | `{ type, tool, data, render? }` |\n| `needs_confirmation` | `{ type, run_id, action, summary }` — no `confirm_url`; see §8 |\n| `needs_input` | `{ type, run_id, request_id, question, options[], allow_free_text, sensitive, respond_url, timeout_seconds? }` |\n| `final` | `{ type, answer, usage? }` — terminal |\n| `error` | `{ type, detail, status }` — terminal, surface `detail` verbatim |\n\nRules a client must respect:\n\n- **An idle run emits `: keepalive` SSE comments every 10 s.** Skip lines that\n  aren't `data:` and reset your read-idle timer on them — a long tool call is not\n  a dead stream.\n- **Never treat stream close without a terminal event as success.** The server\n  guarantees one; a close without one means something broke on your side.\n- **Answer `needs_input`, don't restart.** The run is parked on the *same*\n  stream. `POST /v1/gaia/query/{run_id}/respond` with\n  `{ request_id, response }` and keep reading the existing stream — a fresh\n  `/query` POST abandons the paused run. Unknown run → **404**; a `request_id` that\n  is no longer pending → **409** (both loud, never a silent drop). Render each\n  option's `description`, and mask the input when `sensitive` is set.\n- **Cancel with `POST /v1/gaia/query/{run_id}/cancel`.** It returns\n  `{ run_id, cancelled }` — an unknown id reports `cancelled: false` with a\n  **200**, not a 404, because a cancel racing a normal completion is expected.\n  Dropping the HTTP connection also cancels the run.\n\n## 8. Over `/v1/gaia/query`, confirmation-gated tools are **refused, not prompted**\n\nRead this before you design a workflow around it. This section is about the HTTP\nsurface — the agent's other transport can collect an approval; see SPEC §5.5.\n\nSeven of the agent's 71 tools mutate the machine and need explicit approval\nbefore they run. Five sit in the base `TOOLS_REQUIRING_CONFIRMATION` set —\n**`write_file`**, **`edit_file`**, **`run_shell_command`**,\n**`execute_python_file`**, and **`notify_desktop`**, which spawns a PowerShell\nchild on Windows to draw the notification — and the flagship adds two of its\nown, **`install_skill`** and **`remove_skill`**, because installing a skill\nwrites third-party code under `~/.gaia/skills` and removing one deletes it.\nEverything else — reading, indexing, querying, web fetching, memory — runs\nwithout asking.\n\nOver `/v1/gaia/query` there is **no way to collect an approval**, so the stream\ndoes not prompt. When the agent reaches one of those tools it emits a\n`needs_confirmation` event, and the server **immediately follows it with a\nterminal `final`** whose `answer` says it stopped before running that action,\nthen cancels the run. There is no `confirm_url`, no resume, and no\n`/query/{run_id}/confirm` endpoint — it is a deliberate deny-by-default stub, not\nan oversight.\n\nConcretely, your client sees:\n\n```\ndata: {\"type\":\"needs_confirmation\",\"run_id\":\"…\",\"action\":\"write_file\",\"summary\":\"Run 'write_file'?\"}\ndata: {\"type\":\"final\",\"answer\":\"I stopped before running 'write_file' because it needs your explicit approval, and this streaming surface cannot collect that yet. …\"}\n```\n\nSo: **`/query` cannot run any of those seven tools.** If your integration needs\nthat, drive the agent from a surface that can prompt — its stdio transport is the\none that can, because its control channel carries an approval back to a turn\nalready in flight (SPEC §5.5) — or perform the mutation yourself from your own\ncode and let the agent do the reading and reasoning. Treat `needs_confirmation`\nas an early warning that the run is about to end, not as a question you can\nanswer.\n\n## 9. File-access scope\n\nThe agent's file, document, and data tools are confined to a set of allowed\npaths, and **the default is the user's home directory**. That is the honest scope\nfor a personal document agent, and it is still a real boundary — system\ndirectories, program files, and other users' homes are refused, with the check\nrun against the *resolved* path so a symlink out of scope doesn't slip through.\n\n**Being in scope is not the same as being safe, and two denylists apply inside\nit.** Reads refuse secrets — `.env`, `id_rsa`, `credentials.json`, `.netrc`,\n`.pem`/`.key`, and everything under `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube` —\neven in an allowed directory. Writes additionally refuse anything that executes\non its own: shell startup files, PowerShell profiles, `~/.config/autostart/*`,\nsystemd user units, `LaunchAgents`, and a repo's `.git/` (hooks and config). Both\ncome back as a structured error naming the file and the reason, so do not plan an\nintegration around reading a credential file or editing a shell rc — perform\nthose from your own code.\n\n**In 0.1.1 narrowing it is a construction-time setting only.** The packaged\nsidecar exposes no flag or env var for `allowed_paths` (its CLI accepts only\n`--host` and `--port`), so restricting the scope means embedding `GaiaAgent` in\nyour own Python process:\n\n```python\nfrom gaia_agent.agent import GaiaAgent, GaiaAgentConfig\n\nagent = GaiaAgent(config=GaiaAgentConfig(allowed_paths=[\"/home/me/Documents\"]))\n```\n\n## 10. Skills — one always-on, the rest opt-in\n\nThe agent is built to host **Agent Skills** (short playbooks loaded into its own\nprompt, grouped into named sets, one set active per launch), and its bundled\nskill directory is the highest-precedence discovery root.\n\nLoaded skills are **not** all resident every turn: each turn the agent embeds\nthe query against every loaded skill's description and renders only the\nmatching bodies in full — the rest collapse to a one-line menu entry, and the\nmodel (or the user) re-activates one by calling `load_skill` on it again.\n`GAIA_DYNAMIC_SKILLS=0` disables the per-turn selection (every loaded body\nrenders every turn); `GAIA_DYNAMIC_SKILLS_TAU=<float>` overrides the match\nthreshold. Manifest `skills:` entries are always-on and never collapse. If the\nembedder is unavailable, selection disables itself for the session and every\nbody renders — capability is never silently lost to a failed match.\n\n**One skill ships enabled: `gaia-voice`.** It is a manifest `skills:` entry, so\nit is always on, always rendered in full, and paid on every LLM call of every\nturn — budget for it. It is not a task recipe but the agent's honesty floor: do\nnot claim work you did not do, do not present empty output as a result, do not\nsubstitute a near-miss and report success. Those failures corrupt an answer\nwhatever the task is, which is why it cannot live in an opt-in bundle. It\ndeclares no tools, and its body measures 676 tokens (tiktoken `cl100k`).\n\n**No skill *set* loads.** `gaia-agent.yaml` ships its `skill_sets:` and\n`default_skill_set:` blocks **commented out** — following the email agent's\nprecedent, because loading several skill bodies into every prompt costs tokens\nand no eval has measured that trade for this agent yet. Re-enabling is\nuncommenting two blocks; no code change.\n\nSo today there is nothing for `GAIA_SKILL_SET` to select — leave it unset. Once\na release declares sets, `GAIA_SKILL_SET` is the selection channel for the\npackaged sidecar (its CLI accepts only `--host` and `--port`), and an undeclared\nname raises naming the valid sets rather than falling back to a default. Beyond\n`gaia-voice`, do not design around a skill being on by default.\n\n## 11. The project map — two things it costs you\n\nWhen the agent's working directory is a code repository, every task starts with\na **project map** in the system prompt: the root, the directory shape, the\nlikely entry points, which commands are installed, and the three platform\ndifferences that change command syntax. It exists so the agent stops burning\nround trips on \"no such file\" and \"command not found\".\n\nTwo consequences an integrator needs to plan for:\n\n- **Up to 600 prompt tokens, every turn.** That is the enforced ceiling\n  (1.8% of the NPU profile's 32K window), not a typical value — budget it\n  alongside `gaia-voice`'s 676.\n- **A background embedding pass on first contact with a new repository.** If\n  the repo has no [code index](https://amd-gaia.ai/docs/guides/code-index), the\n  map starts one in a background thread so semantic search is ready when it is\n  needed. On a large monorepo that is minutes of local embedding.\n  `GAIA_PROJECT_MAP_AUTO_INDEX=0` turns it off.\n\nThe sidecar's CLI accepts only `--host` and `--port`, so pointing the map at a\nspecific project means `GAIA_PROJECT_ROOT=/path/to/repo` in its environment, or\n`GaiaAgentConfig(project_root=...)` when embedding. A directory that is neither\na VCS checkout nor holds a recognised manifest gets **no map** — that is the\ndesigned answer, not a failure.\n\n## 12. Ports\n\n| Service | Port |\n|---|---|\n| Agent sidecar | `8141` on `127.0.0.1` |\n| GAIA daemon | assigned at start, recorded in `~/.gaia/host/instance.json` |\n\nPort **4001 is reserved repo-wide**: `spawnSidecar` throws a `RangeError` and\n`gaia serve --port 4001` exits 2. Both services bind loopback only — this agent\nspeaks for the user's documents and memory and has no business on a LAN\ninterface.\n\n## 13. Running in a server or long-lived app\n\n- **`fetchAll` / `fetchBinary` are a build step**, not per request — network plus\n  a full SHA-256 hash of a large artifact. Run once at install time.\n- **`resolveSidecarPath` / `resolveTuiPath` are startup, not per request.** They\n  re-hash the binary against `binaries.lock.json` before handing back a path that\n  gets spawned, so they cost a full read of a large file. Resolve once and keep\n  the path. `{ verify: false }` skips the check for a binary you built yourself.\n- **Spawn once at boot** and hold the `Sidecar` handle for the process lifetime.\n  Never per request.\n- **Low concurrency.** One local Lemonade model slot, so parallel queries\n  serialize. Cap inflight runs.\n- **The package does not restart a crashed sidecar.** It reaps one; supervision\n  is the daemon's job (or yours).\n- **`DEBUG=gaia`** puts download, spawn, and sidecar output on **stderr**. stdout\n  belongs to the TUI once exec'd, and to machine-readable JSON for `fetch` /\n  `version` — never write diagnostics there.\n\nEvery failure throws a typed error extending `GaiaError`, so\n`instanceof GaiaError` catches any of ours: `IntegrityError`, `PlatformError`,\n`HealthTimeoutError`, `VersionMismatchError`, `BinaryNotFoundError`, `HttpError`.\nThere is no silent null.\n\n## Gotchas — read before debugging\n\n- **`/health` green ≠ ready.** It never touches the model server. Use\n  `GET /v1/gaia/init` and branch on `.ready`; it returns 503 with a full body and\n  a `hint`, not an empty error.\n- **A terminal `error` whose `detail` starts \"Local Lemonade Server is not\n  reachable\"** means Lemonade isn't running or isn't reachable — not a bug in\n  this package. Start it, or set `LEMONADE_BASE_URL`.\n- **`needs_confirmation` is followed by a refusal and the run ends.** See §8.\n  The seven gated tools are unreachable **over `/query`** — the agent itself can\n  run them on a transport that can prompt (SPEC §5.5).\n- **A placeholder hash in `binaries.lock.json` blocks the fetch before any\n  network call.** Between releases that is the *expected* state — it is not a\n  broken install, and there is no override.\n- **No `linux-arm64` / `win32-arm64` sidecar.** The TUI has both. A `PlatformError`\n  on those hosts is the design, not a missing artifact.\n- **A `401` from `/v1/gaia/*` is the caller-auth token, not a bug.** The sidecar\n  requires `Authorization: Bearer <token>`; `/health`, `/version`, and\n  `/v1/gaia/version` are exempt, which is why a green health check sits happily\n  in front of a 401 on `/query`. It skips the check only in dev mode — neither\n  token env var set — which is what `spawnSidecar` and `gaia serve` produce,\n  because this package mints nothing. Do not treat loopback binding as the\n  boundary; mint a token and pass it (§6).\n- **`run_id` must be a UUID**, and unknown fields in the request body are a\n  **422** — the model forbids extras. Typos don't get ignored.\n- **`gaia run` needs the *Python* `gaia` CLI on `PATH`** — the TUI shells out to\n  it to start the daemon. So the TUI doesn't re-invoke our own npm shim, the\n  child's `PATH` is rewritten: a directory holding nothing but our shim (an npx\n  temp dir) is dropped, and a **shared** bin directory is moved to the end\n  instead of removed, so the `python3` / `lemonade-server` / real `gaia` beside\n  it stay reachable. If the Python CLI isn't installed anywhere, the daemon never\n  comes up. It must also be **0.23.1+**: an older core's daemon starts fine but\n  has no sidecar entry for this agent, which reads as a UI with a dead agent\n  rather than as a version problem.\n- **The TUI is installed as `gaia-tui`, never `gaia`** — the terminal-hub artifact\n  *is* called `gaia-<platform>`, and a file named `gaia` in a cache directory would\n  shadow the npm bin shim. The lock's `filename` and `executable` differ for that\n  reason. Don't rename it back.\n- **The TUI comes from a lane this package doesn't publish.** If a fetch 404s on\n  the TUI but not the sidecar, the pinned `terminal-hub` version is the thing to\n  check — `gaia version` prints it and its base URL.\n- **ESM-only.** `require(\"@amd-gaia/gaia\")` fails; use `import` or dynamic\n  `import()`.\n\n## Verify the integration\n\nGreen path, in order:\n\n```bash\nnpx @amd-gaia/gaia version          # per-component version + source URL + matrix\nnpx @amd-gaia/gaia fetch            # JSON: one entry per binary with its sha256\nnpx @amd-gaia/gaia serve --port 8141\n```\n\nAgainst a lock that still carries `PENDING-…` hashes, `fetch` is *expected* to\nfail with a `PlatformError` before any download — that is the gate working, not a\nbroken install. Only a published release has real hashes.\n\nThen, in another terminal:\n\n```bash\ncurl -s http://127.0.0.1:8141/health          # {\"status\":\"ok\",\"service\":\"gaia-agent-gaia\"}\ncurl -s http://127.0.0.1:8141/version         # {\"apiVersion\":\"2.12\",\"agentVersion\":\"0.1.1\"}\ncurl -s http://127.0.0.1:8141/v1/gaia/init    # 200 + \"ready\":true, or 503 + a \"hint\"\ncurl -N -X POST http://127.0.0.1:8141/v1/gaia/query \\\n  -H 'content-type: application/json' \\\n  -d '{\"query\":\"What can you do?\",\"run_id\":\"00000000-0000-4000-8000-000000000001\",\"context\":[],\"can_answer_questions\":false}'\n```\n\nA healthy run streams `status` / `token` events and ends with one `final`. If\n`/v1/gaia/init` is 503, fix what its `hint` names and retry — the rest of your\nintegration is fine.\n\nThat `/query` call carries no `Authorization` header because `gaia serve` starts\nthe sidecar in dev mode. Against one started with a token, add\n`-H \"authorization: Bearer $TOKEN\"` — a **401** here and a green `/health` is\nthat and nothing else (§6).\n\n**A 503 from `/query` itself is a different condition**: every retained\nsession slot is busy and none is idle enough to evict (SPEC §5.2). Do NOT\nloop on `/v1/gaia/init` — it will report ready. Wait for a running turn to\nfinish (or close an idle session) and retry the same `/query`.\n\n**Three more refusals are yours to avoid**, each naming its fix in `detail`\n(SPEC §5.2 has the reasoning):\n\n- **409 — the `run_id` is still in flight.** You mint it, so mint a fresh UUID\n  per request; reusing one would leave the earlier run with no way to be\n  cancelled.\n- **409 — `model` differs from what this `session_id` was built with.** Only\n  construction reads a model, so it cannot be applied to the retained agent.\n  Omit `model` to stay on the session's current one, or start a new\n  `session_id` to switch.\n- **400 — the `Host` header is absent or empty.** The loopback check fails\n  closed, so omitting the header is refused rather than served. Send\n  `Host: 127.0.0.1:<port>`; every real HTTP client already does.\n\nFor the full wire contract, lock schema, exit codes, and timeout table, see\n[`SPEC.md`](./SPEC.md). For the user-facing overview, see [`README.md`](./README.md)\nand <https://amd-gaia.ai/docs/guides/gaia>.\n\n## TUI inference providers\n\nThe stdio TUI supports `/provider` for Local, Fireworks AI, and AMD LLM Gateway.\nKeys are entered in a masked field and sent directly to local Lemonade's runtime\nauth API, never as agent queries. `/model` lists supported discovered cloud and\ndownloaded local models; `/model fireworks.gemma-4-31b-it` selects Gemma 4 31B IT\nwhen available. Cloud chat sends conversation history to the selected provider;\nembeddings remain on Lemonade. The status event names the actual provider and\nmarks remote inference. This is a TUI/stdio capability, not an HTTP query command.\n",
      "evaluation": "",
      "capability_matrix": "",
      "scorecard": ""
    },
    {
      "id": "terminal-hub",
      "name": "GAIA Terminal Hub",
      "description": "Browse, install, and chat with GAIA agents from the terminal",
      "category": "tools",
      "latest_version": "0.24.1",
      "icon": "terminal",
      "language": "go",
      "type": "component",
      "author": "AMD",
      "security_tier": "experimental",
      "download_size_bytes": 18292898,
      "tags": [
        "tui",
        "terminal",
        "cli",
        "hub"
      ],
      "tools_count": 0,
      "models": [],
      "min_gaia_version": "0.23.0",
      "permissions": [],
      "deprecated": false,
      "requirements": {
        "min_memory_gb": 1,
        "min_disk_gb": 0,
        "min_context_size": 0,
        "platforms": [
          "win-x64",
          "win-arm64",
          "linux-x64",
          "linux-arm64",
          "darwin-x64",
          "darwin-arm64"
        ],
        "npu": "optional",
        "gpu_vram_gb": 0
      },
      "readme": "# GAIA Terminal UI\n\nChat with GAIA in your terminal — no browser, no account, nothing you type\nleaves your machine.\n\n`gaia-tui` boots straight into the flagship `gaia` agent: conversation,\ndocuments, data, web research, memory, skills. There is nothing to browse and\nnothing to pick — one binary, one agent, unless you ask for another by id\n(see [Running another agent](#running-another-agent)).\n\n## Install\n\nOne command installs the `gaia` CLI, the local model runtime, and the terminal\nUI itself:\n\n```bash\n# macOS and Linux\ncurl -fsSL https://amd-gaia.ai/install.sh | sh\n```\n\n```powershell\n# Windows\nirm https://amd-gaia.ai/install.ps1 | iex\n```\n\nThen set up the local model — several GB, so give it a while — and launch:\n\n```bash\ngaia init\ngaia-tui\n```\n\n`gaia init` installs Lemonade Server, the runtime that does the thinking on your\nmachine. Skip it and the readiness gate stops you before the agent starts and\ntells you the same thing.\n\n### Or download the binary directly\n\nThe installer fetches these and verifies their SHA-256; you can also take one\nyourself. Rename it to `gaia-tui` and put it on your `PATH`:\n\n| Platform | Download |\n|---|---|\n| Windows x64 | [`gaia-win-x64.exe`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-win-x64.exe) |\n| Windows ARM64 | [`gaia-win-arm64.exe`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-win-arm64.exe) |\n| macOS Apple Silicon | [`gaia-darwin-arm64`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-darwin-arm64) |\n| macOS Intel | [`gaia-darwin-x64`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-darwin-x64) |\n| Linux x64 | [`gaia-linux-x64`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-linux-x64) |\n| Linux ARM64 | [`gaia-linux-arm64`](https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0/gaia-linux-arm64) |\n\n`https://hub.amd-gaia.ai/agents/terminal-hub/manifest.json` lists what is\npublished, with the SHA-256 of each build. A direct download still needs the\n`gaia` CLI on your `PATH` — first-time setup (`gaia init`) and the `email`\nagent's daemon both go through it — so the one-line installer above is the\nshorter route.\n\nThe binary is installed as `gaia-tui`, never as `gaia`: the two have different\nsubcommands and would collide on your `PATH`.\n\n### Building from source\n\nFor working on the TUI itself. Needs `git`, [Go](https://go.dev/dl/), and\n[uv](https://docs.astral.sh/uv/):\n\n```bash\ngit clone https://github.com/amd/gaia.git\ncd gaia && uv venv && uv pip install -e .\ncd tui && make build                # -> tui/bin/gaia-tui\ncp bin/gaia-tui ~/.local/bin/       # somewhere on your PATH\n```\n\n## Your first run\n\n```bash\ngaia-tui\n```\n\nThat opens on a splash frame (the GAIA mascot), then a readiness gate, then\nchat. The gate checks the few things that would otherwise make the agent fail —\nis `gaia-agent` on this machine, is the local model server running, are the\nmodels downloaded. Anything not ready is shown with the exact command that\nfixes it, and `f` runs the fix that can be automated (downloading the models)\nwithout leaving the terminal.\n\nIf `gaia-agent` itself is missing, the gate stops there and points you at the\ninstaller — there is no in-TUI download for the agent binary.\n\nColours adapt to your terminal automatically. Some terminals never answer that\nquery (SSH, tmux, a CI log) — if the screen comes out hard to read, force it:\n\n```bash\nGAIA_TUI_THEME=light gaia-tui    # or dark; unset or \"auto\" = detect\n```\n\n## Running another agent\n\n`gaia-tui` ships one agent by default, but it can also drive `email` by id —\nit keeps its own readiness gate (background service, sidecar, local model,\nmailbox):\n\n```bash\ngaia-tui run email                             # chat with it\ngaia-tui run email --query \"triage my inbox\"   # one-shot: answer on stdout\ngaia-tui chat --agent email                    # same as `run email`\n```\n\nInstalling and uninstalling a hub sidecar agent like `email` is the Python\nCLI's job, not the terminal UI's:\n\n```bash\ngaia hub list                 # what the Agent Hub offers, and what you have\ngaia hub install email --trust\ngaia hub uninstall email\n```\n\n## Commands worth knowing\n\n```bash\ngaia-tui                    # launch straight into the flagship agent's chat\ngaia-tui run <id>           # chat with an agent by id (e.g. email)\ngaia-tui run <id> --query \"…\"  # one-shot: answer on stdout, exit 0/1/3\ngaia-tui chat --agent <id>  # same idea, via the chat subcommand\ngaia-tui status             # is the background service running, and what do I have\ngaia-tui version\n```\n\n`--bypass-permissions` is available only for agents launched as subprocesses.\nDaemon-backed agents, including a Hub-installed flagship, reject it before\nreadiness checks; omit the flag to run with confirmation prompts enabled.\n\nFull command reference: <https://amd-gaia.ai/docs/reference/cli>\n\n---\n\n# Going deeper\n\nFor developers and power users. If you just want to use GAIA agents, you are\nalready done.\n\n## Exit codes\n\n`gaia-tui run --query` is built to be scripted:\n\n- `0` — the agent answered and nothing reported a failure\n- `1` — an error, or a tool failed and nothing recovered it. This holds even\n  when the agent still wrote an answer, so `gaia-tui run … && next-step` never\n  fires over work that did not actually happen.\n- `3` — a confirmation gate held back a destructive action this run had no way\n  to approve: nothing broke, and nothing was done\n\n`--timeout` bounds a single `--query` turn (default 15m).\n\nIn the pre-run checks, a condition that cannot be determined renders `[?]`\nrather than a checkmark and never counts as ready — unknown is never treated as\nfine.\n\n## Choosing an AI provider\n\nPress **p** during setup, or enter **`/provider`** in chat, to choose **Local**,\n**Fireworks AI**, or **AMD LLM Gateway** through Lemonade 11.8.1+. Paste a key into\nthe masked field; it stays in Lemonade memory until the server restarts. Provider\nsettings are shared with other clients of that server. Fireworks suggests\n`fireworks.gemma-4-31b-it` when your account exposes it. AMD Gateway accepts your\norganization's HTTPS endpoint and authentication header.\n\nType to search discovered models, then press Enter to select. The header shows\nthe active provider; remote chat sends conversation history to that provider.\nEmbeddings remain on Lemonade. Cloud setup skips downloading a local chat model.\nSee [AI providers](../docs/guides/ai-providers.mdx) for key handling and recovery.\n\n## Testing the harness against Claude\n\n`--use-claude` runs an agent on Anthropic's Claude API instead of the local\nmodel. It exists so that when something goes wrong you can tell *which* thing\nwent wrong: on a known-good model, a bad answer is the harness's fault, not the\nmodel's.\n\n```bash\ngaia-tui run gaia --query \"...\" --use-claude\ngaia-tui run gaia --use-claude --claude-model claude-haiku-4-5\n```\n\n**This sends your conversation off the machine**, which is the one thing GAIA\notherwise never does — so the chat header names the exact model in use\n(`claude · haiku-4.5`) for as long as the mode is on, colored to mark it\nremote, and the launch says so in the transcript. It needs `ANTHROPIC_API_KEY`\nset (a repo-root `.env` works too).\n\n`--claude-model` takes one of `claude-opus-5`, `claude-sonnet-5`,\n`claude-haiku-4-5`, `claude-fable-5` — there is no date suffix. Anything else\nis refused at the command line, with the accepted ids, rather than forwarded to\nAnthropic to come back a 404 mid-turn.\n\n**A Claude session does not start the local backend at all.** First-boot setup\nis skipped, so `LemonadeServer.exe` is never launched and the first answer is\nnot held behind an install. The transcript says so, and says what it costs:\nretrieval, memory and the code index still embed through Lemonade (Anthropic\nhas no embeddings API), so those need `gaia init --skip-chat-model`, or\n`/setup` in the composer, before they work.\n\nPaths that cannot honour the flag say so instead of quietly ignoring it: the\ndaemon transport refuses it, `--claude-model` without `--use-claude` refuses,\nand `chat --subprocess` tells you to put the flag in the command line you own.\n\n**Switching models mid-session:** the gaia agent also takes `/model` in the\nchat composer — `/model` alone lists every switchable id (the curated Claude\n5 family, downloaded local models, and discovered Fireworks/AMD models), and `/model <id>`\nswaps the live client without losing conversation history or loaded skills.\nTyping the space in `/model ` turns the slash palette into a model picker, so\nthe Claude ids are pickable rather than remembered; local ids stay behind bare\n`/model`, since only the agent knows what this machine has downloaded.\n\nAn unknown id, a missing credential, or an unreachable Lemonade Server all\nrefuse the switch with an actionable message and leave the session on\nwhichever model was already working. Backends never swap themselves: a local\nswitch with Lemonade down is refused with both ways forward (start the server,\nor name a Claude id), never silently answered somewhere else.\n\n## Running against a local clone\n\nThree independent layers, and only one of them has a flag.\n\n**The TUI binary** — just rebuild it: `cd tui && go build -o bin/gaia-tui ./cmd/gaia`.\n\n**GAIA core / the daemon** — no flag exists. The daemon serves whichever\ncheckout launched it, so you point it at your clone by launching it from an\neditable install (`uv pip install -e .`) or with `PYTHONPATH=<clone>/src`, then\nre-anchoring it:\n\n```bash\ngaia daemon stop && gaia daemon start\ngaia daemon status    # \"api: v1.1\" is repo source; \"v1\" is the released wheel\n```\n\nThe footgun: a per-user daemon keeps serving the checkout that launched it no\nmatter which directory you run the CLI from. If your edits do not seem to take,\nthat is almost always why.\n\n**An agent from source** — `user` (the default) runs the published frozen\nbinary; set the agent's mode environment variable to `dev` on the TUI launch so\nthe TUI and daemon request the same checkout:\n\n```bash\nGAIA_EMAIL_AGENT_MODE=dev gaia-tui run email\n```\n\nDev mode resolves the TUI caller's checkout (`git rev-parse --show-toplevel`)\nand sends the agent package directory (`<clone>/hub/agents/email/python`) to\nthe daemon. It compares that path — never executes it — against the checkout\nthe daemon is anchored to; a mismatch is refused loudly, naming both\ncheckouts and the fix. If you start the sidecar manually instead, use the same\nmode with `gaia daemon start-agent email --mode dev`.\n## An agent that only exists in your clone\n\n**It shows up in `status`.** The catalog reads `~/.gaia/agents/<id>/.installed`\nsentinel files and adds an id it has never seen rather than ignoring it, with\nsparse metadata — a sentinel only proves id and version:\n\n```bash\nmkdir -p ~/.gaia/agents/myagent\necho '{\"id\":\"myagent\",\"version\":\"0.1.0\"}' > ~/.gaia/agents/myagent/.installed\ngaia-tui status     # lists myagent under \"Installed in ~/.gaia/agents\"\n```\n\n**It will not run through the daemon.** Sidecar specs are built into GAIA core\nand there is deliberately no runtime registration route, so `gaia daemon\nstart-agent myagent` fails with `unknown agent 'myagent'; registered agents:\nemail`. `--mode dev` develops an agent core already knows about; it does not add\na new one. Getting a brand-new agent supervised end to end is not documented yet.\n\n## A daemon of your own\n\nThe daemon keys off `$HOME/.gaia/host/instance.json`, so a throwaway `HOME`\ngets you a private daemon — own port, own agents, own state — that cannot\ncollide with your normal setup or a colleague's session on the same box. Set it\nper command; an exported `HOME` outlives the command that needed it and quietly\nredirects everything else you run in that shell.\n\n```bash\nTMPHOME=$(mktemp -d)\nHOME=\"$TMPHOME\" gaia daemon start\nHOME=\"$TMPHOME\" gaia daemon stop\nrm -rf \"$TMPHOME\"\n```\n\n## The `tui` prefix\n\nA leading `tui` word is accepted and dropped — `gaia-tui tui status` and\n`gaia-tui status` are the same command — so the `gaia tui …` form used\nelsewhere in the docs keeps working.\n\n## Contributing: colours\n\nNew colours are added to `internal/ui/theme` and addressed by role\n(`theme.Text`, `theme.Danger`, `theme.Accent`…) — never a raw\n`lipgloss.Color(\"NNN\")` in a screen file. Keep package-level colour vars as\n`lipgloss.Style` values and call `.Render()` where the string is actually\nused; a `.Render()` called at package-init time bakes in a string before\n`theme.Init()` has picked light or dark, so it never changes again.\n",
      "changelog": "",
      "spec": "",
      "skill": "",
      "evaluation": "",
      "capability_matrix": "",
      "scorecard": ""
    }
  ]
}