Skip to content

Sign users in over SMS or voice ​

SMS and voice agents cannot display an embedded connection interface. Send the user a sign-in URL and manage identity and profile operations on your server.

Follow the Quickstart for context reads, prompt injection, and conversation commits. Message channels require different methods for sign-in links, sender identity, and account recognition:

RequirementMethod
Send a sign-in URLauth.createMessageSignInUrl() returns the URL to text
Resolve the senderauth.resolveMessageIdentity() returns the identity for the turn
Recognize an existing accountauth.recognizePhone() says so before you ask them to sign in

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

  1. Decide whether the message is a turn. A turn is a new text message from the user in a direct conversation. Exclude duplicate deliveries.
  2. Call auth.resolveMessageIdentity() to get the identity. Call auth.recognizePhone() if you do not know the sender yet.
  3. Read the user profile with a timeout and a fallback.
  4. Answer. If they still need to sign in, send the URL from auth.createMessageSignInUrl() in the reply.
  5. Commit the turn after sending the response.

Do not build any of these URLs by hand. The rest of this page is the main path, with a phone number where the account ID usually goes.

Set up the client ​

Create the client once, on your server, with your secret key. Keep the key on the server.

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, send these two values as the X-API-Key and X-Agent headers on every request. See the API reference.

Register the line you text from ​

Register each sender number at deployment. The hosted flow uses the registered number to return the user to the conversation. Configure rejects numbers whose ownership has not been verified.

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.

Sender number registration is channel-specific. If a number is registered on sms but requested on imessage, Configure returns ACCESS_DENIED with messageLinePhone is not registered for this agent. The same error is returned for an unregistered number.

Resolve the sender once per turn ​

Resolve the sender identity once per turn and pass the result to configure.profile(). Linked senders use a user token. Unlinked senders use their phone number 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 }

Validate the stored token before relying on linked. With validateToken: true, the call checks the stored token with Configure. Without it (the default), a supplied token short-circuits the call. It returns linked: true without touching the network, even for a revoked or expired token. For an expired token, the default can report linked: true; validation reports linked: false.

Normalize the handle before you pass it. Configure matches an external ID literally. +15551234567 and 15551234567 resolve to different profiles. Inconsistent formatting can create a separate profile for the same sender. Trim the handle, 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 with the phone as an external ID from the first message, before anyone signs in. Linking adds the user's wider profile and their connected apps. Your agent can use developer-scoped context before sign-in and approved cross-agent context after linking.

Send one URL ​

Ask Configure for the URL instead of creating a generic connect link. It returns a permanent page in the common case. It returns a code-bearing link when it can prove who the sender is.

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 to determine the URL type:

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 for either mode. Do not send a generic expiring connection URL in a text thread. Users may open old messages after the URL expires. The permanent sign-in page creates a connection URL when opened.

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 requesting sign-in, check whether the sender has a Configure account. If that account has already approved your agent, the response includes approval status and 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 only the phone. The call takes one string or an array. Message clients often give you an email-form handle instead of a number, so a phone-only lookup misses returning users.

The permanent page cannot bind a sender with no phone number. For that sender, pass a subject with the handle you have, and let Configure decide what the link needs to carry.

matched indicates that Configure found the number. approved means this user has approved your agent before. You get a token only when approved is true. A matched sender who never approved you still has to sign in. Account recognition does not grant agent approval.

Decide what counts as a turn ​

Message webhooks also deliver non-message events and duplicates. Filter those events before generating a response or committing a turn.

Answer direct threads only. A group thread has several people in it, and one profile cannot represent them. A reply there shows one person's context to the others. 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 deliver at least once, and out of order. Record the inbound message ID, and drop a repeat before you spend a model call on it. commit() takes no idempotency key, so 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 ​

Handle these four sender and profile states:

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.

Do not request sign-in solely because a profile read failed. A timeout does not establish whether the user is connected. Offer the link when they ask for it, when a connector call fails, or on their first message. Do not offer it again on every turn.

Keep context on the reply path ​

Set a timeout and fallback for the profile read so it does not delay the response. Avoid retries on the response path.

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

The example returns the cached profile on timeout. A rejected read propagates to the caller. Handle that rejection in your caller and use the last cached profile when appropriate for the request. The refresh runs again on the next message.

Use format() instead of assembling the text yourself. It applies the sections the user chose to hide. Manual formatting can reintroduce sections the user has hidden.

Write the turn back ​

Choose the write method according to the content:

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() submits the turn for durable-context extraction. It is safe to call on every message. remember() writes one fact verbatim. Use it only when the user asked you to remember something.

Check saved in the response. remember() returns a result without throwing when a fact is not saved. Exact duplicates return reason: "exact_hash". Semantically similar facts return near_duplicate.

reason is on the response but not yet on the RememberResponse type. In TypeScript, read it from the value. Autocomplete will not offer it.

Configure does the duplicate check for you, so do not search before writing. A search on the reply path makes the sender wait for the same result. When the fact is stored, the ID you need for forget() is on saved.memory.id.

Commit after sending the response. Do not delay the response to wait for a memory write.

The example saves an explicit fact with remember() and submits other turns with commit(). If an explicit-fact turn also contains context to extract, submit that turn with commit() after the response. Configure extracts durable information only from the messages it receives.

Give the model the tools ​

Use the same per-user MCP session pattern as the Quickstart. Create a session for each user and pass mcp_servers to your model call. The agent can then search the profile and reach connected apps during the conversation.

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

Tell the model what the profile is ​

Profile context includes previously saved information and may be outdated. Instruct the model to apply corrections stated by the user.

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, not instructions. Before you put it in a prompt, fence it the way the Trust page shows.

Handle errors ​

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

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

A 401 response can indicate an expired stored token. It does not establish that the user disconnected. Resolve again with validateToken: true. Or drop the token and pass the sender's handles, so the call recognizes them again. Then retry the read once.

Do not resolve again with the same stored token and no validateToken. That returns the same expired token without asking Configure, so you loop. Never turn a 401 alone into a sign-in request.

Right after someone connects, their apps are still syncing. A connector call can fail on the next message. Answer without the connector and try again on the following turn. Do not send another link to someone who has already used one.

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

Infrastructure to connect agents, apps, and chats