Skip to content

Message-Agent SSO

Use sign-in.me when your agent lives in a message thread instead of a browser session. The agent sends one hosted link. Configure handles phone verification, connector setup, and consent. Your server stores the returned agent-scoped token. On the next message, Configure recognizes the sender and your agent already knows them.

hey, what's on my calendar tomorrow?
Happy to check. Connect your profile first:
sign-in.me/your-agent
phone verifiedagent approvedgmailcalendaragent token
Recognizeauth.recognizePhone()

The sender's verified phone resolves to their profile and a fresh agent token.

Personalizeprofile → model context

Your agent loads the full profile before it replies. First answer, already personal.

This is the recommended pattern for SMS agents, iMessage agents, and any message surface where the user can tap a browser link but will keep talking in the original thread.

The Loop

A message agent runs the same five steps on every inbound message:

  1. Resolve the sender. Use a stored token, phone recognition, or a developer-scoped externalId.
  2. Offer sign-in when it helps. Send one hosted link. Never build the URL yourself.
  3. Give the model Configure. Attach the hosted Configure MCP server to the model call. The model reads the profile, searches, remembers, and offers sign-in as tools. This is the pattern our reference agent Luke runs on.
  4. Pre-load the profile. So the first reply already knows the user, read the profile before the model runs: the model reads it as its first tool, or you place it from code.
  5. Keep the token fresh. Refresh through recognition when a call fails with 401 or 403.

Steps 1, 2, and 5 are the sign-in plumbing (SDK/REST). Step 3 is the MCP pattern: the default, and where the personalization comes from. The rest of this page walks each one.

Install

The examples on this page use the Configure TypeScript SDK:

bash
npm install configure
ts
import { Configure } from "configure";

const configure = new Configure({
  apiKey: process.env.CONFIGURE_API_KEY!,   // sk_… stays server-side
  agent: "your-agent",
});

Bring your own model runtime and message provider. The page shows the Configure calls; your webhook framework and model API stay yours.

Send One URL

The agent API returns a hosted URL with every agent:

json
{
  "name": "your-agent",
  "sign_in_url": "https://sign-in.me/your-agent"
}

Send sign_in_url unchanged. Configure resolves the agent, browser credential, branding, phone verification, consent, and connectors on its own origin. Multiple Gmail accounts work by default. You do not enable a rollout flag, expose a publishable key, or build query parameters.

The TypeScript SDK returns the same URL:

ts
const url = configure.auth.signInUrl();
await message.reply(`Connect your profile: ${url}`);

Existing signInUrl(options) calls remain supported. New integrations should start with the zero-argument form.

When to offer it. Send the link when the sender's request needs personal data or a connected app and they are not linked. Offer once per thread. An unlinked sender still gets a working agent. Re-offer when the user asks. The model can also re-offer through configure_connect when it hits an authorization challenge.

Choose The Return

Configure supports two completion behaviors:

  • message ends with a button that returns the user to the agent's message line.
  • redirect sends the user back to the agent's HTTPS URL. Configure OAuth uses its registered OAuth callback instead.

Set this server-side in the agent's hosted metadata document, then register its HTTPS URL as hosted_metadata_url:

json
{
  "delivery": "message",
  "valueProposition": "Describe the value your agent provides.",
  "messageLinePhone": "+14155550123",
  "messageBody": "Send news!"
}

For browser delivery, use:

json
{
  "delivery": "redirect",
  "agentUrl": "https://your-app.example/"
}

Field rules:

  • displayName: shown as "{displayName} uses Configure"; defaults to the agent record's display name, then a humanized slug.
  • logoUrl: https only; defaults to the agent record's logo, then a letter mark.
  • delivery: message or redirect. The default is message, so existing message agents do not need metadata changes.
  • valueProposition: optional plain-text value statement for the hosted page. Configure normalizes it and limits it to 120 characters.
  • messageLinePhone, messageBody: the optional E.164 return line and suggested message for message delivery. Without a number, the completion screen tells the user to return to Messages.
  • agentUrl: the HTTPS destination for redirect delivery.

Configure fetches this document server-side and caches it for 60 seconds. Callers cannot override these identity or delivery fields through the hosted URL.

Recognize Returning Senders

After a user signs in once, Configure can recognize them by phone on every later message. For Apple-ID/iCloud iMessage senders represented by an email handle, it can also recognize an exact match to the user's OAuth-verified Gmail or Outlook credential. It does not match inferred or user-typed profile email. Recognition lets your agent know a returning sender before it writes one word.

ts
const recognition = await configure.auth.recognizePhone([senderPhone]);

if (recognition.approved && recognition.token) {
  await store.save(subjectKey, {
    configureToken: recognition.token,
    configureUserId: recognition.userId,
  });
}

The response tells you where the sender stands:

FieldMeaning
matchedA Configure user has this verified phone.
recognizedThe sender is a known person.
approvedThe user has approved this agent. Configure mints a token only then.
tokenA fresh agent-scoped token for this user. Configure returns it only when approved is true.
displayNameThe user's name, when available. Safe to greet with.

Two rules keep this safe:

  • Recognition is not authorization. A matched phone never exposes profile data. The user must approve your agent on the hosted page first. Until then, approved stays false and Configure returns no token.
  • Normalize before you send. Pass the sender's number as close to E.164 as your provider gives you. A formatted number like +1 (415) 555-0123 must reach Configure as a phone number, not as decoration.

Collect candidates from whatever phone-like fields your provider exposes. Configure normalizes tel:, sms:, imessage: prefixes and ignores non-phone IDs:

ts
function phoneCandidatesFromMessage(message: InboundMessage): string[] {
  return [message.sender?.phone, message.sender?.address, message.sender?.id]
    .filter((v): v is string => Boolean(v));
}

Keep Tokens Fresh

Agent tokens expire, and Configure re-checks approval when a token is used. Revoked access stops working before expiry. Your server stores a token per sender and refreshes it through recognition, never by hand.

The rule: when any Configure call fails with 401 or 403, re-recognize the sender. Retry once with the fresh token. Store the fresh token.

ts
import { ConfigureError } from "configure";

const isAuthError = (e: unknown): boolean =>
  e instanceof ConfigureError && (e.statusCode === 401 || e.statusCode === 403);

async function withFreshToken<T>(
  subjectKey: string,
  phone: string,
  call: (token: string) => Promise<T>
): Promise<T> {
  const stored = await store.get(subjectKey);
  try {
    return await call(stored.configureToken);
  } catch (err) {
    if (!isAuthError(err)) throw err;            // 401 or 403 only
    const rec = await configure.auth.recognizePhone([phone]);
    if (!rec.approved || !rec.token) throw err;  // user revoked; surface it
    await store.save(subjectKey, { configureToken: rec.token, configureUserId: rec.userId });
    return await call(rec.token);                // retry once, never loop
  }
}

Cover every path that uses the token: profile reads, searches, connector calls, and the MCP bridge. A refresh path that covers tools but not the profile read fails silently in production.

An auth failure is not an empty profile

Never show "no data on file" because a read returned 401. A stale token and a missing profile are different facts. Log auth failures separately from empty results. Refresh and retry. If the retry fails, say the lookup failed. Do not say the data does not exist.

Use validateSignInToken(token) when you load a stored token you do not fully trust: after a restart, after a completion webhook, or on first use in a process.

Give The Model Configure: The MCP Pattern

This is the heart of the integration, and the pattern our reference agent (Luke) runs on: the model talks to a hosted Configure MCP server. You attach one server to the model call, and the model gets the profile, search, memory, connectors, and sign-in as tools. Configure resolves the user and enforces permissions on every call, so the model never handles a key or a phone number.

MCP is the default; the SDK is for two jobs

Attach the hosted MCP server and the model has Configure. Use the SDK for the sign-in plumbing above, and for placing the profile deterministically from your own code (next section). You do not need it for the tools themselves.

Attach the server for every sender, linked or not. A linked sender's model gets the profile and connector tools. An unlinked sender's model still gets configure_connect, so it can offer sign-in in its own voice.

The endpoint is https://api.configure.dev/mcp. Attach it with any MCP client that can POST JSON-RPC with custom headers. Authenticate each request with your developer key, your agent, and the sender's identity:

ts
// Linked sender: the agent token carries the user. The three X-Configure-*
// headers enable profile reads: identify your session and scope with stable
// IDs of your choosing, and declare commit support.
headers: {
  "X-API-Key": process.env.CONFIGURE_API_KEY,
  "X-Agent": "your-agent",
  "Authorization": `Bearer ${token}`,
  "X-Configure-Session-Id": conversationId,
  "X-Configure-Runtime-Scope-Id": "your-agent-runtime",
  "X-Configure-MCP-Commit-Supported": "1",
}

// Unlinked sender: a stable external id keeps memory in your developer scope.
headers: {
  "X-API-Key": process.env.CONFIGURE_API_KEY,
  "X-Agent": "your-agent",
  "X-User-Id": `imessage:${subjectKey}`,
}

Profile reads require the commit contract

Without the three X-Configure-* headers, linked configure_profile_read and configure_profile_search calls fail with mcp_commit_plumbing_required. Declaring commit support means your model must honor configure_profile_commit when a read result asks for it; the tool result carries the instruction. The MCP adapter owns this plumbing for you.

The model gets configure_profile_read, configure_profile_search, configure_profile_remember, configure_connect, and connector tools when the sender is linked. A call to an app the user has not connected fails closed and returns a connect link. See the MCP adapter guide for the full tool list.

Use the same externalId here that you use in resolveMessageIdentity. When the user later signs in, the identity stays consistent.

configure_connect mints links inside the conversation: the sign-in link for an unlinked sender, and the connect-an-app link for a linked one. Your model includes the returned URL in a normal reply. Two rules for your prompt:

  • When a profile tool returns an authorization challenge, the user is not signed in. Offer the configure_connect link. Do not retry the read.
  • Never let the model build a Configure URL by hand. Only configure_connect and the SDK mint them.

Read The Profile Before Every Turn

The MCP tools let the model fetch the profile mid-turn. But the first reply should already know the user, so the profile must be in context before the model runs. Every linked turn. There are two ways to do it, and both are correct:

  • Let the model read it (pure MCP). The model calls configure_profile_read as its first tool. This is the simplest way: the model owns the timing, and the pre-turn read is just its opening move.
  • Place it from code (deterministic). Read it yourself with the SDK and inject it, so the context is loaded before the model's first token. Use this when you want the read to always happen and to control exactly where it lands:
ts
const { profile } = await withFreshToken(subjectKey, phone, (token) =>
  configure.profile({ token }).read()
);
const system = `${AGENT_STYLE}\n\nWhat Configure knows about this user:\n${JSON.stringify(profile)}`;

Either way, four rules apply, each learned from a production failure:

Read on every linked turn

Do not guess which messages "look personal." A keyword gate will miss "where do I go in the evenings", and the answer was in the profile.

Serve the full context

Do not truncate the profile to save tokens. A 500-character cap once cut the exact fact the user asked about. Configure already bounds the read.

Same tools in every variant

If you A/B test personalization, vary the injected context only. Never strip Configure tools from one arm. That arm becomes an agent that denies data it has.

Search before you deny

The injected context is a summary, not the whole profile. Tell your model: use configure_profile_search before saying anything is missing.

Run the read concurrently with the rest of your turn setup. It makes the first sentence of every reply personal.

Resolve Identity

Give each sender a stable app-local externalId. Use a stored Configure token when one exists; otherwise the user continues as a developer-scoped user until they link.

ts
const subjectKey = message.sender?.id || thread.id;
const stored = await store.get(subjectKey);

const identity = await configure.auth.resolveMessageIdentity({
  externalId: `imessage:${subjectKey}`,
  token: stored?.configureToken,
  phoneCandidates: phoneCandidatesFromMessage(message),
});

The result tells you which path you are on:

ts
interface MessageIdentity {
  externalId: string;          // always present; your stable fallback
  token?: string;              // present only when linked
  userId?: string;
  linked: boolean;             // approved token in hand
  approved: boolean;
  recognized: boolean;         // true when this token came from phone recognition
  displayName?: string | null;
  source: "token" | "phone_recognition" | "external_id";
}

Use validateToken: true after webhook completion, after a restart, or when loading an untrusted stored token.

Advanced Message Binding

Use createMessageSignInUrl() only when your server needs a completion webhook tied to a specific thread. It returns a URL your application sends unchanged. Provider-signed sender proof (messageSenderProof) is accepted but not yet verified: the endpoint currently returns a plain sign-in link (mode: "plain", fallbackReason: "sender_proof_unsupported"), so do not depend on server-side sender binding today.

ts
const agentPhone = await messageProvider.currentPhone();

await configure.auth.registerMessageLine({
  channel: "imessage",
  phone: agentPhone,
  label: "Primary iMessage line",
  metadata: { provider: "your-message-provider" },
});

const handoff = await configure.auth.createMessageSignInUrl({
  reason: "signin",
  channel: "imessage",
  subject: { key: subjectKey, externalId: `imessage:${subjectKey}` },
  thread: { key: thread.id, spaceId: thread.id, messageId: message.id },
  messageSenderProof: providerSignedMessageSenderProof,
  agentPhone,
  idempotencyKey: `${thread.id}:${message.id}:signin`,
});

await message.reply(`Connect your profile: ${handoff.url}`);

channel is a stable lowercase identifier, such as imessage, sms, or whatsapp. createMessageSignInUrl() requires it. registerMessageLine() defaults to imessage when you omit it.

Today the response is always mode: "plain" with the standard hosted link. Supplied sender proof is fallbackReason: "sender_proof_unsupported"; absent proof is "sender_proof_missing". The SDK types also define mode: "minted" for code-bearing links, so handle both modes, but do not depend on minted links or server-side sender binding today.

When a message user needs to refresh connected apps, send them back through the hosted message flow with /reconnect after the agent handle:

txt
https://sign-in.me/{agent}/reconnect

For example:

txt
https://sign-in.me/your-agent/reconnect

This resolves to the same agent-scoped hosted flow as sign-in.me/{agent}. Signed-in users can review their current connection state without losing the message-agent defaults like logo, return instructions, and copy mode. Use this instead of sending message users to profile.configure.dev, which manages the user's global profile and does not carry agent-specific message context.

Completion Webhook

To notify your message server when hosted sign-in finishes, generate a journeyId and map it back to the message space.

Add a root-relative messageCompletePath to your registered hosted metadata document:

json
{
  "messageCompletePath": "/auth/configure/complete"
}

Configure resolves this path against the registered metadata origin. Hosted completion ignores caller-supplied completion URL overrides.

ts
const journeyId = crypto.randomUUID();
await store.saveJourney(journeyId, { spaceId: thread.id });
const subjectKey = message.sender?.id || thread.id;
const agentPhone = await messageProvider.currentPhone();

await configure.auth.registerMessageLine({
  channel: "imessage",
  phone: agentPhone,
  label: "Primary iMessage line",
  metadata: { provider: "your-message-provider" },
});

const handoff = await configure.auth.createMessageSignInUrl({
  reason: "signin",
  channel: "imessage",
  subject: {
    key: subjectKey,
    externalId: `imessage:${subjectKey}`,
    senderId: message.sender?.id,
  },
  thread: {
    key: thread.id,
    spaceId: thread.id,
    messageId: message.id,
  },
  messageSenderProof: providerSignedMessageSenderProof,
  journeyId,
  agentPhone,
  idempotencyKey: `${thread.id}:${message.id}:signin`,
});

Use handoff.url as the link you send in the message thread. Use agentPhone for the agent's current provider-owned return line, and register it before URL creation. Provider-owned lines can change, so resolve the number from the provider when you create the URL rather than storing a long-lived phone number in docs, prompts, or model instructions.

Hosted completion posts the agent token to your endpoint:

json
{
  "token": "eyJ...",
  "userId": "00000000-0000-0000-0000-000000000000",
  "agent": "your-agent",
  "journeyId": "...",
  "authJourneyId": "...",
  "completionId": "...",
  "connectedTool": "gmail",
  "connectorConnected": true
}

Validate the token before storing it. Treat completionId as an idempotency key so retries cannot send two confirmation messages:

ts
app.post("/auth/configure/complete", async (req, res) => {
  const { token, journeyId, completionId } = req.body;
  const validation = await configure.auth.validateSignInToken(token);

  if (!validation.valid) {
    return res.status(401).json({ ok: false, linked: false });
  }

  if (completionId && await store.hasCompletion(completionId)) {
    return res.json({ ok: true, linked: true });
  }

  const journey = await store.consumeJourney(journeyId);
  if (!journey) {
    return res.status(404).json({ ok: false, linked: false });
  }

  await store.save(journey.spaceId, {
    configureToken: token,
    configureUserId: validation.userId,
  });
  if (completionId) await store.saveCompletion(completionId);

  res.json({ ok: true, linked: true });
});

Accept completion requests only on the path declared by your hosted metadata. Validate the agent token and journey before trusting connector fields.

The stored token is the agent's MCP credential: attach it to https://mcp.configure.dev and the configure_* profile and connector tools surface to your model by name.

Better Auth

If your app already uses Better Auth, keep Better Auth as the app session owner and link Configure to that session.

Use this when Configure is an account connection inside an existing Better Auth app.

ts
// GET /api/configure/sign-in
export async function GET() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return new Response("unauthorized", { status: 401 });

  const state = crypto.randomUUID();
  await saveConfigureState(state, session.user.id);

  const url = configure.auth.signInUrl({
    publishableKey: process.env.CONFIGURE_PUBLISHABLE_KEY!,
    displayName: "Your App",
    returnTo: `${process.env.APP_URL}/api/configure/callback`,
    state,
  });

  return Response.redirect(url);
}

Register the callback once from your server:

ts
await configure.auth.allowSignInReturnTo(
  `${process.env.APP_URL}/api/configure/callback`
);

Then exchange the short-lived code with your sk_ key and store the Configure token against the Better Auth user:

ts
// GET /api/configure/callback?code=cfgsic_...&state=...
export async function GET(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  if (!code || !state) return new Response("bad request", { status: 400 });

  const userId = await consumeConfigureState(state);
  if (!userId) return new Response("invalid state", { status: 400 });

  const linked = await configure.auth.exchangeSignInCode(code);
  await db.configureAccount.upsert({
    userId,
    configureUserId: linked.userId,
    configureToken: linked.token,
  });

  return Response.redirect("/settings/integrations");
}

OAuth Provider

Use this when Configure should appear as a first-class Better Auth OAuth provider. Better Auth's Generic OAuth plugin supports custom OAuth providers plus custom token and user-info hooks, so you can point it at Configure OAuth.

The Configure helper sets the pieces Better Auth needs for this provider: PKCE, client_secret_basic, the Configure API resource, and a server-side identity lookup through the Configure SDK.

This path needs an OAuth client, which the keys screen after signing in at configure.dev/login issues, or npx configure setup --users writes to .env: see Get your credentials. Register the callback Better Auth serves, usually https://yourapp.com/api/auth/oauth2/callback/configure, with npx configure add origin <url>; it must match exactly.

ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { configureBetterAuthOAuthProvider } from "configure";

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        configureBetterAuthOAuthProvider({
          clientId: process.env.CONFIGURE_OAUTH_CLIENT_ID!,
          clientSecret: process.env.CONFIGURE_OAUTH_CLIENT_SECRET!,
          apiKey: process.env.CONFIGURE_API_KEY!,
          agent: process.env.CONFIGURE_AGENT!,
        }),
      ],
    }),
  ],
});

If your Better Auth schema requires an email for every user, add your own mapProfileToUser wrapper and decide how your app handles Configure profiles that only have phone identity.

Use Better Auth OAuth when Configure is a login choice. Use the account-link flow when the user is already signed into your app and is connecting Configure for personalization.

SDK Helpers

MethodUse
auth.createMessageSignInUrl(options)Create message-aware sign-in, reconnect, or permission-review URLs on a trusted server.
auth.signInUrl(options?)Return the canonical https://sign-in.me/{agent} URL. Options are supported for backward-compatible or advanced handoffs.
auth.resolveMessageIdentity(options)Prefer a stored token, recognize phone candidates, then fall back to externalId.
auth.recognizePhone(candidates)Server-side phone recognition for approved agents.
auth.validateSignInToken(token)Check a stored or callback token still belongs to this agent.
auth.exchangeSignInCode(code)Exchange hosted return-code callbacks for an agent token.
auth.allowSignInReturnTo(returnTo)Allowlist a web callback or deep link for hosted return codes.
configureBetterAuthOAuthProvider(options)Build a Better Auth Generic OAuth provider config with Configure PKCE, resource, and user-info defaults.

sk_ keys are always server-side. pk_ keys are only for hosted Configure UI.

Personalization infrastructure for agents