Skip to main content

Google token flow

One connected Google account covers Gmail and Sheets. Both are tools on the google core agent (src/pi-agent/core-agents/google/). They talk to Google through nono's credential-injection proxy, so the sandboxed model never holds the user's real access token. There is no Google MCP server.

This page is the end-to-end sequence: login, connect Google, prompt, tool calls, Gmail.

Setup and failure modes are in Google.

4. Open chat and send a prompt​

POST /api/chat/messages streams NDJSON back to the browser (Streaming Chat Responses). Django still knows who the user is from the session. The sandboxed pi process does not get that cookie. Instead, session start in chat/pi_runtime.py (Docker) or chat/k8s_runtime.py:

  1. Calls tokens_for_session(user) (chat/oauth_proxy.py) — Google's access_token_for_user refreshes if the cached access token is within 60s of expiry, otherwise reuses it.
  2. Writes a per-session nono overlay (write_session_files). The Gmail and Sheets proxy routes are added only when that access token exists. A route with env://GMAIL_ACCESS_TOKEN and no env var makes nono fail at proxy start.
  3. Starts (or reuses) the warm process (Warm pi Sessions) as nono … -- pi …. send_email and the Sheets tools load from /opt/pi-core-agents/google/index.ts. rasterize_chart loads from /opt/pi-extensions/rasterize_chart.ts.

send_email, search_spreadsheets, and fetch_sheet are on the tool list whenever the google agent is loaded. If the user never connected Google, the proxy env is missing and the tool errors with a message to connect a Google account and start a new Session.

Connecting Google after a warm session is already running does not attach the proxy. Start a new Session so session start runs again.

5. What the model can call​

ToolKindWhen it worksWhat it talks to
fetch_sheetgoogle core agentSheets proxy attached at session startSHEETS_BASE_URL + phantom SHEETS_API_KEY. nono injects the real token
search_spreadsheetsgoogle core agentDrive proxy attached at session startGSHEETS_BASE_URL + phantom GSHEETS_API_KEY
rasterize_chartpi extensionalways loadedWrites {uuid}.png under /workspace/.pi-attachments/
send_emailgoogle core agentGmail proxy attached at session startPOST {GMAIL_BASE_URL}/gmail/v1/users/me/messages/send
render_chartpi extensionalways loadedChat UI only. It does not produce a file for email

The model drives the chain itself. There is no Django "send this chart" endpoint. A prompt like "chart last quarter and email it to alex@" becomes tool calls the agent chooses, streamed as tool_start / tool_end in the chat UI.

6. Chart-then-email​

rasterize_chart (src/pi-agent/extensions/rasterize_chart.ts) takes the same chart arguments as render_chart, writes a PNG into the user's workspace mount, and returns a short filename (chart_attachment). The model must pass that filename unchanged into send_email. It never carries image bytes through tokens.

send_email (src/pi-agent/core-agents/google/send_email.ts) then:

  1. Refuses the filename unless it matches a UUID .png pattern, and reads it from /workspace/.pi-attachments/.
  2. Builds an RFC 2822 message (plain text, or multipart HTML with the PNG inline as cid:).
  3. Base64url-encodes it as Gmail's raw field.
  4. fetches the send endpoint with Authorization: Bearer ${GMAIL_API_KEY}.

GMAIL_API_KEY is a phantom token nono minted for this session. GMAIL_BASE_URL is the localhost reverse proxy nono started (not gmail.googleapis.com). The Landlocked child is allowed those two env vars (allow_vars). It is not allowed GMAIL_ACCESS_TOKEN.

7. How the real token is injected​

At container start, Django puts the real Google access token in the supervisor environment as GMAIL_ACCESS_TOKEN (docker run -e / k8s env). The compiled session profile points at it with credential_key: "env://GMAIL_ACCESS_TOKEN" and never copies the value into session.json (that file is world-readable and bind-mounted into the box).

On POST /gmail/v1/users/me/messages/send to the proxy, nono:

  • strips the phantom GMAIL_API_KEY,
  • injects Authorization: Bearer <real access token>,
  • forwards to https://gmail.googleapis.com.

endpoint_rules allow only that one method and path. The OAuth grant is already gmail.send (send-only — no read or list). Together that is defense in depth if the child is compromised: it can ask the proxy to send mail as this user, and nothing else on Gmail.

The access token is loaded once per warm session, with Google's usual ~1 hour lifetime. A session older than that gets 401 from Gmail until the container is recreated. Details of the proxy and wsl2_proxy_policy are in Agent Sandboxing.

Sequence​

Browser (session cookie)
→ POST /api/chat/messages
→ Django: who is this user? refresh Google access token if they connected
→ warm pi container (nono supervisor + Landlocked pi)
→ model may call rasterize_chart → /workspace/.pi-attachments/<uuid>.png
→ model may call send_email
→ phantom Bearer + GMAIL_BASE_URL
→ nono proxy injects real token
→ Gmail API messages.send
→ NDJSON tool_start / tool_end / text_delta / done
→ chat UI

Sheets, in the same turn, leave the sandbox through those proxy routes: fetch_sheet uses SHEETS_BASE_URL and a phantom SHEETS_API_KEY, and search_spreadsheets uses GSHEETS_BASE_URL and GSHEETS_API_KEY. Same pattern as Gmail. They do not use PI_USER_TOKEN.