Configure with the Vercel AI SDK
This page takes a working AI SDK agent to a personalized turn. The user's own profile reaches the model, and what the turn learned is written back.
All docs pages, indexed for agents: https://docs.configure.dev/llms.txt (add .md to any page URL for plain markdown).
Two paths, and how to choose
The AI SDK is itself an MCP client. It can connect to Configure MCP directly and receive the configure_* tools with no adapter and no Configure SDK.
The rule: if your app already holds a Configure access token and you only want the tools, use the MCP path. If you own the model loop and also want profile.read(), profile.commit(), and the rest of the server-side API in the same process, use the SDK adapter.
Path A: connect over MCP
Point the AI SDK's MCP client at https://mcp.configure.dev/mcp and send the user's Configure access token as a bearer token. The tools arrive already shaped for streamText.
ts
import { createMCPClient } from "@ai-sdk/mcp";
const mcp = await createMCPClient({
transport: {
type: "http",
url: "https://mcp.configure.dev/mcp",
headers: { Authorization: `Bearer ${configureAccessToken}` },
},
});
const tools = await mcp.tools();
// ...run the turn, then: await mcp.close();On AI SDK 5 the same function is exported as experimental_createMCPClient.
The token is the OAuth access token your backend stored after sign-in. See Configure OAuth for how to get one, and Set up MCP for the client surface. Everything after "The four details that decide whether this works" still applies, except that write-back becomes the configure_profile_commit tool instead of an SDK call.
Path B: use the SDK adapter
Configure ships an adapter for this framework. Install the SDK and the framework packages:
bash
npm install configure ai @ai-sdk/openai @ai-sdk/reactai is an optional peer dependency of configure. Nothing in the main entry point loads it, so the adapter lives on its own subpath. That subpath is ESM only, because ai is ESM only: import it, do not require it. Add @ai-sdk/mcp as well if you take path A.
The adapter needs the five Configure credentials and a stored access token per user. Both of those are the next two sections, inline. Skip them if your app already signs users in with Configure.
Step 1: the five credentials
configure is already installed by the line above, and it carries the CLI:
bash
npx configure setup --usersSetup opens a browser to sign in or create a developer account, then writes five values to .env and registers the callback http://localhost:3000/auth/configure/callback:
bash
CONFIGURE_API_KEY=sk_... # server only
CONFIGURE_PUBLISHABLE_KEY=pk_... # safe in the browser
CONFIGURE_AGENT=your-agent # safe in the browser
CONFIGURE_OAUTH_CLIENT_ID=oc_... # safe in the browser
CONFIGURE_OAUTH_CLIENT_SECRET=ocs_... # server only, shown onceThe route handler below reads the first two. The other three belong to sign-in. If your app runs on another port, pass it: npx configure setup --users --redirect-uri http://localhost:5173/auth/configure/callback. Deployed callbacks get added later with npx configure add origin https://yourapp.com/auth/configure/callback, on the same client id.
No Configure account yet, and you want the handler running first? One unauthenticated POST mints working keys:
bash
curl -X POST https://api.configure.dev/v1/sandbox/provisionIt returns sk_test_, pk_test_, an agent handle, and a synthetic user whose profile is already full. It mints no OAuth client, so that is three of the five: enough for the route handler, not enough for browser sign-in. Point the handler at the synthetic user by subject instead of by token, and skip step 2 entirely until you want real users:
ts
const profile = configure.profile({ externalId: "sandbox-user" });Test mode (sandbox) covers the canned connector data and the failures you can trigger on purpose.
Step 2: sign-in, both halves
Sign-in is two routes at two URLs, not one. A callback page runs in the browser at the redirect URI you registered, because <configure-sso-button> created the PKCE verifier there and the browser is the only place that still has it. An exchange endpoint runs on your backend, because the client secret may not leave it. Neither half can do the other's job.
One command writes both:
bash
npx configure add callback --framework nextFor next that is four files, on paths derived from the callback you registered:
| File | Job |
|---|---|
app/auth/configure/callback/page.tsx | Browser half. Reads code and state, recovers the PKCE verifier, posts both to the backend, and finishes through Configure.completeSso() so a popup closes instead of navigating. |
app/api/auth/configure/callback/route.ts | Backend half. Exchanges the code and sets the tokens as httpOnly cookies. |
lib/configure-oauth.js | The exchange itself, client_secret_basic against https://api.configure.dev/oauth/token. |
components/ConfigureSignInButton.tsx | The mounted sign-in button. |
Existing files are left alone unless you pass --force. Drop --framework and it reads your package.json instead; express and vite get the same two halves, shaped for those stacks.
Two things are left for you. Render <ConfigureSignInButton /> on your sign-in page, and set NEXT_PUBLIC_CONFIGURE_OAUTH_CLIENT_ID in the production build rather than only .env.local, because a missing client id renders a setup notice instead of a button.
The generated backend route stores the access token in an httpOnly cookie named configure_access_token. That cookie is what the route handler reads next. Move it into your own session store when you have one; cookies are the portable default, not the best home for a refresh token.
Writing the two halves by hand instead is a detour, not a shortcut: Quick Start has the short version and Handle the callback has the full one, with the PKCE recovery and single-use-code defenses the generated files already carry.
The route handler
This is a complete Next.js App Router handler. Copy it whole.
ts
// app/api/chat/route.ts
import { cookies } from "next/headers";
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai";
import { Configure } from "configure";
import { toAISDKTools } from "configure/ai-sdk";
const configure = new Configure({
apiKey: process.env.CONFIGURE_API_KEY,
agent: process.env.CONFIGURE_AGENT,
});
// commit takes plain strings, not UI message parts. Flatten and trim here so
// the packet stays inside the bounds instead of throwing after the response
// already shipped.
function toCommitMessages(messages: UIMessage[]) {
return messages
.slice(-10)
.map((message) => ({
role: message.role === "assistant" ? ("assistant" as const) : ("user" as const),
content: message.parts
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n")
.trim()
.slice(0, 4000),
}))
.filter((message) => message.content.length > 0);
}
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
// The cookie the generated callback route set at sign-in. Read it from your
// own session store instead once you have one. The model never sees it.
const accessToken = (await cookies()).get("configure_access_token")?.value;
const profile = configure.profile({ token: accessToken });
const result = streamText({
model: openai("gpt-4o"),
messages: convertToModelMessages(messages),
// toAISDKTools returns a Record keyed by tool name, so it spreads correctly.
tools: {
// ...yourTools, if you have your own. Declare them above; this handler
// works with only Configure's.
...toAISDKTools(profile, { connectors: ["gmail", "calendar"] }),
},
// Required. Without it the turn ends on the tool call and the user sees
// no text, even though the Configure read succeeded.
stopWhen: stepCountIs(5),
// Write-back belongs here, after the answer. Do not await it: the response
// has already streamed, and a slow commit only delays the process.
onFinish: ({ text }) => {
void profile
.commit({ messages: toCommitMessages(messages), response: text })
.catch((error) => console.error("Configure commit failed", error));
},
});
return result.toUIMessageStreamResponse();
}Swap openai("gpt-4o") for any AI SDK provider. Nothing above depends on the provider.
The four details that decide whether this works
1. A Record, not an array
profile.tools() returns Anthropic-shaped definitions: an array of { name, description, input_schema }. The AI SDK wants an object keyed by tool name. Spreading the array into a tools object produces { 0: ..., 1: ... }, the model is offered nothing, and no error is raised. toAISDKTools() does the conversion, so spread its result instead.
It takes the same options as profile.tools() (connectors, actions, advanced) plus onError. Options that belong to profile.tools() are forwarded to it; onError is not.
2. stopWhen is required
The AI SDK stops after one step by default. The model calls configure_profile_read, the step ends, and the turn returns no text. Your logs show a successful Configure read and your user sees an empty reply. Set stopWhen: stepCountIs(5) so the model gets a step to answer from the tool result. stepCountIs is exported by ai on both version 5 and version 7.
3. Commit in the completion callback, unawaited
commit() is write-back, not part of the answer. It belongs in onFinish, and it must not be awaited in the request path.
It is bounded, and it throws INVALID_INPUT when a packet is over the limit: at most 20 messages, 16,000 characters per message, at most 20 memories of at most 1,000 characters each, and 50,000 characters in total. In a completion callback that throw is invisible, because the response already shipped. Trim the packet before you send it, as toCommitMessages above does, and log the rejection.
Commit messages are { role, content } with content as a string. AI SDK UI messages carry parts instead, so flatten them first.
4. Refusals come back as values
Configure fails closed. A call for an app the user has not connected, or a call made without sign-in, is refused. The adapter returns that refusal to the model as a normal tool result:
json
{
"error": true,
"code": "AUTH_REQUIRED",
"message": "...",
"suggestedAction": "connect_tool",
"docUrl": "https://docs.configure.dev/reference/errors#TOOL_NOT_CONNECTED"
}This is the point of the default. A Configure refusal NAMES its own fix. suggestedAction is a machine-actionable action word, not a URL: reauthenticate, connect_tool, reconnect, check_permissions, commit_profile, and a few others. You mint the actual link with profile.connect({ app }), server side, and never build one by hand.
Throwing instead does not abort the run, but the framework converts the error into a generic tool-error part, which drops suggestedAction and docUrl and never reaches toolResults. The model then knows only that something failed.
Pass onError: 'throw' when you handle refusals yourself. code values are listed in Handle errors.
Tell the model what to do with the value. One line in your system prompt is enough:
text
If a tool result has error: true, do not retry it. Say in one sentence what is
missing. Never invent a sign-in or connect URL: the app supplies the link.Turn a refusal into a connect button
The refusal is in the message parts, so the client can react to it. It carries the action word, not a link, so the link is minted on your server:
ts
// app/api/configure/connect/route.ts
export async function POST(req: Request) {
const { app } = await req.json();
const profile = configure.profile({ token: await accessTokenFromCookie() });
const { connect_url, import_url } = await profile.connect(app ? { app } : {});
return Response.json({ connect_url, import_url });
}tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { isToolUIPart } from "ai";
export function Chat() {
const { messages } = useChat();
return <div>{messages.map((message) =>
message.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>;
if (!isToolUIPart(part) || part.state !== "output-available") return null;
const output = part.output as { error?: boolean; message?: string; suggestedAction?: string };
if (!output?.error) return null;
return (
<button key={i} onClick={async () => {
const res = await fetch("/api/configure/connect", { method: "POST", body: "{}" });
const { connect_url, import_url } = await res.json();
// connect_url is absent when status is import_ready, which returns
// import_url instead. Opening undefined would blank the tab.
const url = connect_url ?? import_url;
if (url) window.open(url, "_blank", "noreferrer");
}}>
{output.message ?? "Connect to continue"}
</button>
);
}),
)}</div>;
}The user finishes on Configure and returns. Configure signs and hosts every link, so always use the one profile.connect() returned.
Prove it
Done is a real personalized turn plus a write-back. A button that renders is not done.
Prove the credentials before the model loop. Stop your dev server first, because verify serves the callback while it waits:
bash
npx configure verifyIt completes a real sign-in, exchanges the code with your client secret, and reads a profile with the resulting token, so a rotated secret or a callback that differs by one character fails in your terminal instead of in a user's browser. Then start the app, sign in through the button, and ask the agent "what do you know about me?".
A working turn looks like this:
configure_profile_readappears in your logs as an ordinary tool call.- The reply uses what came back, not a generic answer.
commitruns after the reply, returning astatusofprocessingorcompletedwithfacts_writtenandmemories_written.
Then ask again in a new conversation. What the first turn learned is already there. That round trip is the loop, and it is the thing you are shipping: The loop explains why each step exists.
Two failures come first, and neither one looks like an error:
The assistant replies with nothing. Your logs show a successful Configure read and the user sees an empty message. stopWhen is missing, so the turn ended on the tool call. Set stopWhen: stepCountIs(5).
The assistant apologizes instead of answering. Look at the tool result rather than the text. A value with error: true and code: "AUTH_REQUIRED" means the token was rejected: the user is not signed in, or the token expired. It carries suggestedAction, which is the fix to surface. A value with code: "INVALID_INPUT" and the message profile requires token for linked users or externalId for unlinked users is different: no token reached configure.profile() at all, so the cookie read is wrong or the user never completed sign-in.
To test the tool path with no model at all, call it directly:
ts
const result = await profile.executeTool({
name: "configure_profile_read",
input: { sections: ["identity", "summary", "preferences"] },
});A non-error result confirms your keys, agent handle, and token are wired correctly. Provider-agnostic loops for Anthropic and OpenAI: Handling tool calls. Every profile method: Reference: profile.
The whole path
npm install configure ai @ai-sdk/openai.npx configure setup --userswrites the five credentials and registershttp://localhost:3000/auth/configure/callback.npx configure add callback --framework nextwrites the callback page, the exchange endpoint, and the sign-in button.- Render
<ConfigureSignInButton />, and setNEXT_PUBLIC_CONFIGURE_OAUTH_CLIENT_IDin the production build. - Copy the route handler into
app/api/chat/route.ts. It reads theconfigure_access_tokencookie the callback set. - Spread
toAISDKTools(profile)intotools. It is a Record, so it spreads;profile.tools()is an array and does not. - Set
stopWhen: stepCountIs(5), or the turn ends on the tool call with no text. - Call
commit()inonFinish, unawaited, on a trimmed packet. - Render the refusal's
suggestedActionin your UI, and tell the model in one line not to retry a result witherror: true. npx configure verify, then sign in and ask the agent about the user.