Connections as browsers
A connection is how a skill reaches a service: a REST API, a cookie- authed dashboard, a local SQLite file. This page is about the architectural shape of connections — what makes them isolated, why that isolation is by construction rather than by policy, and how the ambient cookie-jar mechanism works without the skill author ever touching cookies.
For the surface (what a skill author writes), see Connections & Auth. For the resolution algorithm (how the engine picks the freshest cookie when multiple sources offer candidates), see Auth resolution. This page is the middle layer: what a connection is.
The mental model
Section titled “The mental model”Each connection is a sandboxed identity profile. The profile decides three things:
- Mode — browser, fetch, or api. Picks the HTTP personality (header bundle, cookie behavior).
- Identity — cookies, API key, OAuth tokens, or none. What makes a request “authenticated.”
- Scope — the
(domain, identifier)key under which this connection’s credentials live in the store.
Tools bind to a connection with @connection("portal"). Inside the
tool body, plain http.get("/x") does the right thing because the
connection owns the mode and the auth. Skill code never threads
cookies, tokens, or browser headers.
Two tools on different connections — even in the same skill — are separate browser profiles. Two tools on the same connection share state. Simple rule, enforced structurally.
Credentials key on (domain, identifier), not (skill, connection_name)
Section titled “Credentials key on (domain, identifier), not (skill, connection_name)”The credential store is keyed on who the identity is, not which skill is asking for it.
credentials (SQLite row): domain ← derived from the connection's base_url (or explicit domain=) identifier ← account key within that domain (email, userId, customerId) item_type ← cookies | api_key | oauth value ← encrypted blob (AES-256-GCM)Domain derivation (crates/auth/src/domain.rs):
- Explicit
domain=on theconnection(...)call wins. - Otherwise, the registrable domain of
base_url—host_from_urlcollapsesapi.exa.ai,dashboard.exa.ai, andauth.exa.aiinto one namespace:exa.ai. - Fallback: the skill id.
Why not key on (skill, connection_name)?
Section titled “Why not key on (skill, connection_name)?”Imagine 20 skills declaring a connection named portal. Some point
at tilefive.com, some at exa.ai, some at amazon.com. The name
portal is a local label inside the skill — arbitrary, not
globally meaningful. Two skills shouldn’t collide just because their
authors both picked a reasonable short name.
Keying on the derived domain instead gives four properties:
- One source of truth per identity. Log into
claude.aionce; every skill talking to Claude sees the same session. - Skill rename / move is safe. Reorganize
amazon.pyinto a nested folder, rename itswebconnection toaccount— the cookies don’t move because they’re keyed onamazon.com. - Subdomain sprawl collapses naturally.
api.exa.aianddashboard.exa.aishare theexa.ainamespace; theapiconnection’s key-auth row and thedashboardconnection’s cookie row live alongside each other as differentitem_types. - Multiple accounts within a domain stay separate. Joe’s
Goodreads and Jane’s Goodreads are distinct rows (
goodreads.com26631647vsgoodreads.com+19887766). Same connection name, totally isolated.
The connection name is a local label. The domain is the identity namespace. That’s the architecture.
Two jars, one for persistence, one for a single call
Section titled “Two jars, one for persistence, one for a single call”Cookies live in two places simultaneously. Understanding which jar holds what is the key to the whole model.
┌────────────────────────────────────────────────────────────┐│ Credential store ││ ───────────────── ││ Rust, SQLite at ~/.agentos/data/agentos.db ││ Encrypted at rest (AES-256-GCM, key in macOS Keychain) ││ Rows keyed by (domain, identifier, item_type) ││ Persistent — survives engine restart, machine reboot ││ The vault. │└────────────────────────────────────────────────────────────┘ ↑ (write delta back on tool exit) │ │ (seed on tool entry) ↓┌────────────────────────────────────────────────────────────┐│ Per-call jar ││ ───────────── ││ Python, ContextVar inside the SDK ││ Plaintext — only in the Python worker's memory ││ Lives for exactly one tool invocation ││ The briefly-opened wallet. │└────────────────────────────────────────────────────────────┘ ↑ ↓ │ │ (read) (append) │ │ ↓ ↓┌────────────────────────────────────────────────────────────┐│ http.get / http.post calls inside the tool body │└────────────────────────────────────────────────────────────┘Lifecycle of one tool call
Section titled “Lifecycle of one tool call”Concretely, with ABP’s book_class on the portal connection:
- Tool entry. Engine resolves cookies for
(tilefive.com, joe@contini.co)from the credential store. Decrypts in memory, hands to the Python worker asparams["auth"]["cookies"]. - SDK seeds the per-call jar.
_bridge.pystashes the cookies and the connection’s mode on aContextVarscoped to this tool call. - Tool body runs.
http.post("/bookings", json=...). Insidehttp.post, the SDK reads the ContextVar, attaches cookies to the outbound request, sends it. - Response comes back. SDK parses
Set-Cookieheaders, accumulates changes into the jar delta (another ContextVar). - Tool body returns. SDK sees the delta, appends
__cookie_delta__: {...}to the return dict. - Engine receives the delta. Upserts the new cookie values into
the credential store row (
domain=tilefive.com,identifier=joe@contini.co). Row is now updated. - Per-call jar dies. ContextVar goes out of scope. Plaintext cookies no longer exist anywhere in memory.
Next time anyone calls book_class or get_my_memberships on the
portal connection, step 1 reads the updated cookies.
Session tokens that rotate on every request stay current
automatically, across processes and engine restarts.
Why two jars and not one?
Section titled “Why two jars and not one?”Because they have different lifetimes and different security requirements:
- The credential store must persist across restarts → must be on disk → must be encrypted.
- The per-call jar must be fast to read/write during a tool body → must be in-process → lives in memory only.
Copying the credential row into memory for the duration of the call, then writing the diff back, gives us both: fast access during work, no plaintext on disk at rest.
Three modes
Section titled “Three modes”A connection picks one of three modes. The mode decides cookie behavior and header personality.
mode= | Cookie jar | Headers | When to use |
|---|---|---|---|
"browser" | Yes — full per-call jar, Set-Cookie writeback | Navigate bundle (Sec-Fetch-*, Upgrade-Insecure-Requests, UA, Sec-CH-UA) | Cookie-authed dashboards, site scraping, login flows |
"fetch" | Yes — same as browser | XHR bundle (Sec-Fetch-Mode: cors, no nav headers) | AJAX-style API calls a real browser’s JS would make |
"api" (default) | No jar. Stateless. | None added — caller passes any needed headers | REST APIs with key or token auth |
Individual tools can override with mode=, jar=, or extra
headers= on the HTTP call — rare, maybe 5% of tools. 95% inherit
from the connection.
What this replaces
Section titled “What this replaces”Before this model landed, skills carried cookies manually:
# BEFORE — the skill threads cookies everywhereasync def list_api_keys(**params): cookie_header = require_cookies(params, "list_api_keys") async with http.client(cookies=cookie_header, http2=False, **http.headers(accept="json")) as c: resp = await c.get("/api/keys") return parse(resp)Now:
# AFTER — the connection owns the mode and the jarconnection("dashboard", base_url="https://dashboard.exa.ai", auth={"type": "cookies", "domain": ".exa.ai"}, mode="browser")
@connection("dashboard")async def list_api_keys(**params): resp = await http.get("/api/keys") return parse(resp["json"])The skill shrinks. The engine doesn’t grow. The cookie-auth Set-Cookie
roundtrip that used to require a session context manager happens
automatically because the per-call jar is ambient to every HTTP call
the tool body makes.
Why this is secure by construction
Section titled “Why this is secure by construction”- Tools on different connections cannot see each other’s cookies.
Different connections → different
(domain, identifier)keys → different rows → different in-memory jars. There’s no code path that would let one jar read the other. - A compromised skill cannot reach across identities. It can only touch the credential row its resolved connection maps to. It can’t enumerate rows, can’t open a jar for a different domain.
- Plaintext lives for seconds, in one process. The per-call jar dies with the tool call. The Python worker’s memory pages get reused; no persistent plaintext exists.
- Encryption at rest covers every identity. Same AES-256-GCM path for API keys, cookies, OAuth tokens. No connection type has a weaker storage path.
Related
Section titled “Related”- Security — the broader invariants (skill decoupling, engine refuses to know entity types, credentials encrypted at rest).
- Auth resolution — the algorithm
the engine uses to pick the freshest cookie when store, cache,
and browser providers all offer candidates for the same
(domain, identifier). - Connections & Auth (skill author’s view) —
the surface. What the
connection(...)call looks like, whatmode=does, which auth types are supported.