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, and your server stores the returned agent-scoped token.

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

Install

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

export const configure = new Configure({
  apiKey: process.env.CONFIGURE_API_KEY!,
  agent: process.env.CONFIGURE_AGENT!,
});

Keep CONFIGURE_API_KEY as an sk_ key on the server. Use CONFIGURE_PUBLISHABLE_KEY only when building hosted URLs.

The Pattern

  1. Give every message sender a stable app-local externalId.
  2. If you have a stored Configure token, use it.
  3. If the platform exposes phone-like sender metadata, ask Configure to recognize it.
  4. If neither exists, continue with externalId.
  5. When the user asks to connect, send a sign-in.me URL.
ts
const subjectKey = message.sender?.id || space.id;
const stored = await store.get(subjectKey);

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

if (identity.token && identity.token !== stored?.configureToken) {
  await store.save(subjectKey, {
    configureToken: identity.token,
    configureUserId: identity.userId,
  });
}

resolveMessageIdentity() returns either a linked token or the same externalId fallback. That means the rest of your app can use one identity result without caring whether the user has linked yet.

For the hot path, stored tokens are trusted by default. Use validateToken: true when loading a token from durable storage, after webhook completion, or on the first turn after a deploy/restart.

For the simplest message-agent flow, no callback endpoint is required. The user completes hosted auth, returns to the message app, and the next inbound message can be recognized by phone or use the token you already stored.

ts
const signInUrl = configure.auth.signInUrl({
  publishableKey: process.env.CONFIGURE_PUBLISHABLE_KEY!,
  delivery: "message",
  displayName: "Your Agent",
  messageLinePhone: process.env.AGENT_PHONE_NUMBER,
  messageBody: "done",
  connectors: ["gmail", "calendar"],
});

await message.reply(text(`Connect your profile: ${signInUrl}`));

The URL is hosted at:

txt
https://sign-in.me/{agent}?pk=pk_...&delivery=message

sign-in.me resolves the agent from the path. Do not add agent from request body, message text, or any caller-controlled field.

Photon/Spectrum

Spectrum gives you an async stream of [space, message] pairs. Configure only needs a stable externalId, optional phone candidates, and a hosted URL when the user asks to connect.

ts
import { Configure } from "configure";
import { Spectrum, text } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers/imessage";

const configure = new Configure({
  apiKey: process.env.CONFIGURE_API_KEY!,
  agent: process.env.CONFIGURE_AGENT!,
});

const app = await Spectrum({
  projectId: process.env.PHOTON_PROJECT_ID!,
  projectSecret: process.env.PHOTON_PROJECT_SECRET!,
  providers: [imessage.config()],
});

const wantsConnect = /\b(connect|link|sign[\s-]?in|log[\s-]?in|login)\b/i;

function phoneCandidatesFromSpectrum(
  space: { phone?: string },
  message: { sender?: { id?: string; address?: string; phone?: string } }
): string[] {
  return [
    message.sender?.phone,
    message.sender?.address,
    message.sender?.id,
    space.phone,
  ].filter((value): value is string => Boolean(value?.trim()));
}

for await (const [space, message] of app.messages) {
  if (message.content.type !== "text") continue;

  const subjectKey = message.sender?.id || space.id;
  const saved = await store.get(subjectKey);
  const identity = await configure.auth.resolveMessageIdentity({
    externalId: `spectrum:${subjectKey}`,
    token: saved?.configureToken,
    phoneCandidates: phoneCandidatesFromSpectrum(space, message),
  });

  if (identity.token && identity.token !== saved?.configureToken) {
    await store.save(subjectKey, {
      configureToken: identity.token,
      configureUserId: identity.userId,
    });
  }

  if (wantsConnect.test(message.content.text)) {
    const url = configure.auth.signInUrl({
      publishableKey: process.env.CONFIGURE_PUBLISHABLE_KEY!,
      delivery: "message",
      displayName: "Your Agent",
      messageLinePhone: process.env.AGENT_PHONE_NUMBER,
      messageBody: "done",
      connectors: ["gmail", "calendar"],
    });

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

  await runAgentTurn({ message, identity });
}

The Luke prototype used the right identity shape: a stored token when linked, and a stable app-local external ID before linking. sign-in.me moves the verification and consent UX out of the message transcript so developers do not have to build OTP state machines in every agent.

Completion Webhook

If you want the browser flow to notify your message server immediately, generate a journeyId, include a messageCompleteUrl, and map that journey back to the message space.

ts
const journeyId = crypto.randomUUID();
await store.saveJourney(journeyId, { spaceId: space.id });

const url = configure.auth.signInUrl({
  publishableKey: process.env.CONFIGURE_PUBLISHABLE_KEY!,
  delivery: "message",
  displayName: "Your Agent",
  journeyId,
  messageCompleteUrl: "https://agent.example.com/auth/configure/complete",
  messageLinePhone: process.env.AGENT_PHONE_NUMBER,
});

Hosted completion posts the agent token to your endpoint:

json
{
  "token": "eyJ...",
  "userId": "00000000-0000-0000-0000-000000000000",
  "agent": "your-agent",
  "journeyId": "..."
}

Validate the token before storing it:

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

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

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

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

Accept completion requests only from your own hosted route. sign-in.me will call same-origin, https:, or localhost development endpoints.

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 Continue with Configure.

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.

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.signInUrl(options)Build https://sign-in.me/{agent} or a custom hosted sign-in URL.
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