Per-agent OAuth (Path B)
Agents OAuth into third-party providers (Gmail, Calendar, Slack, GitHub, Notion) with per-agent credentials. The agent is the principal; the user consents on the agent's behalf.
This is the canonical aeqi pattern. Founder pick, 2026-05-06: "we should just use the app to connect the hello@aeqi.ai email per oauth to the agent — that's the right path logically."
Why per-agent (not per-user)
Three viable patterns:
| Pattern | Description | Verdict |
|---|---|---|
| A. OAuth user-flow | Tokens land on a user, then bound to an agent at runtime. | Hack. The agent isn't the principal; revocation gets weird. |
| B. Per-agent OAuth (canonical) | Each agent has its own OAuth identity. "Connect Google" on agent settings → user consents on behalf of the agent → tokens scoped (scope_kind=Agent, scope_id=<agent_id>, provider="google"). |
Clean. Same flow customer orgs use. |
| C. Service Account + DWD | One service account impersonates any *@aeqi.ai mailbox. |
Admin-only setup; not viable for customer orgs. Can coexist for internal automation. |
Path B is what ships in product.
The flow
- User opens an agent's Integrations tab.
- User clicks Connect Google (or Slack, Notion, GitHub — provider-agnostic).
- Frontend calls
GET /api/agents/{agent_id}/integrations/google/start. Platform mints an HMAC-signed state token (bindsagent_id, nonce, expiry); returns{authorize_url}. - Browser redirects to provider's OAuth screen with
agent_id-bound state. - User consents.
- Provider redirects to
GET /api/integrations/google/callback?code=...&state=.... Public route, no auth — provider can't carry a JWT. - Platform decodes state, verifies HMAC + expiry, extracts
agent_id. Exchanges code for tokens. POSTs to runtime'scredentials_ingestIPC verb. - Runtime writes encrypted tokens scoped
(scope_kind=Agent, scope_id=<agent_id>, provider=<x>, name="oauth_token"). - Browser redirects to agent settings with
?connected=google. UI refetches status; renders the success state.
From this point on, tools that consume credentials by that scope key "just work" without further wiring.
Component layout
| Surface | What |
|---|---|
| aeqi (runtime) | credentials_ingest IPC verb. HTTP surface POST /api/integrations/credentials/ingest. Writes to per-tenant credentials substrate. |
| aeqi-platform | start + callback + status routes per provider. State token mint/verify. Code-for-tokens exchange. |
| aeqi/apps/ui | "Connect Google" button on agent's Integrations tab. Redirects via window.location.href = authorize_url. |
The credentials substrate is provider-agnostic — one ingest verb covers them all.
State token
HMAC-SHA256 over agent_id || "|" || nonce || "|" || expires_at, base64url-encoded. Default expiry 600s. The signing key is AEQI_OAUTH_STATE_SECRET. The callback verifies HMAC + expiration before persisting tokens.
This prevents:
- CSRF — callback can't be forged without the secret.
- Replay — nonce + expiration.
- Cross-agent confusion — state binds
agent_id; callback can only persist tokens for that agent.
Required env vars
/etc/aeqi/secrets.env on the platform host:
GOOGLE_CLIENT_ID=... # GCP OAuth client (Web application type)
GOOGLE_CLIENT_SECRET=...
AEQI_OAUTH_STATE_SECRET=... # openssl rand -hex 32
When unset, routes return 503 {error: "google_oauth_not_configured", setup_required: true} — graceful degradation. Restart aeqi-platform.service after editing.
GCP redirect URI must match exactly: https://app.aeqi.ai/api/integrations/google/callback (no trailing slash).
Existing tools that consume credentials
crates/aeqi-pack-google-workspace/ consumes Google credentials by the scope key:
gmail.send,gmail.search,gmail.readcalendar.find_busy,calendar.propose_slots,calendar.create_eventmeet.create
Each tool calls GoogleApiClient::for_agent(agent_id). The client looks up (scope=Agent, agent_id, provider=google, name=oauth_token), refreshes on 401, and proxies the API call.
Generalizing to other providers
The pattern (per-agent OAuth → HMAC state → callback exchange → IPC ingest → credentials substrate) generalizes. To add Slack, Notion, GitHub for an agent:
- Add provider-specific scopes to the
startroute. - Add provider name to the callback exchange (
provider="slack"). - The
credentials_ingestIPC verb is provider-agnostic — same path. - Add a "Connect Slack" button on the same Integrations tab.
Don't fork the credentials substrate per-provider. The discriminator is the provider field.
What about Service Accounts?
Service accounts work for internal automation — e.g., a single aeqi-noreply@*.iam.gserviceaccount.com that sends transactional email. They don't work for customer orgs because customers can't grant your service account domain-wide delegation in their Google Workspace.
Use Path B for anything customer-facing. Service accounts can coexist for internal-only paths.
Related
- Multi-scope integrations — Entity > Role > Agent precedence when scopes overlap.
- Agents — agents own credentials.