Connect an app.
This page shows you how to get the credential that every other PAMbase call needs: a connection token. PAMbase is, in one line, OAuth for AI memory — each user has one personal memory store, and before your app can read it or write to it, the user must grant your app access through a consent screen. The end result of that consent is a long-lived connection token your backend sends as a bearer credential on every request.
client_id, a client_secret, and an app slug (your app's unique URL name). Throughout these docs the running example is Margin, a reading companion whose slug is margin. New to the platform? Start with the Quickstart.memory:read:note.* (read the user's reading notes). See Permissions.The flow end-to-end
Three short hops. The user's browser visits the hub, the hub sends them back to you with a code, and your server trades that code for the token.
User clicks "Connect PAMbase" in Margin│▼Browser → GET <hub>/connect?app=margin&redirect_uri=<your_callback>&state=<csrf>&scopes=<optional override>│▼Hub: user logs in (if needed), reviews the scopes Margin's MANIFEST requests, approves│▼Hub redirects → <your_callback>?code=<short_lived_code>&state=<csrf>│▼Your backend → POST <api>/v1/oauth/token{ code, client_id, client_secret }│▼Returns → { connection_token, ai_id, scopes }
Three terms appear above and are defined as you use them below: the state (a random anti-forgery value), the code (a short-lived, single-use authorization code), and the connection token (the durable credential). The scopes shown on the consent screen come from your app's manifest — the declaration of everything your app is allowed to ask for. You cannot request a scope the user's consent screen hasn't been told about.
1. Send the user to the hub
Generate a fresh state value per attempt — a random string you persist server-side and check when the user returns, so an attacker can't forge the callback (CSRF protection). Then build the consent URL with buildConnectUrl. By default the consent screen shows every scope in your manifest; pass scopes only when you want to request a subset.
import { buildConnectUrl } from "@pambase/sdk";import crypto from "node:crypto";app.get("/connect", (req, res) => {const state = crypto.randomUUID();req.session.pambaseState = state; // persist to verify on the callbackconst url = buildConnectUrl({hubBaseUrl: process.env.PAMBASE_HUB_URL!, // http://localhost:3000 in devappSlug: "margin",redirectUri: `${process.env.APP_URL}/connect/callback`,state,// Optional subset. Margin's full set lives in its manifest:// scopes: ["identity:read", "memory:read:note.*", "memory:write:note.*"],});res.redirect(url);});
2. Handle the callback and exchange the code
When the hub redirects the user back, you receive a one-time code and the state you issued. Verify the state matches first, then exchange the code for a connection token on your server. The exchange requires your client secret, so it must never run in the browser.
import { exchangeCodeForToken } from "@pambase/sdk";app.get("/connect/callback", async (req, res) => {// CSRF check — the returned state must match what we issuedif (req.query.state !== req.session.pambaseState) {return res.status(400).send("Invalid state");}const { connection_token, ai_id, scopes } = await exchangeCodeForToken({apiBaseUrl: process.env.PAMBASE_API_URL!, // http://localhost:4000 in devcode: String(req.query.code),clientId: process.env.PAMBASE_CLIENT_ID!,clientSecret: process.env.PAMBASE_CLIENT_SECRET!,});// scopes = what the user ACTUALLY approved (may be fewer than you asked for)await db.users.update(currentUser.id, {pambaseToken: connection_token, // store encrypted, server-side onlypambaseAiId: ai_id,pambaseScopes: scopes,});res.redirect("/app");});
3. Use the token with the SDK
Pass the stored token when you construct the client. Every subsequent call in these docs assumes a pambase built this way.
import { PAMbaseApp } from "@pambase/sdk";const pambase = new PAMbaseApp({baseUrl: process.env.PAMBASE_API_URL!,connectionToken: user.pambaseToken, // loaded from your secret storeretry: { maxAttempts: 3 }, // optional; built-in for network/429/5xx});
Margin in practice
Margin is a reading companion. At connect time it requests exactly the scopes it needs — read the user's display name, read and write their reading notes and observed preferences, read context at session start, and host a chat. After consent, scopes tells Margin what the user actually approved:
{"connection_token": "pct_live_3f9a…","ai_id": "ai_8f3c1b2d","scopes": ["identity:read","memory:read:note.*","memory:write:note.*","memory:read:preference","memory:write:preference","context:read:app.session.start","ai:host:chat"]}
Margin stores the token, then branches on the returned scopes. If the user declined ai:host:chat, Margin hides its recommendation chat instead of erroring later. Always read the returned array — never assume you got everything you asked for. To inspect what a given user holds at any time, see Discover the vocabulary.
Token lifecycle
There are no refresh tokens. The connection token is durable, and when it eventually expires or the user revokes the connection, your next API call returns 401 unauthorized — at which point you re-run this flow.
| Token | TTL | Notes |
|---|---|---|
connection_token | ~365 days | Bearer credential for all app API calls. No refresh — re-connect on 401. |
authorization code | 10 minutes | Single-use. Exchanged exactly once for a connection token. |
| hub / dev session | 7 days | Cookie session for the user in the hub UI — unrelated to your app token. |
401 unauthorized from the API as “connection gone”: clear the stored token and prompt the user to reconnect. There is nothing to refresh. See Auth & tokens.Gotchas & errors during connect
| Symptom | Cause | Fix |
|---|---|---|
invalid_grant | Code expired (>10 min), already used once, or doesn't match this client | Restart the flow; exchange the code once, immediately, on the server |
unauthorized | Wrong client_id / client_secret on the token exchange | Check your dev-portal credentials and env vars |
| scope not in manifest | You passed a scope your manifest hasn't declared | Add the scope to your manifest, then re-consent |
| state mismatch | Missing or forged callback, or a stale browser tab from an old attempt | Reject and restart; never trust a callback without a matching state |
forbidden | Later call assumes a scope the user declined at consent | Read the returned scopes array and adapt; see Discovery |
What to expect next
You now hold a connection token and know which scopes the user approved. The natural first read is the memory brief — a one-call summary of the person — followed by richer reads and writes.
- Pull the memory brief — your first read with the new token.
- Discover the vocabulary — confirm what scopes this user actually granted.
- Permissions and Manifest — how scopes are declared and enforced.