Connect your app to your users' context
By the end of this page, a user clicks a chip in your app, brings their context on Configure's page, and comes back to an agent that already knows them. Steps 1 to 6 are code on your server. Step 7 gives the model Configure's tools.
You need Node.js 18 or later. You do not need a Configure account to start: the sandbox gives you keys and a test user in one call.
1. Install the SDK and get your credentials
bash
npm install configure
npx configure setup --usersTIP
Using a coding agent? Paste Read https://configure.dev/skill.md and add Configure to this project. into Claude Code, Cursor, or Codex. It runs setup and every step on this page. See Skills.
Setup signs you in and writes your credentials to .env:
| Variable | What it is |
|---|---|
CONFIGURE_API_KEY | Secret key, sk_ prefix. Server only. Never send it to a browser. |
CONFIGURE_AGENT | Your agent handle: lowercase letters, digits, and hyphens, 2 to 63 characters. Identifies your app on every request. |
CONFIGURE_PUBLISHABLE_KEY | Publishable key, pk_ prefix. Safe in browser code. Not used on this page. |
CONFIGURE_OAUTH_CLIENT_ID, CONFIGURE_OAUTH_CLIENT_SECRET | The OAuth client for Sign in with Configure. Server only. Not used on this page. |
This page needs two of them in production: the secret key and the agent handle. Give staging its own key so a test user never writes to a real profile.
Create one client for your server, and one profile handle per user, keyed by the id your product already uses:
ts
import { Configure } from "configure";
export const configure = new Configure({
apiKey: process.env.CONFIGURE_API_KEY,
agent: process.env.CONFIGURE_AGENT,
});
export const profileFor = (userId: string) =>
configure.profile({ externalId: userId });externalId is your user id. The user never sees it. When they connect, Configure links their account to it. Use an id that lasts longer than a session, such as your account id. Never use a cookie or a session id: the next visit looks like a new user, and the profile is lost. For visitors without an account, use Sign in with Configure as the account.
Four names on this page are yours: app is your Express (or similar) server, req.user is the user your sign-in put on the request, store is your cache of one string per user, and contextFor is the helper in step 6. Server code is labeled with a file under server/, and browser code with a file under client/.
2. Add the chip
Add a chip or button that links to a route on your server. Label it with what the user gets, such as Connect or Personalize with Configure. Put it where the user decides to share context with you: under the composer, in an empty chat, in onboarding, or in settings.
html
<a class="chip" href="/configure/start">Connect</a>
The chip only links to the route in step 3. Configure's own buttons, which open Configure's page inside yours, are on Components.
3. Send the user to Configure
The route mints a link for this user and redirects to it. returnUrl is where Configure sends them when they finish.
ts
import { profileFor } from "./configure";
const RETURN_URL = "https://yourapp.com/configure/return"; // a constant, never from the request
// The chip in step 2 links here.
app.get("/configure/start", async (req, res) => {
const connect = await profileFor(req.user.id).connect({ returnUrl: RETURN_URL });
res.redirect(connect.connect_url); // Configure's page, then back to RETURN_URL
});Three rules:
- Mint the link when the user clicks. Links are per user and expire at
expires_at. Never build a Configure URL yourself: onlyprofile.connect()mints one. Asking again for the same user while the last link is still valid gives you that same link back, with the newreturnUrl. returnUrlis a constant. It must behttps, even while you develop: Configure refuseshttp://localhost. Never build it from the request, or your route becomes an open redirect.- Most apps never register a host. Configure sends the user straight to
returnUrlwhen the browser'sRefererorigin matches it, which the defaultstrict-origin-when-cross-originpolicy sends, or when its host is inallowed_return_hostson your developer account. Otherwise the user sees a button that names the host, and clicks it to continue.
WARNING
Register a host only when the browser sends no Referer: a native or headless client, a site with Referrer-Policy: no-referrer or same-origin, or a link marked rel="noreferrer". Register your own apex, which covers its subdomains. Never register a shared host. PATCH /v1/developer/me with allowed_return_hosts takes up to 20 bare hostnames such as example.com. This list is separate from npx configure add origin, which registers an OAuth callback and has no effect here.
The result has other fields you can ignore here: connected says whether the app you asked for is already connected, livemode says whether this is the test plane, mcp_result is what an MCP host renders as Configure's card, and instructions and durable_setup are written for agents.
connect() options, all optional. Over REST they are snake_case. The API reference lists the rest.
| SDK | REST | What it does |
|---|---|---|
returnUrl | return_url | Where Configure sends the user afterwards. https only. |
app | app | Ask for one app: gmail, calendar, drive, notion, or sheets. The page opens on that app. |
purpose | purpose | One line shown to the user about why. |
client_name in the request body does not change the page. Configure's pages show your agent's registered display name and logo.
4. The user brings their context to Configure
Configure's page does the rest, with your app's name in its title. You never handle a credential, a phone number, or an export.





There is no permissions screen. Finishing the flow approves your app. The user can narrow or revoke access later on their Configure profile. A user who already has a Configure account taps Sign in on the first screen and enters their phone number and code.
The page asks for a mailbox only when you require one. What the hosted flow asks for has every screen and how to require an app or an assistant.
Try it yourself. That link opens Configure's page as a user sees it.
5. Bring them back
Configure sends the user to RETURN_URL. The return route refreshes their stored context (step 6) and sends them to the chat.
ts
// Configure sends the user here when they finish.
app.get("/configure/return", async (req, res) => {
const context = await contextFor(req.user.id, { force: true }); // step 6: reads the profile; null until they connect
if (!context) return res.redirect("/?configure=not-finished"); // they closed Configure's page early
res.redirect("/chat"); // their first chat starts with their context
});
Two rules:
- Trust the profile, not the URL. Anyone can type the return address into a browser.
contextForreadsprofile.linked, and the route decides from that. - Refresh on the return, not when you mint the link. A user who opens Configure's page and closes it has not changed anything. A fresh import can take a few minutes to process, so a thin first read is normal.
To show a welcome screen that lists what the user brought, or to pre-fill a form from their profile, see Onboarding.
6. Keep their context current
Two jobs, both on your server.
Refresh each connected user's profile once a day, store it, and inject it on the first turn of every session. The profile grows whenever the user talks to another connected agent, imports memories, or connects an app. A daily read keeps your agent current, for example knowing the user connected Gmail yesterday. Tell the user, with a line such as "Refreshed from your Configure profile today".
Refresh when you read, not on a timer. Timers are easy to break: they run on one instance and not others, stop after a serverless cold start, or never get started. A check on each read cannot break that way, because the code that needs the context is the code that refreshes it.
ts
const A_DAY = 24 * 60 * 60 * 1000;
const NOT_CONNECTED = 5 * 60 * 1000;
// Called on the first turn of a session. Returns fast when the stored copy is
// fresh, and refreshes it when it is not.
export async function contextFor(userId: string, { force = false } = {}) {
const cached = await store.get(userId); // { text, refreshedAt } or null
// Cache "not connected" too, or every turn from every unconnected user is a
// live read. Keep it short so a user who connects is picked up within minutes.
const ttl = cached?.text ? A_DAY : NOT_CONNECTED;
if (!force && cached && Date.now() - cached.refreshedAt < ttl) return cached.text;
const read = await profileFor(userId).read({
sections: ["identity", "preferences", "summary", "integrations"],
});
const text = read.profile.linked ? read.profile.format() : null; // null until they connect
await store.set(userId, { text, refreshedAt: Date.now() });
return text;
}What read() returns:
ts
const read = await profileFor(userId).read({ sections: ["identity", "preferences", "summary", "integrations"] });
read.profile.linked; // boolean: has this user connected
read.profile.identity; // { name, given_name, family_name, email, email_verified, role, occupation, location, company_website, ... }
read.profile.summary; // one paragraph
read.profile.integrations; // { gmail: { connected }, calendar: { connected }, drive, notion, sheets }
read.profile.format(); // the whole thing as text for a system prompt
read.profile.format({ guidelines: false }); // the same without Configure's handling guidelinesStep 5 already refreshes once, with force: true, so the first session does not wait a day.
ts
// First turn of every session, for a linked user.
const context = await contextFor(user.id);
const systemPrompt = context
? `${basePrompt}\n\n<user_context>\n${fence(context)}\n</user_context>`
: basePrompt; // not connected yet: nothing to injectfence is yours, and you need it. The profile holds text that other assistants wrote and that the user pasted in. That text can contain your closing tag, end the block early, and turn the rest into instructions. Trust and boundaries has a working fence and two ways a naive one fails.
Commit every message at the end of every turn, for every user, including turns the user stopped. You do not need an "is this a Configure user" flag. Before a user connects, the profile lives in your own namespace, keyed by your externalId. When they connect, it moves to their account with nothing lost, so their first connected turn is already informed.
Never read the profile on a turn to decide whether to commit. The stored copy already answers linked.
ts
await profileFor(user.id).commit({
messages: [
{ role: "user", content: userText },
{ role: "assistant", content: assistantText },
],
});format() includes Configure's handling guidelines, about 2,400 characters telling the model not to invent personal facts. Pass guidelines: false when you have your own. The Python SDK defaults to off, so pass guidelines=True there when you want them.
Check the text before you inject it. On an empty profile, format() returns the guidelines and nothing else, about 2,375 characters. format({ guidelines: false }) returns an empty string in that case, which is easy to test.
A turn can finish, be stopped by the user, or throw. A stopped turn often holds something worth keeping. If you stream, commit from the stream's end callback, which runs in all three cases:
ts
return result.toUIMessageStreamResponse({
// Without this, the end callback does not run when the user stops the answer
// or closes the tab, and that exchange is lost.
consumeSseStream: consumeStream,
originalMessages: turnMessages, // so the callback sees the user's message, not only the reply
onEnd: ({ messages }) => {
// Not awaited: a commit must not delay the reader or fail the turn.
void commitTurn(user.id, messages).catch(() => {});
},
});Send only the messages that exist. A stopped turn often has no assistant text, and an empty message is rejected.
Truncate before you send. The SDK checks the limits in your process, so an over-long turn throws INVALID_INPUT before anything reaches Configure. A fire-and-forget commit swallows that error and loses the exchange. commit accepts up to 20 messages, up to 16,000 characters per message and 50,000 across the whole call. Cut each message and the whole batch to fit.
The result has status: processing when the commit was queued, or completed when it ran inline. facts_written and rejected_memories appear once it has run. Pass sync: true to always wait for them.
If Configure is slow or down, your chat must still answer. Put a timeout on the read and continue without context, and never let a commit throw into your response:
ts
const context = await Promise.race([
contextFor(user.id),
new Promise((resolve) => setTimeout(() => resolve(null), 1500)), // your budget, not ours
]).catch(() => null); // no context beats no answerDo the same for every Configure call in a turn.
WARNING
Do not leave the refresh or the commit to the model. In our test, a small model with the tools available called read zero times and commit zero times across nine turns. Your server makes sure both happen.
7. Give the model the Configure tools
The daily refresh gives the model a starting picture. The configure_* tools over MCP let it search the profile, save what it learns, and read connected apps, through a session your server mints per user.
Mint the session the first time the model calls a tool, not at the start of every turn. Minting, connecting, and listing tools takes three round trips before the first token, and most turns never use Configure. One integration measured half a second added to every turn by minting up front. Take the tool definitions from profile.tools(), mint the session inside the first tool call, and reuse it for the rest of the turn. A session is a URL and a bearer token, so any client that speaks MCP over Streamable HTTP can use it.
Anthropic needs @anthropic-ai/sdk 0.123 or later, the mcp-client-2025-11-20 beta, the server list, and a matching toolset. OpenAI takes the session as a remote MCP tool. Vercel AI SDK takes it as an MCP client. Any other client takes the URL and the bearer.
OpenAI. The Responses API takes the session as a remote MCP tool. The bearer goes in authorization.
Vercel AI SDK. The session goes in as an MCP client with the bearer in its headers. Close it when the turn ends.
Any MCP client. Any client that speaks MCP over Streamable HTTP with a bearer. Here, the official TypeScript SDK:
All four samples belong in server/turn.ts.
ts
const session = await profileFor(user.id).mcpSession();
const response = await anthropic.beta.messages.create({
model: "claude-opus-5",
max_tokens: 16000,
betas: ["mcp-client-2025-11-20"],
system: systemPrompt,
messages: turnMessages,
mcp_servers: session.mcp_servers, // url, name, authorization_token
tools: session.mcp_servers.map((s) => ({ // every server needs a matching toolset
type: "mcp_toolset",
mcp_server_name: s.name,
})),
});ts
const session = await profileFor(user.id).mcpSession();
const response = await openai.responses.create({
model: "gpt-5",
instructions: systemPrompt,
input: turnInput,
tools: session.mcp_servers.map((s) => ({
type: "mcp",
server_label: s.name,
server_url: s.url,
authorization: s.authorization_token,
require_approval: "never",
})),
});ts
import { streamText, stepCountIs, consumeStream } from "ai";
import { createMCPClient } from "@ai-sdk/mcp";
const [server] = (await profileFor(user.id).mcpSession()).mcp_servers;
const configureMcp = await createMCPClient({
transport: { type: "http", url: server.url, headers: { Authorization: `Bearer ${server.authorization_token}` } },
});
const result = streamText({
model,
system: systemPrompt,
messages: turnMessages,
stopWhen: stepCountIs(5),
tools: { ...yourTools, ...(await configureMcp.tools()) },
// streamText returns before the answer streams, so closing the client in a
// finally block would cut the answer off. Close it when the stream ends.
onFinish: () => { void configureMcp.close(); },
onError: () => { void configureMcp.close(); },
});
return result.toUIMessageStreamResponse({ consumeSseStream: consumeStream });ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const [server] = (await profileFor(user.id).mcpSession()).mcp_servers;
const client = new Client({ name: "your-app", version: "1.0.0" });
await client.connect(
new StreamableHTTPClientTransport(new URL(server.url), {
requestInit: { headers: { Authorization: `Bearer ${server.authorization_token}` } },
}),
);
const { tools } = await client.listTools(); // 17 tools for a user sessionAnthropic. Anthropic's API rejects mcp_servers without a matching mcp_toolset, so keep both.
OpenAI. Use this rather than running the calls yourself. toOpenAIFunctions(profile.tools()) with profile.executeTool(call) also works, but on a profile keyed by externalId it serves only the profile tools. Connector tools return a connect link until they are called through the session or a user token.
Vercel AI SDK. toAISDKTools(profile) from configure/ai-sdk also exists. On a profile keyed by externalId it serves only the profile tools. The session above is what reaches connected apps.
Any MCP client. Give tools to your model loop and send its calls to client.callTool. Use one session per user. The bearer identifies the user, so no tool takes a user id.
With the tools, the model calls configure_profile_search when the injected context does not answer a question, configure_profile_remember to save something, and configure_gmail_search or configure_calendar_get to read the user's mail or week. When a user connects Gmail, Calendar, Drive, Notion, or Sheets, those tools appear on the same server. You do not change or deploy anything.


Try it in the sandbox
Test every step before a real user does. This endpoint needs no account and is rate limited per IP:
bash
curl -X POST https://api.configure.dev/v1/sandbox/provisionYou get sk_test_ and pk_test_ keys, a test agent, and a synthetic user, Nova Sandbrook, external id sandbox-user, already linked with Gmail and Calendar connected. Point your .env at the test key and run the seven steps as her. The tool list grows from nine tools on your key to seventeen on her session; the MCP page explains why. The response also includes her test phone number and its fixed code, so you can go through Configure's page yourself. Every Configure page accepts them on the test plane and never sends a text.
To test a failure, call POST /v1/sandbox/simulate with your sk_test_ key:
| Scenario | What it does | What to watch |
|---|---|---|
gmail_token_expired | Makes her Gmail connection fail like a revoked token. | Your tool_not_connected handling, and the link in its payload. |
POST /v1/sandbox/reset restores her seeded state. returnUrl must be https on the test plane too, so run your return route behind an https address while you develop.
To start from a running app, configure-quickstart has three examples of this pattern: a chat app, a product onboarding, and an SMS agent.
Handle errors
| What you see | What it means | What to do |
|---|---|---|
authorization_required | The user is not connected. It never means "no data on this user". | The result has a connect_url. Send the user to it, through /configure/start or as the link itself. |
approval_required | The user linked a real Configure account, so your key no longer has access to their profile. They need to approve your agent. | Send them through /configure/start again. Finishing Configure's page approves your agent. See Your own users. |
-32009 commit_required | A read left a commit this turn has not made. | Commit the turn, then retry. configure_connect is not the fix. |
tool_not_connected | The user has not connected that app, or its token was revoked. | On an externalId profile the call returns the connect payload. On a token profile it throws with suggestedAction "connect_tool" (never connected) or "reconnect" (access expired) and no link, so mint one with connect({ app }). See Connectors. |
429 rate_limited | Too many calls from your account in a short time. The limit is per account, not per user. The body has retry_after in seconds. | Back off and reuse the link you have. Each link is valid until expires_at. |
402 PAYMENT_REQUIRED | Your account is over its monthly read quota. The user's connection is fine. | Do not show a connect link; it will not help. Fix the plan, and let the turn answer without context. |
The SDK does not put the server's own code, such as approval_required, on the error. Match statusCode and suggestedAction instead. 401 (AUTH_REQUIRED) means your key is wrong. 403 (ACCESS_DENIED, suggestedAction: "complete_approval") means this user has not approved your agent. Handle them separately: if you treat both as "not connected" and cache it, a key rotation marks every user as unconnected.
TIP
The user clicks a chip, brings their context on Configure's page, and comes back to an agent that knows them and learns from every conversation.