Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions docs/guides/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,42 @@ Preferences are stored in process memory only — restarting the agent (or quitt

Senders you reply to quickly are automatically promoted to priority on the next triage run — no explicit command needed. The agent measures how long it took you to reply to each sender (using the original message's receipt timestamp as the anchor) and promotes senders whose median reply latency falls below the threshold. Promotion is applied on-demand during triage, not on a background thread, and persists across agent restarts. Works across both connected mailboxes (Gmail and Outlook).

## Scheduled daily briefing (off by default)

The same pre-scan can run on a schedule instead of only on demand — a morning
briefing that's ready before you ask. **It ships off by default**: nothing scans
your inbox until you explicitly enable the schedule.

The email sidecar stores the schedule and serves the trigger; it does **not** run
a timer. A host scheduler — GAIA's autonomy engine once it lands, or any
cron-like runner today — fires the trigger at your configured time:

```bash
# Enable once (the sidecar listens on 127.0.0.1:8131 by default):
curl -s -X PUT http://127.0.0.1:8131/v1/email/briefing/schedule \
-H "Content-Type: application/json" \
-d '{ "enabled": true, "time": "08:00", "max_messages": 25 }'

# What the scheduler fires daily:
curl -s -X POST http://127.0.0.1:8131/v1/email/briefing/run
```

The run returns a `kind: "email_briefing"` envelope whose `pre_scan` field is the
same `email_pre_scan` card the Agent UI renders, and also persists the envelope to
`~/.gaia/email/briefing_latest.json` so a consumer can pick up the latest briefing
without having been the live caller (the interim delivery surface until push
delivery arrives with the autonomy engine).

Two guarantees, both fail-loud:

- **Disabled means disabled.** The trigger re-checks `enabled` itself — a disabled
schedule answers `409` before any mailbox access, so a stale or misfiring
scheduler can never scan a mailbox whose briefing you turned off.
- **A corrupt schedule file is an error, not a guess.** If
`~/.gaia/email/briefing.json` exists but can't be parsed, the endpoints return
`500` naming the file and the fix — the agent never silently falls back to
"disabled" (or worse, "enabled").

## Action surface

### Read
Expand Down Expand Up @@ -286,9 +322,10 @@ isolated, and dogfoods the exact binary shipped to integrators.
`GAIA_EMAIL_AGENT_MODE` only selects which *process* answers (`user` default /
`dev`); there is no in-process fallback. Two surfaces run through it:

- **The `/v1/email/*` REST surface** — the full schema-2.1 contract (triage,
batch triage, search, inbox pre-scan, draft/send + confirm, archive/unarchive,
quarantine/unquarantine, calendar view/preview/create/respond, health,
- **The `/v1/email/*` REST surface** — the full schema-2.2 contract (triage,
batch triage, search, inbox pre-scan, the scheduled daily briefing, draft/send
+ confirm, archive/unarchive, quarantine/unquarantine, calendar
view/preview/create/respond, health,
version). This is exactly what third-party integrators consume, so the UI
exercises the real product. The sidecar's connector OAuth **write** routes are
never exposed (all grant writes stay on the backend's single-writer path).
Expand All @@ -301,7 +338,7 @@ The sidecar is spawned lazily on first email use and tree-killed on shutdown; th
REST surface and the chat agent share one sidecar process.

**Chat tool surface (in-app email agent):** the sidecar-backed chat agent exposes
the tools the schema-2.1 REST contract serves today — inbox pre-scan, search,
the tools the REST contract serves today — inbox pre-scan, search,
calendar view, and archive + undo. Tools that have no REST route yet (labels,
stars, mark-read, move, trash/delete, summarize, profile, preferences, forward,
send) are not exposed in the chat agent until their routes land; the underlying
Expand Down
19 changes: 19 additions & 0 deletions hub/agents/npm/agent-email/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire
`SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR
bump is always at least a package MINOR bump with a migration note.

## Unreleased

Contract bumped to `SCHEMA_VERSION` **2.2** — additive, no existing shape change,
so `checkVersion` (MAJOR-only) keeps accepting 2.x clients.

- **Scheduled daily inbox briefing** (#1608): the sidecar can now turn the
pre-scan into a scheduled morning briefing instead of only answering on
demand. New REST surface: `GET`/`PUT /v1/email/briefing/schedule` (persisted
schedule — **off by default**; enabling it is an explicit `PUT`) and
`POST /v1/email/briefing/run` (the trigger a host scheduler fires daily; the
sidecar stores the preference but runs no timer — that belongs to the host /
GAIA's autonomy engine #555). A disabled schedule makes the trigger a `409`
before any mailbox access, so a misfiring scheduler can never scan a mailbox
whose briefing is off. The run returns a `kind: "email_briefing"` envelope
whose `pre_scan` is byte-compatible with `prescan()`'s card, and atomically
persists it to `~/.gaia/email/briefing_latest.json` as the interim pull-based
delivery surface until push delivery lands with the autonomy engine.
REST-only for now — no typed client methods yet; drive it with `fetch`.

## 0.3.0

Contract bumped to `SCHEMA_VERSION` **2.1** — additive, no triage shape change, so
Expand Down
12 changes: 8 additions & 4 deletions hub/agents/npm/agent-email/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @amd-gaia/agent-email

[![npm version](https://img.shields.io/npm/v/@amd-gaia/agent-email?label=version)](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.1** · last updated **2026-06-26**
[![npm version](https://img.shields.io/npm/v/@amd-gaia/agent-email?label=version)](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.2** · last updated **2026-07-01**

**Eval scorecard (v0.3.0): aggregate 84.67 / 100** — within-one-bucket **acceptance** accuracy (3-run mean, 95% CI [83.4, 86.0]) over 100 of 220 labeled emails ([`./SCORECARD.md`](./SCORECARD.md)). Triage priority is ordinal, so the bar (#1437) credits exact-or-adjacent buckets — what users feel — not exact 4-way match (reported as a secondary, 0.46). The linked scorecard carries the full recipe, metrics + reported secondaries, run-to-run variance/CI, a per-category breakdown, the run environment, a worked recomputation, and reproduction steps.

Expand Down Expand Up @@ -214,9 +214,13 @@ When you need finer control, the steps are exported individually:
| `checkVersion(client)` | Throw if the sidecar's contract MAJOR differs from the client's. |
| `shutdown(sidecar)` | Kill the whole process tree. |

As of `SCHEMA_VERSION` 2.1 this package exposes inbox **search** (read-only),
the **archive** / phishing-**quarantine** mailbox actions (+ their undo), and
calendar **view / create / respond**. The remaining mailbox **actions** (label,
As of `SCHEMA_VERSION` 2.2 this package exposes inbox **search** (read-only),
the **archive** / phishing-**quarantine** mailbox actions (+ their undo),
calendar **view / create / respond**, and a **scheduled daily briefing** (#1608):
an off-by-default schedule plus a `POST /v1/email/briefing/run` trigger that
turns the pre-scan into a morning briefing — REST-only for now (no typed client
methods yet; drive it with `fetch`), with the timer owned by your app or GAIA's
autonomy engine, not the sidecar. The remaining mailbox **actions** (label,
move, mark read/unread) are part of the full agent but not yet exposed through
this package — see
[`SPEC.md`](https://github.com/amd/gaia/blob/main/hub/agents/npm/agent-email/SPEC.md) for the complete surface.
Expand Down
22 changes: 22 additions & 0 deletions hub/agents/npm/agent-email/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ reversible inside a 30s window via the ungated `unarchive` / `unquarantine`. Eve
non-2xx response throws `HttpError` (`status`, `url`, `bodyText`) — handle it; there is
no silent null.

**Scheduled daily briefing (schema 2.2, REST-only).** The sidecar can turn the
pre-scan into a scheduled morning briefing (#1608), but it does **not** run a
timer — your app (or GAIA's autonomy engine, once it lands) owns the schedule and
fires the trigger. No typed client methods yet; use `fetch` against the sidecar:

```ts
// Enable once (ships OFF by default):
await fetch(`${base}/v1/email/briefing/schedule`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true, time: "08:00", max_messages: 25 }),
});
// What your scheduler fires daily — returns { result: { kind: "email_briefing",
// pre_scan: { kind: "email_pre_scan", … } } }; renders with the same card as prescan().
const briefing = await fetch(`${base}/v1/email/briefing/run`, { method: "POST" });
```

A disabled schedule makes the trigger a `409` **before any mailbox access** — a
misfiring scheduler can never scan a mailbox whose briefing is off. Each run also
persists the envelope to `~/.gaia/email/briefing_latest.json` for pull-based
consumers.

## 5. From a renderer (Electron / browser)

The sidecar serves **same-origin only — no CORS**. A renderer on a different origin
Expand Down
50 changes: 43 additions & 7 deletions hub/agents/npm/agent-email/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Detailed reference for `@amd-gaia/agent-email`. For a quick start, see
[`README.md`](./README.md); for an AI-assisted integration walkthrough, see
[`SKILL.md`](./SKILL.md). The contract version is `SCHEMA_VERSION` **2.1**.
[`SKILL.md`](./SKILL.md). The contract version is `SCHEMA_VERSION` **2.2**.

## Architecture

Expand Down Expand Up @@ -53,6 +53,9 @@ result.
| `POST /v1/email/triage/batch` | `triageBatch()` | **Standalone** | Same as `triage` for an `items` array (1–100). Returns a parallel `results` array, order-preserved; per-item failures isolate (HTTP 200 can carry errored items — inspect `results[].error`). A `502` fails the whole batch (Lemonade unreachable). |
| `POST /v1/email/search` | `search()` | **Connector** | Read-only inbox search. A connected Google/Microsoft mailbox (`503` if none, `400` if 2+); **no** confirmation token. Lists messages matching `query`/`labels` and returns metadata only (no body). |
| `POST /v1/email/prescan` | `prescan()` | **Connector** | Reads recent inbox messages from the connected Google/Microsoft mailbox and returns the read-only triage-card envelope (`kind: "email_pre_scan"`). `503` if no mailbox is connected, `400` if 2+ are. Heuristic-only — no Lemonade call. |
| `GET /v1/email/briefing/schedule` | — (REST only, schema 2.2) | **Standalone** | Reads the persisted daily-briefing schedule (#1608). Ships **off by default**; an absent config reports as disabled, a corrupt config file is a `500` naming the file and the fix. |
| `PUT /v1/email/briefing/schedule` | — (REST only, schema 2.2) | **Standalone** | Replaces the schedule (full-document, contract-validated — bad `time`/`max_messages` → `422` before anything persists). The only way to turn the briefing on. |
| `POST /v1/email/briefing/run` | — (REST only, schema 2.2) | **Connector** | The scheduled briefing trigger. No body — the persisted schedule is the config. Disabled → `409` **before any mailbox access**; enabled → runs the pre-scan path, returns the `kind: "email_briefing"` envelope, and persists it to `~/.gaia/email/briefing_latest.json`. |
| `POST /v1/email/draft` | `draft()` | **Standalone** | Nothing external — wraps your `(to, subject, body)` and returns a single-use confirmation token. |
| `POST /v1/email/send` | `send()` | **Connector** | A valid `draft` confirmation token **and** a connected Google/Microsoft mailbox. The token gate fires first: no/invalid token → `403`; then `503` if no mailbox is connected, `400` if 2+ are. |
| `POST /v1/email/confirm` | `confirmAction()` | **Standalone** | Nothing external — mints a single-use token for `"archive"`/`"quarantine"`, bound to that exact `(action, message_id)`. |
Expand Down Expand Up @@ -109,6 +112,37 @@ const q = await client.quarantine({
await client.unquarantine({ action_id: q.action_id });
```

### Scheduled daily briefing (schema 2.2, #1608)

The daily briefing turns the pre-scan into a scheduled morning summary. The
sidecar stores the preference and serves the trigger — it does **not** run a
timer. A host scheduler (GAIA's autonomy engine once it lands — #555 — or any
cron-like runner in your app) fires `POST /v1/email/briefing/run` at the
configured `time`:

```bash
# Enable once (ships off by default):
curl -s -X PUT http://127.0.0.1:8131/v1/email/briefing/schedule \
-H "Content-Type: application/json" \
-d '{ "enabled": true, "time": "08:00", "max_messages": 25 }'

# What the scheduler fires daily:
curl -s -X POST http://127.0.0.1:8131/v1/email/briefing/run
# → { "schema_version": "2.2",
# "result": { "kind": "email_briefing", "generated_at": "…",
# "schedule": { … }, "pre_scan": { "kind": "email_pre_scan", … } } }
```

The trigger re-checks `enabled` itself: a disabled schedule is a `409` **before
any mailbox access**, so a stale or misfiring scheduler can never scan a mailbox
whose briefing the user turned off. `result.pre_scan` is byte-compatible with
`prescan()`'s envelope, so a consumer that renders the pre-scan card renders the
briefing. Each run also atomically persists the envelope to
`~/.gaia/email/briefing_latest.json` — the interim pull-based delivery surface
until push delivery lands with the autonomy engine. These endpoints are
REST-only for now (no typed client methods yet); drive them with `fetch` or any
HTTP client.

### Calendar (view / create / respond, schema 2.1)

> **Confirmation gating — deliberate asymmetry.** `send` and calendar **create**
Expand Down Expand Up @@ -276,12 +310,13 @@ connection through this package's API**, so connector-backed calls only work on
machine where the mailbox is already connected in GAIA. Triage and draft, which
need no connector, work anywhere.

As of `SCHEMA_VERSION` 2.1 this package's REST API exposes the read-only inbox
As of `SCHEMA_VERSION` 2.2 this package's REST API exposes the read-only inbox
**search** and **pre-scan** (`search` / `prescan`), the **archive** and
phishing-**quarantine** mailbox actions plus their undo (`confirmAction` / `archive` /
`unarchive` / `quarantine` / `unquarantine`), and calendar **view / create / respond**
`unarchive` / `quarantine` / `unquarantine`), calendar **view / create / respond**
(`listCalendarEvents` / `previewCalendarEvent` / `createCalendarEvent` /
`respondToCalendarEvent`). The full GAIA email agent does more on the live mailbox
`respondToCalendarEvent`), and the **scheduled daily briefing** (REST-only,
`/v1/email/briefing/*`, #1608). The full GAIA email agent does more on the live mailbox
(label, move, mark spam) and calendar (detect / conflicts); those remaining actions are
connector-gated by definition and are **not exposed through this package's REST API
yet**.
Expand Down Expand Up @@ -328,9 +363,10 @@ editors autocomplete but your code never imports them.

TypeScript types in `src/types.ts` mirror two Python sources of truth:

- `contract.py` — the triage request/response contract plus the schema-2.1 additions:
inbox search, mailbox actions (archive / quarantine + reversal), calendar, and
pre-scan (`SCHEMA_VERSION = "2.1"`).
- `contract.py` — the triage request/response contract plus the schema-2.1 additions
(inbox search, mailbox actions — archive / quarantine + reversal — calendar, and
pre-scan) and the schema-2.2 daily-briefing models (`SCHEMA_VERSION = "2.2"`).
The briefing endpoints are REST-only for now — no TS types/methods yet.
- `api_routes.py` — the local draft/send confirmation handshake models.

They are hand-written (vs. generated from `/openapi.json`) because the contract is
Expand Down
2 changes: 1 addition & 1 deletion hub/agents/npm/agent-email/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ function majorOf(version: string): number {
}

export interface VersionCheckOptions {
/** The apiVersion the client was built against. Default SCHEMA_VERSION ("2.1"). */
/** The apiVersion the client was built against. Default SCHEMA_VERSION ("2.2"). */
expectedApiVersion?: string;
}

Expand Down
5 changes: 4 additions & 1 deletion hub/agents/npm/agent-email/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@
* confirm-token handshake (#1779)
* - calendar view/create/respond (#1780)
* - inbox pre-scan (POST /v1/email/prescan, #1778)
* Schema 2.2 (additive) adds the scheduled daily-briefing surface (#1608):
* GET/PUT /v1/email/briefing/schedule and POST /v1/email/briefing/run —
* REST-only for now; no typed client methods/types yet.
*/

/** Frozen contract version echoed by the server's `/version` endpoint. */
export const SCHEMA_VERSION = "2.1" as const;
export const SCHEMA_VERSION = "2.2" as const;

/**
* The five-bucket triage taxonomy (schema 2.0 — contract.py: EmailCategory).
Expand Down
2 changes: 1 addition & 1 deletion hub/agents/npm/agent-email/test/browser-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe("browser entry (./client)", () => {

it("client-entry exports SCHEMA_VERSION and request/response types (runtime const)", async () => {
const mod = await import("../src/client-entry.js");
expect(mod.SCHEMA_VERSION).toBe("2.1");
expect(mod.SCHEMA_VERSION).toBe("2.2");
});

it("client-entry does NOT export spawnSidecar (Node-only)", async () => {
Expand Down
4 changes: 2 additions & 2 deletions hub/agents/npm/agent-email/test/version-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ function clientReturning(apiVersion: string, agentVersion = "0.2.0"): EmailClien
}

describe("checkVersion", () => {
it("accepts apiVersion 2.0 against the current 2.1 default (same MAJOR 2)", async () => {
it("accepts apiVersion 2.0 against the current 2.2 default (same MAJOR 2)", async () => {
// checkVersion enforces MAJOR only, so a 2.0 sidecar stays compatible with
// this client's default-expected SCHEMA_VERSION (now 2.1).
// this client's default-expected SCHEMA_VERSION (now 2.2).
const info = await checkVersion(clientReturning("2.0"));
expect(info.apiVersion).toBe("2.0");
});
Expand Down
Loading
Loading