Skip to main content

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_TOKEN on /api/internal/….
  • Transparent domain intercept — keep an explicit {NAME}_BASE_URL like 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:

RoleName
nono credential idNAME
Supervisor env (real token)NAME_ACCESS_TOKEN
Phantom env the child seesNAME_API_KEY
Proxy base URL the child fetchesNAME_BASE_URL
Vault keysNAME_oauth_client_id, _client_secret, _redirect_uri, _scopes
Django modelNameCredential
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_rules use 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_secret in VaultUnavailable → 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): OneToOne to the user, holding no durable secret — the refresh token goes to the vault through chat/oauth_grants.py (see Where OAuth secrets and tokens live). The row keeps the connected marker, access_token as an EncryptedTextField cache + 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_auth except GET /callback with auth=None.
  • Reuse chat/oauth_connect.py: TimestampSigner state (uid, next, origin), unique salt per vendor, safe_next / safe_origin (CSRF_TRUSTED_ORIGINS + CORS_ALLOWED_ORIGINS), frontend_redirect with {name}_connected / {name}_error.
  • status.configured is 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 the None variant.
  • Wire api.add_router('/integrations/NAME/', router) in config/urls.py. No pi_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_allow
  • endpoint_rules — default-deny; only the paths from step 1
  • enabled_if — credential row exists
  • token_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_KEY are 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 --extension in pi_runtime.py and k8s_runtime.py _EXTENSIONS. A folder of tools can use one index.ts that registers several (HubSpot). skill_tools.py still needs registerTool in the leaf files so Tools page rows exist.
  • No new backend tool endpoints. No PI_USER_TOKEN on these fetches.

7. Tests (minimum)​

In chat/tests.py, mirror the Gmail / HubSpot cases:

  • Profile with a credential contains the NAME route and env://NAME_ACCESS_TOKEN, and the dumped JSON never contains the real access or refresh token.
  • allow_vars has NAME_BASE_URL / NAME_API_KEY, not NAME_ACCESS_TOKEN.
  • compile_profile(user, enabled=set()) (or enabled without NAME) omits the route even if the credential row exists.
  • OAuth callback succeeds on an anonymous client (no session cookie) using the signed state from authorize.
  • Bad state → 302 + {name}_error=bad_state, not 401.
  • Allowlisted SPA origin (127.0.0.1 vs localhost) 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.md and docs/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_router that calls the vendor on every tool invocation.
  • Put NAME_ACCESS_TOKEN in allow_vars or write the token value into session.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_URL into 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:

ProviderSupervisor env (real)Child sees
anthropicANTHROPIC_API_KEYnono's phantom via the route's env_var, injected as x-api-key
openaiOPENAI_API_KEYnono's phantom via the route's env_var, injected as Bearer
bedrockAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGIONAWS_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)​

  1. Paths + header-auth whoami decided; scopes listed.
  2. Vault keys + GET/PUT /vault/{name}-oauth + settings redirect URI.
  3. NameCredential + Connect API via oauth_connect.py (callback auth=None, configured).
  4. Frontend Connect + superuser vault card; origin on authorize.
  5. One OAuthProxyRoute in oauth_proxy.py; allow_vars phantom + base URL only.
  6. Extension(s) on NAME_BASE_URL; --extension registered; no BFF.
  7. Tests: route pointer, no secrets in session.json, callback without cookie, omitted when disabled.
  8. 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:

  1. Calls tokens_for_session(user) (chat/oauth_proxy.py) — HubSpot's access_token_for_user refreshes if the cached token is within 60s of expiry, otherwise reuses it. Missing grant or failed refresh → no HubSpot token.
  2. Writes the per-session nono overlay with the hubspot route only when that token exists (enabled={'hubspot', ...}). session.json contains env://HUBSPOT_ACCESS_TOKEN, never the value.
  3. Starts the warm container as nono … -- pi …, with -e HUBSPOT_ACCESS_TOKEN=... set only when present (supervisor-only; not in allow_vars). The HubSpot tools themselves load lazily, only when the parent delegates to the hubspot pi-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​

ToolKindWhen it existsWhat it talks to
hubspot_whoamipi extensionalways loaded; works only if the HubSpot proxy was attachedGET {HUBSPOT_BASE_URL}/account-info/v3/details (header auth; falls back to /integrations/v1/me)
hubspot_listpi extensionsameCRM v3 list or POST …/search for contacts, leads, deals, companies
hubspot_createpi extensionsame; ask before writePOST /crm/v3/objects/{type}
hubspot_updatepi extensionsame; ask before writePATCH /crm/v3/objects/{type}/{id}
hubspot_ownerspi extensionsameGET /crm/v3/owners, then POST /crm/v3/objects/{emails,meetings,calls}/search (read-only) to rank users by activity
hubspot_create_taskpi extensionsame; ask before writePOST /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​

  1. Add any required scope(s) to the vault hubspot_oauth_scopes value (users reconnect to grant).
  2. Add endpoint_rules on the existing hubspot route if the path is new.
  3. Add a .ts file next to the others that calls {HUBSPOT_BASE_URL} and wire it in src/pi-agent/core-agents/hubspot/tools/index.ts. No new Django tool endpoint.