Skip to main content

Email Agent Packaging

Milestone: Email Agent & Platform Foundations | Status: Planning | Priority: High
Work in Progress — this plan is the authoritative scope for the packaging milestone. Distribution mechanics (npm layout, signing ownership) are settled below; open decisions are called out explicitly.

Overview

The email triage agent already builds as a Python wheel + sdist (gaia-agent-email) and is shareable as a GitHub prerelease asset. That path serves Python integrators and partner evaluation, but it does not serve the primary consumer: a JavaScript/Node host web app that wants to embed the agent as a local service with a one-command install and no Python/C++ toolchain on the user’s machine. This milestone delivers that path: the email agent distributed through npm as a thin JS/TS client plus per-platform, self-contained binaries (a frozen Python build and a native C++ build), runnable as a local sidecar the host app spawns and talks to over a loopback REST API.

Goals

  • A host web app can run npm install @amd-gaia/agent-email and, with one build-time fetch step, get a working local email agent — no Python install, no C++ toolchain, no compile step.
  • The npm package stays lightweight (JS/TS client + fetch CLI only); the large binaries live in R2 object storage, the single source of truth.
  • One REST contract backs both the Python and C++ implementations; the host integrates once and is implementation-agnostic.
  • The platform binary is present on disk at build/sign time (fetched before the host signs its app), so it’s covered by the host’s code signature and runs fully offline at runtime.
  • Each platform fetches only its own binary, integrity-verified against a pinned SHA-256 — fail loudly on mismatch.
  • Binaries are signed/notarized so a host app that bundles them stays signable.

Non-goals (this milestone)

  • Re-enabling the paused PyPI publish path (tracked separately under #1179).
  • An in-process Node native addon (N-API). Captured as a future fast-path in Deferred, not built here.
  • Third-party host glue. We ship the agent + client + contract; an external host owns its own integration. Exception: the GAIA Agent UI is our reference consumer and its migration to the sidecar is in scope as the validation phase (Phase 6).
  • New triage capabilities. This is a packaging and distribution milestone, not a feature milestone.

Current State


Target Architecture

Sidecar model

The agent runs as a separate local process the host app spawns, supervises, and talks to over loopback HTTP. This is the default because it is language-agnostic (the Python and C++ builds look identical to the host — spawn a binary, hit a port), crash-isolated (a segfault in the agent does not take down the host), and allows independent release cadence.

Distribution — thin npm package + build-time R2 fetch

The npm package carries only the JS/TS client and a small fetch CLI — no binaries. The large per-platform binaries live in R2 (the existing workers/agent-hub/ backend), which becomes the single source of truth. The host pulls its platform binary with an explicit build-time step, before it signs its app.
The binaries are stored under the existing Agent Hub R2 layout (agents/<id>/<version>/…) and served by the same Worker that already fronts the bucket. The fetch CLI resolves ${process.platform}-${process.arch}, downloads that one object, verifies it against a SHA-256 pinned in the package (fail loudly on mismatch), and writes it to a host-specified resources directory:
Build-time, not runtime. The fetch runs in the host’s build pipeline before code-signing, so the binary is on disk when the host signs and notarizes — it is covered by the host’s signature and needs no network at runtime. A runtime / first-init download is explicitly rejected: a binary fetched after the app is signed is not covered by the signature (macOS Gatekeeper quarantine blocks it; Windows SmartScreen flags it) and breaks offline first-run. Why this over the alternatives:
  • Binaries in npm via optionalDependencies (esbuild/swc pattern) — considered, and viable; it’s the most reproducible (lockfile-pinned) and lowest-friction for the consumer (npm install just works). Rejected as the default here because frozen-Python binaries are large (~40–100 MB each), and we want one canonical binary store (R2) shared across the npm path, the gaia agent install Hub path, and the GitHub manual share — rather than duplicating tens of MB into the npm registry per release.
  • postinstall download — breaks under npm install --ignore-scripts and behind proxies; rejected. The fetch is an explicit build step, not an install hook.
  • Runtime / first-init download — rejected (signing + offline reasons above).
A runtime-fetch mode is offered only for the non-bundled server/dev consumer (behind an explicit flag, same SHA-256 verification, fails loudly when offline) — never into a signed app bundle.

Lifecycle handshake

The host app implements a deterministic startup sequence; the client SDK provides helpers for each step:
  1. Resolve the platform binary (binaryPath() above) — the binary fetched at build time from R2 and SHA-verified.
  2. Spawn it on an allocated loopback port (agent --port <p> --host 127.0.0.1).
  3. Health — poll GET /health until ready or timeout (fail loudly on timeout — no silent “assume ready”).
  4. Version-checkGET /version returns { apiVersion, agentVersion }; the client refuses a mismatched apiVersion with an actionable error rather than making calls against an incompatible binary.
  5. Ready — typed REST calls; clean shutdown on host exit.

One contract, two implementations

A single OpenAPI spec is the source of truth for the REST surface. The Python and C++ servers both implement it; the JS/TS client is generated/validated against it. Breaking changes bump a SemVer’d apiVersion advertised on /version. This is what makes the host integration implementation-agnostic.

Workstreams

Phase 1 — REST contract & client SDK

Goal: lock the wire contract and ship the JS/TS client against the existing Python server.
  • Author the OpenAPI spec for the email agent REST surface (triage, connectors, health, version). Commit it to the repo as the source of truth.
  • Add GET /version ({ apiVersion, agentVersion }) and confirm GET /health semantics on the existing Python server.
  • Build @amd-gaia/agent-email (TypeScript): typed REST client + lifecycle helpers (spawn, health-poll, version-check, shutdown). Full type defs, no runtime binary yet.
  • Unit + contract tests: client validated against the OpenAPI spec; lifecycle helpers tested against a stub server.
Exit criteria: a Node script can import the client, point it at a manually-started Python server, and run a triage round-trip with types.

Phase 2 — Frozen Python binary

Goal: the Python implementation runs with no interpreter on the user’s machine.
  • Freeze gaia-agent-email (PyInstaller or Nuitka) into a single self-contained executable that boots the REST server.
  • Per-platform builds: win32-x64, darwin-arm64, darwin-x64, linux-x64.
  • Smoke test each frozen binary in a clean environment with no Python: spawn → health → version → triage.
  • Compute and record each binary’s SHA-256 (feeds the Phase 3 pinned manifest); measure per-platform size for R2 storage/transfer.
Exit criteria: all four frozen binaries pass the no-Python smoke test and the lifecycle handshake.

Phase 3 — Fully-automated R2 distribution + thin npm package

Goal: a single version tag publishes everything — binaries to R2 and the thin npm package — with zero manual steps. npm install @amd-gaia/agent-email + one build-time fetch then yields a working, signature-ready agent.
  • Fully-automated, tag-triggered publish workflow (no workflow_dispatch gate): on a namespaced tag, the workflow builds every platform binary → computes SHA-256 → pushes each to R2 via the existing Worker POST /publish (Bearer PUBLISH_TOKENS from a GH Actions secret) → writes the pinned manifest → publishes the npm package. Re-running a published version is a no-op (the Worker’s native version-immutability), not an overwrite.
  • Reuse the existing pipeline rather than forking: extend build_agents.yml’s release-bundle leg (it already produces per-platform binaries + checksums) to call POST /publish instead of stopping at GitHub-artifact upload.
  • Confirm/extend POST /publish to accept multiple per-platform binaries under one <id>/<version> (today it stores gaia-agent.yaml + an artifact; the four OS/arch binaries must coexist in one version without tripping per-version immutability).
  • Build the thin @amd-gaia/agent-email npm package: JS/TS client + fetch CLI + a binaries.lock.json manifest pinning each platform’s R2 key and SHA-256.
  • fetch CLI: resolve platform → download from R2 → verify SHA-256 (fail loudly on mismatch) → write to the host-specified resources dir → chmod +x on POSIX.
  • Document the host integration: run npx @amd-gaia/agent-email fetch as a build step before signing, and (for Electron-style hosts) keep the fetched binary outside the asar (extraResources) so it can be spawned.
  • Atomic release: R2 upload → manifest write → npm publish on one tag; manifest SHAs must match the uploaded R2 objects, and the npm version must reference an R2 version that exists, or the whole release fails.
Exit criteria: pushing a version tag publishes binaries to R2 and the npm package end-to-end with no human step; on three real OSes, npm install + npx @amd-gaia/agent-email fetch retrieves the platform binary, SHA-256 verifies, and the lifecycle handshake succeeds; install works under --ignore-scripts.

Phase 4 — Code signing & notarization

Goal: bundled binaries don’t break a host app’s own signing.
  • macOS: sign + notarize the darwin binaries (unsigned/un-notarized binaries break the host app’s notarization).
  • Windows: Authenticode-sign the win32 binary (unsigned trips SmartScreen on the host’s signed app).
  • Open decision: who holds the signing cert — we sign and publish signed binaries, or we ship reproducible builds the partner signs under their cert. Resolve before Phase 3 publish.
Exit criteria: signed binaries verify on each OS; a test host app bundling them passes notarization/SmartScreen.

Phase 5 — C++ implementation & binary

Goal: a second implementation of the same contract, distributed identically.
  • Stand up the C++ email agent server under hub/agents/cpp/email/, implementing the Phase 1 OpenAPI contract (same routes, same /version apiVersion).
  • Build static / dependency-bundled binaries for the four platforms via the existing C++ build infrastructure (build_cpp.yml, cpp/packaging/).
  • Distribute via the same R2 + thin-package mechanism — upload to the same R2 key layout, pin SHA-256 in the manifest; the host install + fetch path is unchanged, only the binary’s provenance differs.
  • Conformance test: the C++ binary passes the same contract test suite as the Python binary (identical request/response behavior).
Exit criteria: the C++ binary is a drop-in for the frozen Python binary behind the same client, validated by a shared contract test suite.

Phase 6 — Agent UI as first consumer (validation)

Goal: dogfood the package. The GAIA Agent UI (@amd-gaia/agent-ui, Electron) consumes the email agent as the npm-fetched binary sidecar instead of importing the gaia-agent-email Python wheel into its backend. This is the milestone’s real acceptance test — if our own app can install, fetch, spawn, and triage through the package, an external host can too.
  • Add @amd-gaia/agent-email as a dependency of the Electron app; run the build-time fetch in the app’s build; spawn the sidecar via the client’s lifecycle handshake (spawn → health → version → ready).
  • Re-point the UI’s email features (ChatView, EmailPreScanCard, EmailConnectCta, useConnectorsSSE triage calls) at the sidecar instead of the backend’s mounted /v1/email router.
  • Connectors via shared OS keyring (no token handoff): the backend keeps owning the OAuth connect flow and keyring writes (connect CTAs unchanged); the sidecar reads the same OS keyring through its bundled gaia.connectors. This relies on the connectors module’s already-documented “two processes share the keyring” behavior — note the process-local token cache + concurrent-refresh caveat, but no new auth protocol.
  • Remove the built-in path: stop pulling the gaia-agent-email wheel into the UI backend’s dependency/auto-mount chain, so the sidecar is the only email surface — proving the package fully replaces the in-process agent.
  • Keep the Python backend for everything non-email; only email migrates to the sidecar in this phase.
Exit criteria: with the gaia-agent-email wheel removed from the UI backend, the Agent UI completes a triage + pre-scan flow through the spawned @amd-gaia/agent-email sidecar, and an existing Gmail/Outlook connection (granted via the unchanged connect flow) is picked up by the sidecar from the shared keyring.

CI / Release Pipeline

Deployment and distribution are fully automated — a version tag is the only human action. No workflow_dispatch, no manual upload, no manual npm publish.
  • One tag-triggered workflow does the whole release: build matrix → chmod +x + sign + SHA-256 → push each binary to R2 via the Worker POST /publish → write the pinned manifest → npm publish. The current manual build_agent_package.yml (workflow_dispatch → GitHub release) is demoted to a fallback mirror, not the release path.
  • Reuse the existing legs: extend build_agents.yml (C++ binaries + checksums, already tag-aware) and the build-agent-wheel action to push to R2 via POST /publish rather than stopping at GitHub-artifact upload — the workflow comment “R2 … handled by other issues” is exactly this work.
  • Idempotent by construction: the Worker rejects republishing an existing <id>@<version> (native version-immutability), so a re-run or an unchanged-version tag is a safe no-op — no custom overwrite logic.
  • Atomic release: R2 upload → manifest write → npm publish on a single tag. Manifest SHA-256s must match the uploaded R2 objects, and the npm version must reference an R2 version that exists, or the whole release fails — no client-without-binary or stale-SHA states.
  • Auth: the publish step authenticates to the Worker with a Bearer PUBLISH_TOKENS secret stored in GitHub Actions (scoped to the email author); never embedded in the package.
  • Canonical store = R2, mirror = GitHub: R2 (workers/agent-hub/) is the programmatic source of truth; the agent-pkg-email-* GitHub release stays as the manual, no-login mirror.
  • Namespacing: keep release tags namespaced (agent-pkg-email-*) so they never fire the paused PyPI workflows (publish.yml / publish_agents.yml).
  • Distribution registries: the existing @amd-gaia npm scope (already hosts @amd-gaia/agent-ui). The thin model needs one public package, @amd-gaia/agent-email — no per-platform sub-packages.
  • npm auth = trusted publishing (OIDC), no stored token. The package is configured as an npm trusted publisher; the publish job requests id-token: write and runs npm publish --access public with provenance — no NPM_TOKEN secret to store or rotate. This mirrors the repo’s PyPI trusted-publishing pattern (publish_agents.yml). The publish must run from the exact workflow file registered as the trusted publisher on npm.

Versioning & Contract Governance

apiVersion is the host-facing contract version (SemVer), advertised on GET /version and defined as a single constant — SCHEMA_VERSION in hub/agents/python/email/gaia_agent_email/contract.py, aliased as API_VERSION in version.py. Bumping the contract automatically bumps the API version; they cannot drift.

apiVersion bump policy

Clients must reject a server whose apiVersion major is higher than what they support — fail loudly with an actionable error, never fall back to best-effort parsing against an incompatible schema. Clients may accept any minor version equal to or greater than their compiled-against minor. How to apply a bump: update SCHEMA_VERSION in contract.py, regenerate the spec (python -m gaia_agent_email.export_openapi), and run python -m pytest hub/agents/python/email/tests/test_rest_contract.py tests/test_email_openapi_conformance.py to confirm no drift.
  • Package version tracks the agent build; the npm package, the R2 binary version (agents/email/<version>/…), and the pinned binaries.lock.json manifest always publish in lockstep — the manifest references the matching R2 version, never a floating one.
  • The OpenAPI spec (openapi.email.json) is the single source of truth — client, Python server, and C++ server are all validated against it in CI. A change to the contract that isn’t reflected in the committed artifact fails CI (test_rest_contract.py::test_committed_openapi_artifact_is_up_to_date), and a running server that doesn’t conform to the spec fails the conformance suite (tests/test_email_openapi_conformance.py).

Testing Strategy


Risks

  • Runtime download mistaken for build-time fetch — if a host wires the fetch at runtime/first-init instead of at build time, the binary lands outside its code signature (Gatekeeper/SmartScreen blocks) and breaks offline first-run. Mitigation: make fetch an explicit, documented build step; the integration guide leads with “fetch before sign”; the runtime-fetch flag is opt-in and documented as server/dev-only.
  • R2 availability / integrity at build time — a build can’t fetch if R2 is down or an object is corrupt. Mitigation: immutable versioned keys, SHA-256 pinned in the package (fail loudly on mismatch), and the GitHub release as a manual fallback mirror.
  • Manifest ↔ R2 drift — a published SHA that doesn’t match the uploaded object bricks every consumer. Mitigation: the atomic release gates publish on manifest SHAs matching R2 objects.
  • Signing ownership unresolved — blocks the first signed release. Mitigation: resolve the cert-ownership decision (us vs. partner) before Phase 4.
  • Contract drift between Python and C++ — two implementations of one spec can diverge. Mitigation: shared OpenAPI source of truth + a single conformance suite both must pass in CI.
  • Electron asar packaging — binaries inside an asar can’t be spawned. Mitigation: document the asarUnpack requirement prominently in the integration guide.
  • Executable-bit loss — a binary published without the exec bit fails to spawn on POSIX. Mitigation: CI chmod +x + a post-pack assertion.

Deferred

  • In-process Node native addon (N-API) for the C++ build — lowest latency, in-memory data sharing, but couples to the host’s exact Node/Electron ABI and makes a crash fatal to the host. Revisit as a v2 fast-path once the sidecar path is proven.
  • PyPI publish — remains paused under #1179; the npm path does not depend on it.
  • Additional agents — the npm mechanism is designed to generalize (swap the agent id), but onboarding other agents is out of scope for this milestone.

Exit Criteria (Milestone)

  1. npm install @amd-gaia/agent-email + npx @amd-gaia/agent-email fetch on each of the four target platforms yields a working agent with no Python/C++ prerequisites, pulling only that platform’s binary from R2 and SHA-256-verifying it.
  2. The JS/TS client runs the full lifecycle handshake and a triage round-trip against the frozen Python binary.
  3. Binaries are signed/notarized and verified inside a test host app that fetched at build time; the agent then runs offline.
  4. The C++ binary passes the same contract suite as the Python binary and is a drop-in behind the same client.
  5. The thin npm package installs under --ignore-scripts; fetch fails loudly on a SHA-256 mismatch or when offline.
  6. The OpenAPI contract is published and enforced in CI across client + both servers.
  7. Deployment is fully automated: pushing a version tag publishes binaries to R2 and the npm package end-to-end with no manual step, and re-running the release is a safe no-op.
  8. Dogfooded: the GAIA Agent UI runs email triage through the @amd-gaia/agent-email sidecar with the gaia-agent-email Python wheel removed from its backend, picking up connector tokens from the shared OS keyring.