feat(hub): sidecar-served email-agent playground (localhost-only) (#1796)#1814
Conversation
SummaryClean, well-scoped feature that gives a developer a zero-setup way to see the email agent working: the sidecar serves its own GAIA-styled playground page with live health/triage/draft, and the "local-only" claim is enforced structurally via a I also verified the response shapes the page's JS consumes against the real contract models — Issues🟢 MinorCI may skip this test rather than run it (pre-existing pattern). Redundant Optional: no doc pointer. The playground URL ( Strengths
VerdictApprove with suggestions — no blocking issues. The minor items (dead |
) A developer evaluating the email agent had no zero-setup way to see it work — clone, build, run a CLI. Add a GAIA-styled page the sidecar serves at /v1/email/playground that runs entirely against the local sidecar: a stack-health check (sidecar up + Lemonade/model diagnosis), live triage and draft, and a button that exercises the /v1/init readiness endpoint. Localhost-only is structural, not promised: the page is served same-origin (no CORS, no remote-controlled code) and the route ships Content-Security-Policy: connect-src 'self', so the browser refuses any non-local fetch — email content can't leave the machine. The /init button degrades loudly (clear message) until the readiness endpoint (#1795) lands.
Review follow-ups: probe the email-scoped /v1/email/version (not root /version) so the health check works when the router is mounted on a product app, not just standalone; normalize network/transport failures to status 0 so a dropped sidecar is diagnosed cleanly instead of showing 'HTTP undefined'; drop two redundant innerHTML resets.
The spec page used an off-brand orange/blue/green palette; restyle it to the GAIA dark+gold tokens (matching the website and the new playground) with a system font stack (no webfont — the page stays self-contained). Add a 'Convenience pages' section documenting GET /v1/email/spec and GET /v1/email/playground.
…EADME Document the localhost-only playground the sidecar serves at /v1/email/playground, with the screenshot embedded like the npm package's architecture diagram.
…itHub raw.githubusercontent isn't a CDN (rate-limited, not for hotlinking) and the README ships to PyPI. Point at the versioned hub artifact path (hub.amd-gaia.ai/agents/email/0.1.0/playground.webp), matching how the binaries are referenced. Resolves once the package is deployed to R2.
The flat stack of cards was hard to scan. Convert each section to a native <details> dropdown (no JS, CSP-safe) with a chevron and a one-line descriptor; Stack health opens by default and carries a live ready/not-ready badge in its summary so the collapsed state still tells you the status. Refresh the screenshot.
…layground Install & setup now drives provisioning via the API instead of copy-paste: a 'Run gaia init' button POSTs /v1/email/init and streams the output into a terminal panel (line by line, tolerant of SSE or plain-text framing), with a running/ok/failed status and an auto health-recheck on success. Built to the contract the /init PR (#1813) will serve — GET = readiness, POST = provision; until it lands the button reports the endpoint as unavailable. The manual steps remain below, and the CLI hint is now 'gaia init --profile email'.
1173fc5 to
df3ab33
Compare
A gold v<version> badge next to the title, sourced from /version (the running sidecar's agentVersion) — mirrors the v0.2.0 badge on architecture.webp, so a screenshot taken from a given build can never claim a stale version.
#1795) GET /v1/email/init tells you the triage stack isn't ready, but a frozen-binary sidecar had no way to *fix* it. POST /v1/email/init is the provisioning companion: it tells a running local Lemonade to download the configured email model and streams newline-delimited (text/plain) progress so a consumer (the #1814 playground) can render it terminal-style, line by line. A ✓-prefixed final line means success, ✗ means failure. Scope is the frozen-binary reality: the sidecar can't run the full `gaia init` or install Lemonade itself (chicken-and-egg). If Lemonade is unreachable the verb returns a real 503 with an actionable line and pulls nothing; once a pull starts the response is a committed 200 (HTTP status can't change mid-stream), so the trailing ✓/✗ line is the authoritative outcome. The pull posts only `model_name` (no `recipe`) for the built-in email model — the #1655 trap. GET behavior is unchanged. POST is a streaming operational verb (like GET /spec), so it's kept out of the JSON OpenAPI and documented in the HTML spec (spec_html.py + specification.html) instead.
…sets It's an R2-upload source (served from hub.amd-gaia.ai, referenced by absolute URL), not a Mintlify doc — keeping it under docs/ wrongly tripped the docs-validation workflow on pre-existing plans/spec parsing debt. Co-locate it with the agent (parallel to the npm architecture.webp).
…ghter tests Code-review follow-ups: - doProvision: wrap the streaming read in try/finally so a mid-stream drop is reported and the 'Run gaia init' button is never left stuck disabled. - Eliminate the last innerHTML write (use textContent) so the page is 100% textContent-only; add a test asserting zero .innerHTML sinks. - Guard the /version fields so the header badge shows 'v?' rather than 'vundefined' on a malformed response. - Fix the readiness button label (/v1/init -> /v1/email/init). - Tests: assert the EXACT sidecar paths the JS calls (the old check passed on a substring), and pin the CSP allowlist (no connect-src wildcard, no unsafe-eval, base-uri/form-action present).
…eck (bump 0.2.0) (amd#1822) ## Why this matters The email package's version lived in eight files of six different types (Python, YAML, TOML, JSON, Markdown, HTML) with no tool to keep them in sync, so references drifted silently. On `main` right now, `binaries.lock.json` still pins both `agentVersion` and `baseUrl` to `…/agents/email/0.1.0` while every other file already says `0.2.0` — a static pointer to a *prior* hub deployment that no test caught. After this change, `AGENT_VERSION` in `version.py` is the one source of truth, a stamp script syncs every other reference from it, and a `--check` mode fails the build loudly on any mismatch — so a stale version reference can never ship again. Mirrors the Agent UI's existing pattern (`installer/version/bump-ui-version.mjs`: one source → stamps dependents → `--check` gated in CI). **Stamped file types** (all driven from `AGENT_VERSION`): the YAML manifest, `pyproject.toml`, npm `package.json`, the lock's `agentVersion` + `baseUrl`, the two README image URLs, and the `architecture.html` version badge. `API_VERSION` (the REST/contract version) is deliberately **not** touched — it's the contract version, independent of the package build version. **Cross-branch skip-with-warning:** three npm-side targets (README image URLs, `assets/architecture.html`) don't exist on `main` yet — they live on in-flight branches (amd#1776, amd#1814). The script **skips them with a warning** rather than failing, so it works across the partial state today and will stamp them correctly once those branches merge. This PR only touches version strings + the new script + the two workflows + the new test; it does not touch the playground HTML, the `/v1/email/init` endpoint, or the npm client (owned by amd#1814/amd#1813/amd#1776). This PR also fixes the existing `binaries.lock.json` drift (0.1.0 → 0.2.0) as the first run of the new stamper. ## Test plan - [x] `python hub/agents/python/email/packaging/stamp_version.py --check` passes on the post-bump tree (exit 0) - [x] Mutating any target to a wrong version makes `--check` exit non-zero (covered by `test_stamp_version.py`) - [x] `python -m pytest hub/agents/python/email/tests/test_stamp_version.py` — 10 passed (hermetic, no network) - [x] Version-contract tests green with the bump: `test_agent_version_matches_package_export` + `test_agent_version_matches_package_metadata` (pyproject + in-code `AGENT_VERSION` both 0.2.0) - [x] `black` + `isort` clean on the new files - [x] `--check` wired into `release_agent_email.yml` (before publish) and `test_email_agent_unit.yml` (early PR drift gate; npm-side paths added to its triggers)
## Why this matters **Before:** in the email-agent playground you could triage and draft, but **Send** was a dead "connector required" notice — no way to connect a mailbox or send for real. **After:** the playground gains a collapsible **Connectors** panel — paste your own Google/Microsoft OAuth client credentials, connect Gmail/Outlook through **GAIA's own connector framework** (the same flow the Agent UI and `gaia connectors` use), pick the mailbox in **Send**, and send live. The connector routes are mounted **unconditionally** by the sidecar, next to the playground page it already always serves. They reuse `gaia.connectors` (already compiled into the sidecar binary via the send path — no new dependency) and are **excluded from the OpenAPI contract** (`include_in_schema=False`): a playground convenience, not part of the frozen email REST contract. This **survives npm/R2 packaging** — the thin npm package just fetches and spawns that same binary. The connection lives in GAIA's machine-global keyring store shared by every surface, so a real consuming app can establish it via the Agent UI / CLI and the sidecar's send just reads it. Builds on the merged playground (amd#1796 / amd#1814). ## What's in it - **Connector routes** (`include_in_schema=False`): `GET /v1/email/connectors` (status), `POST …/{provider}/configure` (start OAuth), `POST …/{provider}/complete` (wait, then grant the mailbox to `installed:email` so send resolves it), `DELETE …/{provider}` (full disconnect — clears tokens *and* per-agent grants, so a reconnect is clean). - **Connectors panel** at the top of the playground: per-provider client_id/secret → connect; a **Disconnect** button on each connected account (the connect form returns afterward). - **Send** gains an always-present **From-mailbox dropdown**: lists connected mailboxes alphabetically, selects the first, and passes the choice to draft (binding the send token) — so a 2-mailbox setup never hits the send API's `400 "can't choose"`. Empty + disabled when nothing is connected. - Account emails are shown only when *every* connected mailbox has one (no mixed "Gmail · addr" / bare "Outlook"); the store's `"default"` no-email sentinel is never surfaced. **Send needs both a connection and a grant.** `connected_mailbox_providers()` picks the provider by connection presence, but the actual send fetches a token via `get_access_token_sync(agent_id="installed:email")`, which is grant-gated (the amd#1592 orphan failure mode). `…/complete` grants to exactly that id. ## Evidence **UI** — captured from the live sidecar (`Send` itself was not fired — it dispatches a real email; account emails are suppressed in the connected shot because not every connected mailbox resolved one): _Not connected — paste your own Google/Microsoft OAuth client credentials; **Send** is disabled:_  _Connected — a **Disconnect** button on each account; **Send** picks the mailbox from a dropdown and is ready:_  **Always-on, out of the contract** (no flag, no env): ``` $ python …/packaging/server.py --port 8135 $ curl -s -o /dev/null -w '%{http_code}' :8135/v1/email/connectors # 200 (always mounted) $ curl -s :8135/openapi.json | jq '.paths|keys|any(test("connectors"))' # false (excluded from contract) $ curl -s :8135/openapi.json | jq '.paths|has("/v1/email/triage")' # true (core contract intact) ``` **Tests:** the full email unit suite passes, incl. new connector-route, gating/contract-exclusion, disconnect, and default-sentinel coverage. ``` $ PYTHONPATH=hub/agents/python/email python -m pytest tests/unit/agents/email/ -q 417 passed ``` ## Test plan - [ ] `PYTHONPATH=hub/agents/python/email python -m pytest tests/unit/agents/email/test_connector_routes.py tests/unit/agents/email/test_playground.py -q` - [ ] `python hub/agents/python/email/packaging/server.py --port 8131`, open `/v1/email/playground` → connect Gmail/Outlook with your own OAuth client creds; pick the mailbox in Send → live send; Disconnect → the connect form returns. - [ ] `curl :8131/openapi.json` → `/v1/email/connectors` is **not** in `paths`; `/v1/email/triage` is. --------- Co-authored-by: Tomasz Iniewicz <tomasz@iniewicz.com>
A developer evaluating the email agent has no zero-setup way to see it work — clone the repo, build the package, run a CLI. This adds a GAIA-styled page the sidecar serves itself at
http://127.0.0.1:8131/v1/email/playground: visit it and you get a stack-health check (sidecar up + a plain-language Lemonade/model diagnosis — "Lemonade not found", "model not downloaded"), live triage and draft against the running sidecar, a button that exercises the/v1/initreadiness endpoint, and copy-paste install shortcuts.Localhost-only is structural, not a promise. The page is served same-origin (no CORS, no remote-controlled JS) and the route ships
Content-Security-Policy: connect-src 'self', so the browser refuses any non-local fetch — email content can't leave the machine. Inference stays on local Lemonade.The
/initbutton consumes the readiness endpoint from #1795, implemented in PR #1813 (branchclaudia/task-4a1065f9). This branch predates it, so/v1/email/initreturns 404 here — the button fails loudly with a clear message ("update the sidecar — ships with #1795") rather than breaking, and lights up once #1795 merges. The endpoint is not duplicated here; the playground only consumes it.Closes #1796.
Also in this PR
hub/agents/python/email/README.md), mirroring the npm package's architecture-diagram embed./v1/email/specpage on-brand. It used an off-brand orange/blue/green palette; restyled to the GAIA dark+gold tokens (matching the website + playground), self-contained (system fonts, no webfont), and added a "Convenience pages" section listing/specand/playground.Test plan
PYTHONPATH=hub/agents/python/email python -m pytest tests/unit/agents/email/test_playground.py tests/unit/agents/email/test_spec_html.py tests/test_email_openapi_conformance.py -q— 56 pass (route 200, CSP pins egress to'self', no external resources,/playgroundexcluded from/openapi.json, spec page still self-contained).python hub/agents/python/email/packaging/server.py --host 127.0.0.1 --port 8131), openhttp://127.0.0.1:8131/v1/email/playground:lemonade-server serveto see both states).Content-Security-Policy: connect-src 'self'.http://127.0.0.1:8131/v1/email/spec— renders in GAIA dark+gold, lists the playground under "Convenience pages".