Skip to content

feat(hub): sidecar-served email-agent playground (localhost-only) (#1796)#1814

Merged
kovtcharov merged 12 commits into
mainfrom
feat/email-agent-playground
Jun 22, 2026
Merged

feat(hub): sidecar-served email-agent playground (localhost-only) (#1796)#1814
kovtcharov merged 12 commits into
mainfrom
feat/email-agent-playground

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

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/init readiness endpoint, and copy-paste install shortcuts.

email-agent playground

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 /init button consumes the readiness endpoint from #1795, implemented in PR #1813 (branch claudia/task-4a1065f9). This branch predates it, so /v1/email/init returns 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

  • Added a "Playground" section + screenshot to the email agent README (hub/agents/python/email/README.md), mirroring the npm package's architecture-diagram embed.
  • Brought the sibling /v1/email/spec page 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 /spec and /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, /playground excluded from /openapi.json, spec page still self-contained).
  • Start the sidecar (python hub/agents/python/email/packaging/server.py --host 127.0.0.1 --port 8131), open http://127.0.0.1:8131/v1/email/playground:
    • Stack health shows ✓ Sidecar; the Lemonade/model row diagnoses correctly (start/stop lemonade-server serve to see both states).
    • Triage + Draft run live (with Lemonade up); "Run readiness check · /v1/init" shows the graceful 404 message on this branch.
    • Response header includes Content-Security-Policy: connect-src 'self'.
  • Open http://127.0.0.1:8131/v1/email/spec — renders in GAIA dark+gold, lists the playground under "Convenience pages".

@github-actions github-actions Bot added the tests Test changes label Jun 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Summary

Clean, 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 Content-Security-Policy: connect-src 'self' header rather than merely promised. The code follows the established patterns in this package exactly (deferred renderer import like email_spec, importorskip test layout like test_spec_html.py), and the tests assert the boundary contract (CSP header present, no external resources, route excluded from OpenAPI) instead of just a 200 — exactly the "verify call validity, not just invocation" principle from CLAUDE.md. I ran the new suite locally: 6/6 pass. No blocking issues; only minor/optional notes below.

I also verified the response shapes the page's JS consumes against the real contract models — res.result.category / is_spam / is_phishing / summary / suggested_action, action_items[].description / .due_hint, and res.confirmation_token — and they all match EmailTriageResponse (result= wrapper) and EmailDraftResponse. No prompt-injection content in the diff.

Issues

🟢 Minor

CI may skip this test rather than run it (pre-existing pattern). tests/unit/agents/email/ all gate behind pytest.importorskip("gaia_agent_email"), but test_email_agent_unit.yml installs only -e ".[dev,api]" and there's no pythonpath/conftest insertion for hub/agents/python/email, so the import isn't satisfied in that job. Your test-plan command pins it explicitly (PYTHONPATH=hub/agents/python/email …), which is why it passes for you — but the PR gate would skip it (green-but-empty), as it does for the whole email suite today. This isn't introduced by this PR and shouldn't block it; worth confirming the suite actually executes in CI rather than silently skipping. (.github/workflows/test_email_agent_unit.yml:83)

Redundant innerHTML = "" on freshly-created elements. Two spots set innerHTML to empty on an element that was just createElement'd, immediately before assigning textContent — dead no-ops that read as if markup were involved. Safe to drop. (playground_html.py:310, playground_html.py:336)

    out.textContent = ""; const d = diagnose(e);
    const p = document.createElement("div");
    p.textContent = (e.status===502||e.status===0) ? ("✗ " + d.cause + " — " + d.hint + (d.cmd?(" ("+d.cmd+")"):"")) : ("✗ HTTP " + e.status + ": " + (e.body||e.message));

Optional: no doc pointer. The playground URL (/v1/email/playground) isn't mentioned in docs/guides/email.mdx. It's a self-discoverable dev page so this is low priority, but a one-line "open http://127.0.0.1:8131/v1/email/playground to try it" would aid discoverability.

Strengths

Verdict

Approve with suggestions — no blocking issues. The minor items (dead innerHTML lines, optional doc pointer, and confirming the CI gate runs rather than skips the suite) can be addressed in this PR or a follow-up.

@kovtcharov kovtcharov self-assigned this Jun 22, 2026
@kovtcharov kovtcharov requested review from itomek and itomek-amd June 22, 2026 16:56
@github-actions github-actions Bot added the documentation Documentation changes label Jun 22, 2026
itomek
itomek previously approved these changes Jun 22, 2026
)

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'.
@kovtcharov kovtcharov force-pushed the feat/email-agent-playground branch from 1173fc5 to df3ab33 Compare June 22, 2026 17:35
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.
kovtcharov added a commit that referenced this pull request Jun 22, 2026
#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).
@kovtcharov kovtcharov added this pull request to the merge queue Jun 22, 2026
Merged via the queue into main with commit 45e233d Jun 22, 2026
33 checks passed
@kovtcharov kovtcharov deleted the feat/email-agent-playground branch June 22, 2026 20:52
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jun 23, 2026
…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)
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Jun 23, 2026
## 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:_

![Playground — not
connected](https://raw.githubusercontent.com/amd/gaia/feat/email-playground-connectors/docs/assets/img/email-playground-not-connected.png)

_Connected — a **Disconnect** button on each account; **Send** picks the
mailbox from a dropdown and is ready:_

![Playground —
connected](https://raw.githubusercontent.com/amd/gaia/feat/email-playground-connectors/docs/assets/img/email-playground-connected.png)

**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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(hub): sidecar-served email-agent playground (localhost-only) + hub launcher

2 participants