Sign users in over SMS or voice
A text thread has no screen. There is no card to mount and no host script to load. Everything on this page is a server-side call or 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, and commit the turn when it ends. A text thread breaks three things. Configure has a call for each.
| What breaks | The call |
|---|---|
| No button to render | auth.createMessageSignInUrl() returns the URL to text |
| No session to key on | auth.resolveMessageIdentity() returns the identity for the turn |
| A stranger may already have an account | auth.recognizePhone() says so before you ask them to sign in |
Every inbound message runs the same five steps, in this order:
- Decide whether the message is a turn. A turn is real text from the user, in a direct thread, and not a redelivery.
- Call
auth.resolveMessageIdentity()to get the identity. Callauth.recognizePhone()if you do not know the sender yet. - Read their context, with a time budget and a fallback.
- Answer. If they still need to sign in, send the URL from
auth.createMessageSignInUrl()in the reply. - Commit the turn after the reply is out.
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. The key never reaches a handset.
bash
npm install configurets
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
Do this 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. 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.
A line is registered per channel. Ask for a URL on imessage with a line you registered on sms, and you get ACCESS_DENIED with messageLinePhone is not registered for this agent. The message names the phone, not the channel mismatch that caused it. An unregistered line returns the same error.
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(). A linked sender uses 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 }Trust linked only when Configure decides it. 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. Tested against a stale token, the default answers linked: true and validateToken: true answers linked: false.
Normalize the handle before you pass it. Configure matches an external id literally. +15551234567 and 15551234567 are two different people with two different profiles. If a client formats the number differently, the same person silently becomes a stranger. 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. Make the thread useful on the first message and better after they connect.
Send one URL
Ask Configure for the URL instead of minting 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 see which one you got:
mode | What it is | Lifetime |
|---|---|---|
plain | Your agent's permanent page, https://sign-in.me/<agent> | Never expires |
minted | A one-time code-bearing link, with code and expiresAt | 15 minutes |
Send link.url either way. Do not send a generic minted connect link in a text thread. The text stays in the thread forever, but a minted link expires. A user who scrolls back a day later would tap a dead URL. The permanent page mints a link at the moment they open it.
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 sender to sign in, check whether Configure already knows them. 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 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 means Configure knows 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. Treat recognition as a greeting, never as consent.
Decide what counts as a turn
A real channel delivers more than messages. Decide what counts as a turn before you answer or commit anything.
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 winsDecide what the turn is allowed to say
A text surface has four states. The fourth, a failed read, is the one that leads to the worst reply.
| State | What the turn does |
|---|---|
| Linked | Answer. Do not call a profile tool to re-check linking. |
| Recognized, not approved | Answer, and offer the link once. |
| Not linked | Answer, and offer the link once. |
| Read failed | Answer. Claim nothing about their account and send no link. |
Never send "please sign in" because a read failed. A timeout tells you about your network, not about the user. Users remember being asked to sign in again when they already had. 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
The read runs while a person waits for a reply. Give it a time budget and a fallback, not 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. 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. Text you assemble yourself brings back data the user asked you to drop.
Write the turn back
There are two writes. 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. 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 on the response. remember() resolves instead of throwing when it drops a fact. It drops a fact that repeats something already stored. An exact repeat comes back with reason: "exact_hash". A rewording of a stored fact comes back with 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 the reply is sent, not before it. A memory write must never sit between the user's message and your answer.
Commit every turn, not only the turns that look important. Configure decides what is worth keeping, but it can only judge what it receives. A turn you filter out never reaches the profile. A classifier in front of commit() can stop a text thread from teaching the profile anything, with no error to tell you.
Give the model the tools
This works the same as anywhere else. Mint 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 providerTell the model what the profile is
Profile text is dated evidence, not current truth. 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, not instructions. Before you put it in a prompt, fence it the way the Trust page shows.
When something goes wrong
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.
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 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.