Vendor APIs
Vendor HTTP APIs the chat agent calls (Gmail send, HubSpot CRM, and the next one) share one
pattern. Django owns browser OAuth + the vault + minting an access token at session start.
nono is a reverse proxy that injects the real token and
allowlists vendor paths. Pi extensions call {VENDOR}_BASE_URL with a phantom
{VENDOR}_API_KEY.
A new vendor should mean: vault OAuth app + credential model + one proxy-route spec + TypeScript
tools. Not a new Django BFF / pi_router. Copy Gmail (send_email.ts +
chat/google_oauth_api.py) or HubSpot (src/pi-agent/core-agents/hubspot/tools/ +
chat/hubspot_oauth_api.py) — those are the two worked examples.
The vault those app credentials live in can be Fernet + Postgres (VAULT_OAUTH_BACKEND=fernet,
chat/vault.py) or HashiCorp KV (VAULT_OAUTH_BACKEND=hashicorp). The org-cluster backend that
treats every tool the same (Google, HubSpot, Salesforce, SAP, …) is
Proposal: HashiCorp Vault integration secrets.
Just-in-time connect (chat → policy → trusted UI → Vault → recycle sandbox) is the primary path;
Tools-page Connect cards remain as a manual path.
Worked product flows: Send Email from Chat, HubSpot Tools.
When this pattern applies
Use it when:
- The user connects their own OAuth account (not a shared service key).
- The agent should call a small, known HTTP API on that vendor (send mail, list CRM objects).
- The sandbox must never hold the real access token.
Do not use it for:
- Calls back into this backend — Memory, reports, and the Enterprise Database use
PI_USER_TOKENon/api/internal/…. - Transparent domain intercept — keep an explicit
{NAME}_BASE_URLlike Gmail. (The LLM provider is the one exception; see LLM credentials.) - Mid-session token refresh — tokens are snapshotted at session start. If the vendor's
access token lasts 10 minutes and you need a 4-hour chat, this pattern is the wrong fit until
nono can re-read
env://(not assumed in 0.75).
Trust split
Browser Connect → Django OAuth callback → VendorCredential row
↓ session start
SUPERVISOR env NAME_ACCESS_TOKEN
session.json env:// pointer (never the value)
↓
Landlocked pi + extensions → phantom NAME_API_KEY + NAME_BASE_URL → nono proxy
↓ Authorization: Bearer <real>
vendor API host
PI_USER_TOKEN stays in the sandbox for Django's internal APIs. Vendor tools (Gmail, Sheets,
HubSpot) do not use it. They use the phantom key and {VENDOR}_BASE_URL.
src/pi-agent/nono/perspective.json allow_vars lists the phantom key and base URL for every
known vendor, even if that route is off this session. It never lists *_ACCESS_TOKEN. (LLM
routes differ; see LLM credentials.)
Naming
Pick a short NAME (e.g. gmail, hubspot, salesforce). Then:
| Role | Name |
|---|---|
| nono credential id | NAME |
| Supervisor env (real token) | NAME_ACCESS_TOKEN |
| Phantom env the child sees | NAME_API_KEY |
| Proxy base URL the child fetches | NAME_BASE_URL |
| Vault keys | NAME_oauth_client_id, _client_secret, _redirect_uri, _scopes |
| Django model | NameCredential |
| Connect API | /api/integrations/NAME/ |
pi_runtime / k8s_runtime already loop chat/oauth_proxy.py's registry — you do not add
another -e NAME_ACCESS_TOKEN=... by hand.
Steps
1. Decide the HTTP surface (before any code)
Write down:
- Authorize URL, token URL, refresh grant.
- Default scopes (minimum for the first tools).
- Exact methods + paths the extension will call. nono 0.75
endpoint_rulesuse glob segments:*is one path component,**is the rest. Enumerate object types (/crm/v3/objects/contacts) rather than/crm/v3/objects/**or the whole vendor host. - A header-auth identity endpoint if you need whoami. Never
GET /oauth/.../tokens/{access_token}— the child only has a phantom key, and that puts a token in the URL.
If you cannot list the paths, you are not ready to add the route.
2. Vault OAuth app (Django, superuser-only)
The shared app (client id/secret) is not a per-user grant. Mirror
chat/vault_api.py's Google / HubSpot GET/PUT /vault/{name}-oauth:
- Superuser only.
- Client secret is write-only (
client_secret_set, never returned). - Blank stored redirect/scopes delete the vault row so
vault.get_secret(..., default=settings.NAME_OAUTH_REDIRECT_URI)applies. - Wrap
set_secretinVaultUnavailable→ HTTP 503.
Add NAME_OAUTH_REDIRECT_URI in config/settings.py (usually
{BACKEND_BASE_URL}/api/integrations/NAME/callback). Register that exact URI on the vendor's
OAuth app. Schemas: VaultNameOAuthConfigIn / Out next to the HubSpot ones in
chat/schemas.py.
3. Per-user credential + Connect API
Copy chat/hubspot_oauth_api.py (newer) more closely than a BFF:
- Model (
chat/models.py):OneToOneto the user, holding no durable secret — the refresh token goes to the vault throughchat/oauth_grants.py(see Where OAuth secrets and tokens live). The row keeps the connected marker,access_tokenas anEncryptedTextFieldcache +access_token_expires_at, and display fields (email, portal id) filled at connect time while Django still has the real token.makemigrations/ migrate. - Store the grant before creating the row, and abort the connect if the store refuses: a row with no grant behind it reports a working connection that cannot mint a token.
- Router
auth=django_authexceptGET /callbackwithauth=None. - Reuse
chat/oauth_connect.py:TimestampSignerstate (uid,next,origin), uniquesaltper vendor,safe_next/safe_origin(CSRF_TRUSTED_ORIGINS+CORS_ALLOWED_ORIGINS),frontend_redirectwith{name}_connected/{name}_error. status.configuredis true only when vault client id and secret are both set.- Redirect URI / scopes: vault value,
.strip()/ collapsed whitespace, empty → settings /_DEFAULT_SCOPES. access_token_for_user(user) -> str | None: missing grant or failed refresh →None(so session start can omit the route). Keep a raising helper if you want; the registry must use theNonevariant.- Wire
api.add_router('/integrations/NAME/', router)inconfig/urls.py. Nopi_router.
Frontend: a Connect card (copy HubspotConnectionPanel.jsx / GoogleConnectionPanel.jsx) and
lib/api.js helpers. authorize must pass origin: window.location.origin. Disable Connect
when configured === false. Show {name}_error codes. Superuser vault card on the same Tools
editor page (ToolEditorPage.jsx). Extension tools are discovered by scanning
src/pi-agent/extensions/**/*.ts (chat/skill_tools.py) — put pi.registerTool in those
files so the row appears.
4. Registry entry
Add one OAuthProxyRoute in chat/oauth_proxy.py:
name,upstream(https origin only),token_env,phantom_env,base_url_allowendpoint_rules— default-deny; only the paths from step 1enabled_if— credential row existstoken_for—access_token_for_user(lazy-import the OAuth module to avoid cycles)
Session start (pi_runtime.py / k8s_runtime.py) already does:
tokens = tokens_for_session(user)
write_session_files(..., enabled=set(tokens))
inject supervisor env from supervisor_token_env(tokens)
Do not attach a route whose token was not injected — env://NAME_ACCESS_TOKEN with a missing
var fails nono at proxy start.
5. Static allow_vars
In src/pi-agent/nono/perspective.json add NAME_BASE_URL and NAME_API_KEY. Never
NAME_ACCESS_TOKEN. These stay listed even when the user has not connected.
6. Pi extension(s)
fetch(\${process.env.NAME_BASE_URL}/...`)withAuthorization: Bearer ${NAME_API_KEY}`.- If
NAME_BASE_URL/NAME_API_KEYare missing, tell the user to connect on Tools and start a new Session (the proxy is not retrofitted onto a warm session). - On HTTP 401, tell them the session token expired — start a new conversation (Gmail ~1 h, HubSpot often ~30 min).
- Keep object-type allowlists and “ask before write” prompt text in TypeScript. That policy does not need Django.
- Register
--extensioninpi_runtime.pyandk8s_runtime.py_EXTENSIONS. A folder of tools can use oneindex.tsthat registers several (HubSpot).skill_tools.pystill needsregisterToolin the leaf files so Tools page rows exist. - No new backend tool endpoints. No
PI_USER_TOKENon these fetches.
7. Tests (minimum)
In chat/tests.py, mirror the Gmail / HubSpot cases:
- Profile with a credential contains the
NAMEroute andenv://NAME_ACCESS_TOKEN, and the dumped JSON never contains the real access or refresh token. allow_varshasNAME_BASE_URL/NAME_API_KEY, notNAME_ACCESS_TOKEN.compile_profile(user, enabled=set())(orenabledwithoutNAME) omits the route even if the credential row exists.- OAuth
callbacksucceeds on an anonymous client (no session cookie) using the signedstatefromauthorize. - Bad
state→ 302 +{name}_error=bad_state, not 401. - Allowlisted SPA origin (
127.0.0.1vslocalhost) is honored; an untrusted origin is not.
8. Docs + operator notes
- A short product page under
docs/src/tools/(connect → session inject → phantom fetch). - Link it from
docs/src/tools/README.mdanddocs/src/SUMMARY.md. - Redirect URI for local
./dev.sh:http://localhost:8000/api/integrations/NAME/callback. - Connecting after a warm session is running does nothing until a new Session.
Do not
- Add a Django
pi_routerthat calls the vendor on every tool invocation. - Put
NAME_ACCESS_TOKENinallow_varsor write the token value intosession.json. - Put the real token in a URL path or query string.
- Allowlist the entire vendor host because path matching felt inconvenient.
- Inject
HARNESI_BACKEND_URLinto the sandbox for this vendor. - Refresh the access token from inside the Landlocked child, or send the child
MCP_INTERNAL_SECRET.
LLM credentials
The sandbox's own LLM key goes through nono too (chat/llm_proxy.py), but through TLS
interception rather than a *_BASE_URL. pi calls the real provider host through nono's
HTTPS_PROXY, and nono injects the key or, for Bedrock, SigV4-signs the request. No pi override
is needed. nono 0.78 answers 501 to an aws_auth route called through its reverse proxy, so
Bedrock can only work this way.
The supervisor holds the real credentials under their standard names, and none of those
names are in allow_vars. The child sees a stand-in under the same name:
| Provider | Supervisor env (real) | Child sees |
|---|---|---|
| anthropic | ANTHROPIC_API_KEY | nono's phantom via the route's env_var, injected as x-api-key |
| openai | OPENAI_API_KEY | nono's phantom via the route's env_var, injected as Bearer |
| bedrock | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION | AWS_REGION, plus set_vars placeholders AWS_ACCESS_KEY_ID=nono-phantom, AWS_SECRET_ACCESS_KEY=nono-phantom, AWS_BEDROCK_SKIP_AUTH=1 |
The stand-in overrides an inherited value, so even if a real name were added to allow_vars by
mistake, the child would still see the stand-in (verified on nono 0.78). The Anthropic/OpenAI
phantom is nono's proxy token. Code that uses nono's ANTHROPIC_BASE_URL / OPENAI_BASE_URL,
which nono always sets, therefore works too, for example an SDK in a workspace script. With a
set_vars placeholder instead, nono would reject those calls. The route is attached whenever the
user's provider is configured, so every session runs nono in proxy mode.
Unlike a reverse-proxy route, interception does not check the phantom: any request from the box
to that provider's host gets the real key. That matches what the box could do before, and the
key itself still never reaches the child. Backend-side LLM calls (DSPy / litellm lm_kwargs)
don't go through nono.
Token lifetime (honest limitation)
Proxy tokens are snapshotted at session start. Warm sessions older than the vendor's access
token 401 until recreated. Document that on the tool page. Do not put Django back on the
hot path to refresh. A later supervisor re-read of env:// is not assumed in nono 0.75.
Checklist (copy)
- Paths + header-auth whoami decided; scopes listed.
- Vault keys +
GET/PUT /vault/{name}-oauth+ settings redirect URI. NameCredential+ Connect API viaoauth_connect.py(callback auth=None,configured).- Frontend Connect + superuser vault card;
originon authorize. - One
OAuthProxyRouteinoauth_proxy.py;allow_varsphantom + base URL only. - Extension(s) on
NAME_BASE_URL;--extensionregistered; no BFF. - Tests: route pointer, no secrets in
session.json, callback without cookie, omitted when disabled. - Docs + “new Session after connect” + token TTL.
Worked example: HubSpot
Session start injects the proxy
chat/pi_runtime.py (Docker) or chat/k8s_runtime.py:
- Calls
tokens_for_session(user)(chat/oauth_proxy.py) — HubSpot'saccess_token_for_userrefreshes if the cached token is within 60s of expiry, otherwise reuses it. Missing grant or failed refresh → no HubSpot token. - Writes the per-session nono overlay with the
hubspotroute only when that token exists (enabled={'hubspot', ...}).session.jsoncontainsenv://HUBSPOT_ACCESS_TOKEN, never the value. - Starts the warm container as
nono … -- pi …, with-e HUBSPOT_ACCESS_TOKEN=...set only when present (supervisor-only; not inallow_vars). The HubSpot tools themselves load lazily, only when the parent delegates to thehubspotpi-subagents Child Pi (src/pi-agent/core-agents/hubspot/hubspot.md,subagentOnlyExtensions: /opt/pi-core-agents/hubspot/tools/index.ts) — not always registered on the parent.
HubSpot access tokens often last ~30 minutes. Warm sessions older than that 401 until recreated. Django is not on the hot path to refresh.
What the model can call
| Tool | Kind | When it exists | What it talks to |
|---|---|---|---|
hubspot_whoami | pi extension | always loaded; works only if the HubSpot proxy was attached | GET {HUBSPOT_BASE_URL}/account-info/v3/details (header auth; falls back to /integrations/v1/me) |
hubspot_list | pi extension | same | CRM v3 list or POST …/search for contacts, leads, deals, companies |
hubspot_create | pi extension | same; ask before write | POST /crm/v3/objects/{type} |
hubspot_update | pi extension | same; ask before write | PATCH /crm/v3/objects/{type}/{id} |
hubspot_owners | pi extension | same | GET /crm/v3/owners, then POST /crm/v3/objects/{emails,meetings,calls}/search (read-only) to rank users by activity |
hubspot_create_task | pi extension | same; ask before write | POST /crm/v3/objects/tasks |
whoami must not call GET /oauth/v1/access-tokens/{token} — that puts a token in the URL,
and the child only has a phantom key. Account APIs that use Authorization only are the
whoami path.
endpoint_rules enumerate those CRM object types (plus the two whoami paths, GET /crm/v3/owners,
read-only POST /crm/v3/objects/{emails,meetings,calls}/search for hubspot_owners, and
POST /crm/v3/objects/tasks for hubspot_create_task). They do not allowlist all of
api.hubapi.com. Object-type allowlisting and the "ask before write" prompt text live in the
TypeScript tools.
hubspot_owners needs the crm.objects.owners.read scope to list users.
The activity counts it ranks by (emails/meetings/calls) are usually covered by
crm.objects.contacts.read; portals that gate an engagement type behind its own scope must add it via the vault hubspot_oauth_scopes (and users reconnect). The tool reports an unreadable activity type instead of failing.
For rank_by="emails", the email_direction argument picks which emails to count via an
hs_email_direction filter on the search — sent (default, outgoing EMAIL the user sent),
received (inbound INCOMING_EMAIL replies logged), forwarded (FORWARDED_EMAIL), or all (no
direction filter). So rank_by="emails" answers "who sent the most emails" by default; pass
email_direction="all" for total email activity. It is ignored for meetings/calls, and counts 1:1
logged sales emails — not marketing/sequence sends (a separate Marketing Email API not wired here).
PI_USER_TOKEN is still in the sandbox for Kimball / GenBI / Sheets MCP. HubSpot tools do not
use it.
Adding another HubSpot verb
- Add any required scope(s) to the vault
hubspot_oauth_scopesvalue (users reconnect to grant). - Add
endpoint_ruleson the existinghubspotroute if the path is new. - Add a
.tsfile next to the others that calls{HUBSPOT_BASE_URL}and wire it insrc/pi-agent/core-agents/hubspot/tools/index.ts. No new Django tool endpoint.