PAMbaseDocs
How-to

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.

Prerequisites
You have registered your app in the dev portal and have a 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.
One user, one memory — no persona to pick
Every PAMbase user has exactly one memory store, auto-provisioned on signup. There is no “AI” or personality for the user to choose during connect. Your app brings its own persona and (optionally) its own model; the connection simply binds your app to that user's memory at the scopes they approve. A scope is a single permission such as 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.

bash
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.

routes/connect.ts
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 callback
const url = buildConnectUrl({
hubBaseUrl: process.env.PAMBASE_HUB_URL!, // http://localhost:3000 in dev
appSlug: "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.

routes/callback.ts
import { exchangeCodeForToken } from "@pambase/sdk";
app.get("/connect/callback", async (req, res) => {
// CSRF check — the returned state must match what we issued
if (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 dev
code: 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 only
pambaseAiId: ai_id,
pambaseScopes: scopes,
});
res.redirect("/app");
});
Store the token like a password
The connection token is a long-lived bearer credential to the user's memory. Keep it in a server-side secret store, encrypt at rest, and never expose it to the browser or embed it in client bundles. See Security for the full checklist.

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.

typescript
import { PAMbaseApp } from "@pambase/sdk";
const pambase = new PAMbaseApp({
baseUrl: process.env.PAMBASE_API_URL!,
connectionToken: user.pambaseToken, // loaded from your secret store
retry: { 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:

json
{
"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.

TokenTTLNotes
connection_token~365 daysBearer credential for all app API calls. No refresh — re-connect on 401.
authorization code10 minutesSingle-use. Exchanged exactly once for a connection token.
hub / dev session7 daysCookie session for the user in the hub UI — unrelated to your app token.
Recovering from 401
Treat any 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

SymptomCauseFix
invalid_grantCode expired (>10 min), already used once, or doesn't match this clientRestart the flow; exchange the code once, immediately, on the server
unauthorizedWrong client_id / client_secret on the token exchangeCheck your dev-portal credentials and env vars
scope not in manifestYou passed a scope your manifest hasn't declaredAdd the scope to your manifest, then re-consent
state mismatchMissing or forged callback, or a stale browser tab from an old attemptReject and restart; never trust a callback without a matching state
forbiddenLater call assumes a scope the user declined at consentRead 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.