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:
- Calls
tokens_for_session(user)(chat/oauth_proxy.py) — Google'saccess_token_for_userrefreshes if the cached access token is within 60s of expiry, otherwise reuses it. - 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 withenv://GMAIL_ACCESS_TOKENand no env var makes nono fail at proxy start. - Starts (or reuses) the warm process (Warm pi Sessions) as
nono … -- pi ….send_emailand the Sheets tools load from/opt/pi-core-agents/google/index.ts.rasterize_chartloads 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
| Tool | Kind | When it works | What it talks to |
|---|---|---|---|
fetch_sheet | google core agent | Sheets proxy attached at session start | SHEETS_BASE_URL + phantom SHEETS_API_KEY. nono injects the real token |
search_spreadsheets | google core agent | Drive proxy attached at session start | GSHEETS_BASE_URL + phantom GSHEETS_API_KEY |
rasterize_chart | pi extension | always loaded | Writes {uuid}.png under /workspace/.pi-attachments/ |
send_email | google core agent | Gmail proxy attached at session start | POST {GMAIL_BASE_URL}/gmail/v1/users/me/messages/send |
render_chart | pi extension | always loaded | Chat 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:
- Refuses the filename unless it matches a UUID
.pngpattern, and reads it from/workspace/.pi-attachments/. - Builds an RFC 2822 message (plain text, or multipart HTML with the PNG inline as
cid:). - Base64url-encodes it as Gmail's
rawfield. fetches the send endpoint withAuthorization: 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.