Skip to content

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 --users

TIP

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:

VariableWhat it is
CONFIGURE_API_KEYSecret key, sk_ prefix. Server only. Never send it to a browser.
CONFIGURE_AGENTYour agent handle: lowercase letters, digits, and hyphens, 2 to 63 characters. Identifies your app on every request.
CONFIGURE_PUBLISHABLE_KEYPublishable key, pk_ prefix. Safe in browser code. Not used on this page.
CONFIGURE_OAUTH_CLIENT_ID, CONFIGURE_OAUTH_CLIENT_SECRETThe 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>

Smry's home page with the chip circled in red: it reads Connect, followed by the Claude, ChatGPT, Gemini, and Grok marks

The chip under the composer on Smry's home page. One click and the user is on Configure's page.

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: only profile.connect() mints one. Asking again for the same user while the last link is still valid gives you that same link back, with the new returnUrl.
  • returnUrl is a constant. It must be https, even while you develop: Configure refuses http://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 returnUrl when the browser's Referer origin matches it, which the default strict-origin-when-cross-origin policy sends, or when its host is in allowed_return_hosts on 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.

SDKRESTWhat it does
returnUrlreturn_urlWhere Configure sends the user afterwards. https only.
appappAsk for one app: gmail, calendar, drive, notion, or sheets. The page opens on that app.
purposepurposeOne 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.

Configure's import screen titled Remy uses Configure to connect your apps and AI assistants, with ChatGPT, Claude, Gemini, and Grok cards to import, a list of apps to connect starting with Gmail and Google Calendar, and Continue greyed out

1. Import an assistant (ChatGPT, Claude, Gemini, or Grok) or connect an app (Gmail, Calendar, Drive, Notion, or Outlook). Continue unlocks after one.

The import dialog for ChatGPT, with steps to open ChatGPT, copy its answer, and paste it back

2. An import. Configure opens the assistant with the export request written for the user, and checks what they paste back.

Configure's phone screen reading Verify your phone number

3. A phone number. Other apps that use Configure find the same person by it.

Configure's six-digit code screen

4. The code. Entering it saves what the user brought to their Configure account and approves your app.

Configure's receipt reading You're all set, with a row per imported assistant and connected app, and a button to return to Remy

5. The receipt. What came in, then back to your returnUrl.

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
});

A chat headed Remy with a pill reading Updated from your Configure profile today, Remy's first message naming Nova's work and her connected Gmail, an answer to what do you know about me drawn from her profile, and the chip under the composer reading Connected

Back in Remy. The chip now reads Connected, and Remy's first message already knows Nova.

Two rules:

  • Trust the profile, not the URL. Anyone can type the return address into a browser. contextFor reads profile.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 guidelines

Step 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 inject

fence 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 answer

Do 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 session

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

The agent's answer, headed From your linked Configure profile, listing occupation, location, current projects, working hours, diet, and tool preferences

What your agent knows about a user it has never talked to.

The agent's answer listing what it can access through the user's Configure account: profile, Gmail, Calendar, and more

What it can reach through connected apps. Apps the user has not connected are listed too.

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/provision

You 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:

ScenarioWhat it doesWhat to watch
gmail_token_expiredMakes 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 seeWhat it meansWhat to do
authorization_requiredThe 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_requiredThe 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_requiredA read left a commit this turn has not made.Commit the turn, then retry. configure_connect is not the fix.
tool_not_connectedThe 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_limitedToo 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_REQUIREDYour 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.

Next steps ​

Personalization infrastructure for agents