Skip to content

Sign users in over SMS or voice

A text thread has no screen, so there is no card to mount and no host script to load. Everything here is server-side calls and the text you send back.

The loop is the one from the Quickstart, unchanged: read the profile before the turn, put it in the system prompt, commit the turn when it ends. A text thread breaks three things, and there is a purpose-built call for each.

What breaksThe call
No button to renderauth.createMessageSignInUrl() returns the URL to text
No session to key onauth.resolveMessageIdentity() returns the identity for the turn
A stranger may already have an accountauth.recognizePhone() says so before you ask them to sign in

Every inbound message runs the same five steps, in this order:

  1. Decide the message is a turn at all: a direct thread, real text from the user, not a redelivery.
  2. auth.resolveMessageIdentity() to get the identity, and auth.recognizePhone() when you do not know the sender yet.
  3. Read their context, with a time budget and a fallback.
  4. Answer, and send auth.createMessageSignInUrl() in the reply when they still need to sign in.
  5. Commit the turn after the reply is out.

Do not hand-build any of these URLs. Everything else on this page is the main path with a phone number where the account id usually goes.

Set up the client

Server-side, once, with your secret key. It never reaches a handset.

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

const configure = new Configure({
  apiKey: process.env.CONFIGURE_API_KEY,   // sk_..., server only
  agent: process.env.CONFIGURE_AGENT,      // your agent handle
});

Over raw HTTP those two are the X-API-Key and X-Agent headers on every request. See the API reference.

Register the line you text from

Once per line, at deploy time. Configure reflects the line in the sign-in page so the user lands back in the thread they started, and it refuses to reflect a line you have not proved you own.

ts
await configure.auth.registerMessageLine({
  phone: "+14155550100",        // E.164, the number your agent sends from
  channel: "imessage",
  label: "support line",
});

Configure stores a hash and the last four digits, not the number.

channel is your own lowercase identifier for the surface, such as sms, imessage or whatsapp. It defaults to imessage. Pick one string per surface and use it in every later call, because a line is registered per channel: ask for a URL on imessage using a line you registered on sms and you get ACCESS_DENIED with messageLinePhone is not registered for this agent, which names the phone rather than the mismatch that caused it. The same error is what an unregistered line returns.

Resolve the sender once per turn

One call turns a phone number into the identity for this turn. Pass the result straight to configure.profile(). Linked senders use their token, everyone else falls back to the phone as an external id.

ts
const identity = await configure.auth.resolveMessageIdentity({
  externalId: user.phone,           // E.164
  token: stored.token,              // whatever you saved last time, or omit
  validateToken: true,              // check it with Configure instead of trusting it
});

const profile = configure.profile(identity);
// -> { externalId, token?, linked, approved, recognized, source }

linked is the flag, but only when you let Configure decide it. Pass validateToken: true and the call checks the stored token and answers from Configure; leave it off, which is the default, and a supplied token short-circuits the call with linked: true without touching the network. That is your own copy wearing Configure's name, and a revoked or expired token still reports linked. Verified against a stale token: the default answers linked: true, validateToken: true answers linked: false.

Normalize the handle before you pass it. Configure matches an external id literally, so +15551234567 and 15551234567 are two different people with two different profiles, and the same human texting from a client that formats the number differently silently becomes a stranger. Trim it, put it in E.164, and use that one form everywhere.

An unlinked sender still has a working profile. read(), commit() and remember() all work against the phone as an external id from the first message, before anyone signs in. What linking adds is the user's wider profile and their connected apps. Build the thread so it is useful on message one and better after they connect.

Send one URL

Ask Configure for the URL rather than minting a generic connect link. It returns a permanent page for the common case and a code-bearing link when it can prove the sender.

ts
const link = await configure.auth.createMessageSignInUrl({
  reason: "signin",                       // or "reconnect", "permissions"
  channel: "imessage",
  subject: { key: "phone", externalId: user.phone },
  messageLinePhone: "+14155550100",       // the registered line
});

await sendMessage(user.phone, `Sign in here: ${link.url}`);

Read link.mode if you care which you got:

modeWhat it isLifetime
plainYour agent's permanent page, https://sign-in.me/<agent>Never expires
mintedA one-time code-bearing link, with code and expiresAt15 minutes

Send link.url either way. This is why a text thread should not carry a generic minted connect link: a text lives in the thread forever and a minted link does not, so a user who scrolls back a day later taps a dead URL. The permanent page mints at the moment they open it instead.

Format the link as bare text. Do not wrap it in markdown, and do not add a scheme. Message clients linkify a bare domain and render [text](url) literally.

Recognize returning senders

Before you ask a stranger to sign in, ask whether they are one. A number that already has a Configure account and has already approved your agent comes back approved, with a token.

ts
const seen = await configure.auth.recognizePhone([user.phone, user.appleId]);
// { matched, recognized, approved, linked, token?, displayName?, phoneCandidateCount }

if (seen.approved && seen.token) {
  // returning user, no link needed
}

Pass every handle you have for the sender, not the phone alone. It takes one string or an array, and message clients hand you an email-form handle instead of a number often enough that a phone-only lookup misses returning users. A sender with no phone number cannot be bound by the permanent page, so give them a subject with the handle you do have and let Configure decide what the link needs to carry.

matched says the number is known to Configure. approved says this user has approved your agent before, and only then do you get a token. A matched sender who has never approved you still has to sign in, so treat recognition as a greeting, never as consent.

Decide what counts as a turn

"Commit every message" needs a definition of message, and a real channel delivers more than messages.

Answer direct threads only. A group thread has several people in it and one profile cannot represent them. Replying there puts one person's context in front of the others, and committing the thread files everyone's messages into that one profile. Drop non-direct threads at the webhook before any Configure call.

Drop what is not a turn. Reactions, read receipts, typing indicators, attachments that carry no text, and your own outbound messages echoed back all arrive on the same webhook. None of them is a turn to answer or commit.

Expect the same message twice. Message webhooks are at-least-once and arrive out of order. Record the inbound message id and drop a repeat before you spend a model call on it, because commit() takes no idempotency key and a redelivered turn is committed twice.

ts
if (msg.threadType !== "direct") return respond(204);
if (!isUserText(msg)) return respond(204);
if (!(await claimMessageId(msg.id))) return respond(204);   // first delivery wins

Decide what the turn is allowed to say

A text surface has four states, not two, and the fourth is the one that produces the worst message.

StateWhat the turn does
LinkedAnswer. Do not call a profile tool to re-check linking.
Recognized, not approvedAnswer, and offer the link once.
Not linkedAnswer, and offer the link once.
Read failedAnswer. Claim nothing about their account and send no link.

Never derive "please sign in" from a failed read. A timeout tells you about your network, not about the user, and asking a signed-in user to sign in again is the failure they remember. Offer the link when they ask for it, when a connector call fails, or on their first message, and then stop offering it every turn.

Keep context on the reply path

The read happens while a human waits on a text, so it gets a budget and a fallback rather than a retry.

ts
const A_DAY = 24 * 60 * 60 * 1000;
const NOT_CONNECTED = 5 * 60 * 1000;

export async function contextFor(phone: string, profile: ProfileRuntime) {
  const cached = await store.get(phone);
  // Senders who have not connected are most of them, so cache that answer too.
  // Cache only the hit and every message from every one of them is a live read
  // on the reply path, with the sender waiting on it.
  const ttl = cached?.text ? A_DAY : NOT_CONNECTED;
  if (cached && Date.now() - cached.refreshedAt < ttl) return cached.text;

  const read = await Promise.race([
    profile.read({ sections: ["identity", "preferences", "summary", "integrations"] }),
    new Promise((resolve) => setTimeout(() => resolve(null), 1_200)),
  ]);

  if (!read) return cached?.text ?? null;                    // timed out: stale beats nothing
  if (!read.profile.linked) {
    await store.set(phone, { text: null, refreshedAt: Date.now() });
    return null;
  }
  const text = read.profile.format();
  await store.set(phone, { text, refreshedAt: Date.now() });
  return text;
}

Serve the last copy when the read is slow or fails. A day-old profile makes a better reply than no profile, and the refresh runs again on the next message. Use format() rather than assembling the text yourself: it applies the sections the user chose to hide, and hand-assembled prompts reintroduce data the user asked you to drop.

Write the turn back

Two writes, and which one you use depends on what the user said.

ts
if (explicitFact) {
  const saved = await profile.remember(explicitFact);   // "remember that I..."
  if (!saved.saved) { /* rejected as a duplicate or as unusable */ }
} else {
  await profile.commit({ messages: turnMessages });     // everything else
}

commit() sends the turn and Configure distills it, so it is safe to call on every message. remember() writes one fact verbatim, so use it only when the user asked you to remember something.

Check saved on the response. A remember() call resolves rather than throws when the fact is dropped, and it is dropped when it repeats something already stored. An exact repeat comes back with reason: "exact_hash", and a rewording of a stored fact comes back near_duplicate. reason is on the response but not yet on the RememberResponse type, so in TypeScript read it off the value rather than expecting autocomplete. Configure does that comparison for you, so do not search before writing: a search on the reply path makes the sender wait to reach the same result. When the fact is stored, the id you need for forget() is on saved.memory.id.

Commit after the reply is sent, not before it. A memory write must never sit between the user's message and your answer.

Commit every turn rather than the turns that look important. Configure decides what is worth keeping, and it can only judge what it receives, so a turn you filter out is invisible to the profile permanently. A classifier in front of commit() is the quiet way a text thread stops teaching the profile anything.

Give the model the tools

Same as anywhere else. Mint a session per user and hand mcp_servers to your model call, so the agent can search the profile and reach connected apps mid-conversation.

ts
const session = await profile.mcpSession();
// session.mcp_servers -> pass to the model provider

Tell the model what the profile is

Profile text is dated evidence, not current truth, and on a text thread the user will correct it out loud. Say so in the system prompt.

Treat profile facts as dated evidence, not current truth. Location, travel,
timezone and current role change often. If the sender corrects a stored fact,
accept the correction and do not defend the stored claim.

The profile is also user data rather than instructions. Fence it the way the Trust page shows before you put it in a prompt.

When something goes wrong

The failure table in the Quickstart applies unchanged. Two behave differently on a text surface.

authorization_required has no popup to open. Send the URL from createMessageSignInUrl() with reason: "reconnect" and answer the parts of the message that do not need the connector.

401 on a read means the stored token expired, not that the user left. Resolve again with validateToken: true, or drop the token and pass the sender's handles so the call re-recognizes them, then retry the read once. Resolving again with the same stored token and no validateToken is a loop: it returns the same expired token without asking Configure anything. Never turn a 401 into a sign-in request on its own.

Right after someone connects, their apps are still syncing, so a connector call can fail on the very next message. Answer without the connector and try again on the following turn rather than sending another link to someone who has already used one.

rate_limited returns 429 with a retry_after. URL creation shares the account limit, so back off and resend the last URL you sent that sender. A permanent plain URL is always safe to resend.

Personalization infrastructure for agents