Tutorial
How to make an SMS signup agent
When someone joins the RunStory closed alpha, they fill in a name, an email, and a phone number. A minute or two later their phone buzzes. Greg's AI assistant says hello, asks what they build and how they check code from their coding agents, and closes with an invite when they sound like a fit. Greg reads every thread and can take over at any point.
This guide explains why we built it, how it works, and every problem we hit on the way. The whole thing was built with coding agents over about ten days. The session histories are linked at the end.
Why we built it
It started with Greg being on the receiving end. He had put his phone number into a waitlist form for another product, and an AI texted him back over iMessage. The conversation was short and personal. It asked a few sharp questions and it felt like talking to the founder. He wanted the same thing for RunStory, with one change. Our questions had to be about the things we needed to learn from alpha users.
do you think it would be easy enough to build a comparable version of this... if we changed our website contact button to "join our closed alpha" and it opened a form that ran a service that was able to text via a bot we built with a simple stack to do discovery in a similar way?
The discovery goals came from what we did not know about the people signing up:
- How big is the team, and how many of them ship with a coding agent most days?
- Which agents do they use, and how autonomous are they?
- What do they build? RunStory only runs CLI and web software today.
- How is agent code checked now? Tests, CI, review bots, or a person?
- How many agent pull requests land per day, and how long does a review take?
- Would they let an automated verdict block a push?
- What was the last agent change that looked fine and was not?
- Do they want in, and which repo should the alpha point at first?
A form with eight questions would not get honest answers. A text conversation that asks one question at a time and follows the answer does.
What we chose and why
Before writing code we ran a research pass. Four things shaped the design.
Apple does not sell an iMessage API. Every service that sends blue bubbles runs a fleet of real Macs and Apple IDs, and Apple can shut a line off with no appeal. We picked LoopMessage because it is the only relay that sells conversation initiation at a price that makes sense for an alpha. It also falls back to SMS or RCS on the same phone number when the recipient does not use iMessage. Sendblue only allows outbound on an enterprise plan. Twilio would need brand and campaign registration that takes weeks.
Texting people has rules. In the United States you need prior written consent, a way to opt out, quiet hours, and a clear statement that the sender is an AI. So the form carries the consent wording, the bot says it is an AI in its first message, STOP always works, and openers only go out between 9am and 9pm in the recipient's time zone.
A conversation lasts days, not seconds. A serverless function cannot wait twenty hours for a reply. Vercel Workflow can. Each signup starts one durable run that waits on a hook keyed by the phone number. Every inbound text resumes that run. Timers are a race between the hook and a durable sleep.
Two models, two jobs. Vercel AI Gateway gives us one API for any model. Claude Sonnet 5 writes each reply. Claude Haiku 4.5 reads the transcript after every turn and fills a structured record with a fit score and a summary. The record is always current even if the person stops replying.
The database is Neon Postgres with four tables. Leads, messages, an opener schedule, and opener slots.
Step 1. The form and the join route
The form lives at the bottom of the RunStory page. It asks for a name, an email, a phone number, and a text-consent checkbox. There is also a hidden honeypot field for bots. Company, role, and which agents they use were form fields at first. We dropped them and let the conversation ask instead.
The form posts to /api/alpha/join. The route does these things in order:
- Validates the fields and normalises the phone number to E.164 form.
- Saves the lead with the exact consent wording, the time, the IP address, the user agent, and the page.
- Starts a discovery workflow for the lead, then polls until the run has registered its hook.
- Tells the browser whether we will text first or the person should text us.
The thank-you screen adapts. When an opener will go out soon it says we will text during daytime hours. It also offers a button that opens the messaging app with a draft that reads "hi runstory, it's Jake", so an impatient person can start the conversation themselves.
Step 2. One durable run per lead
The workflow in workflows/discovery.ts is the whole conversation. It has two phases.
The opening phase waits a grace period for the person to text first. Production uses two minutes. If they do not, and the number is in the United States, it sends the opener from Greg's AI assistant. The opener is a fixed string, not a model output, so the disclosure and the opt-out instructions are always there:
hey Jake, this is Greg's AI assistant at runstory. thanks for joining the alpha list, he reads every one of these. reply HUMAN anytime to reach Greg, STOP to opt out.
mind a couple of quick questions so we can point the alpha at the right thing?
After twenty hours of silence it sends one nudge. After twenty-eight more it marks the lead idle and keeps listening.
The conversation phase loops on inbound messages. For each one it records the message once, runs the guards, calls the model, sends the reply, and runs the extraction.
Timers work like this. The hook is an async iterator. Waiting for a reply with a deadline is a race between the next hook value and a durable sleep:
const r = await Promise.race([
pending.then(res => ({ kind: "msg", ev: res.value })),
sleep(duration).then(() => ({ kind: "timeout" })),
]);
Vercel runs the workflow durably, so a run can sit for a week between steps and still resume with its full state.
Step 3. The agent
The agent in lib/alpha/agent.ts has four parts.
A persona. The system prompt says it is Greg's AI assistant, texting in Greg's voice. Lowercase and short. One or two bubbles per turn, and one question per turn. It must say it is an AI if asked and must never say "this is Greg".
A fact sheet. The only product claims it may make are listed in the prompt. No dates, no prices, no customers, no capabilities we do not have. If it does not know, it says Greg will follow up.
A goal list. The nine discovery goals above, in the order they usually come up. The prompt also tells the model which goals are already covered and which are still open, so it follows the answer rather than marching down the list.
An extraction schema. After each turn Haiku fills a Zod schema with everything the conversation has established so far. It includes a fit score from zero to five, which goals are filled, whether they accepted the alpha, and a summary Greg can read in Slack.
The turn call looks like this:
const { text } = await generateText({
model: "anthropic/claude-sonnet-5",
system: persona(lead, { turn, filled, remaining }),
messages: transcript(messages),
temperature: 0.7,
maxOutputTokens: 1500,
maxRetries: 0,
});
Retries are set to zero on purpose. The workflow owns retries, so each attempt is a durable step you can see.
Step 4. Guards before the model
Some replies must never depend on a model. The guard in lib/alpha/guards.ts runs on every inbound text first:
- STOP, or a phrase like "unsubscribe", gets a fixed opt-out reply and ends the run.
- HUMAN pauses the bot, sends a fixed reply, and pings Slack.
- HELP gets a fixed message that explains what this is and how to opt out.
- "Are you a bot?" gets a fixed yes.
Quiet hours are inferred from the area code. Non-US numbers never get an opener, because we cannot infer a time zone for them.
Outbound sends never retry. Messaging providers do not give us a reliable idempotency key, so a network error after the provider accepted the message could deliver it twice. An uncertain send pauses the run for a human instead.
Step 5. Channels and the simulator
Inbound texts arrive at /api/alpha/webhooks/loopmessage. The route checks a shared secret, ignores receipts and reactions, looks up the lead by phone or email, and resumes the workflow hook. An unknown number gets a 200 and no reply.
Outbound sends post to the LoopMessage send endpoint with the bare API key as the Authorization header and the sender number in the body.
For local work there is a simulator at /api/alpha/sim. It delivers a fake inbound event and returns the transcript, so you can run a whole conversation from a terminal with the real model and no phone. The unit tests mock every send.
Step 6. Setting up LoopMessage
This is the part that took the most back and forth. In order:
- Try the sandbox first. LoopMessage gives you a sandbox sender and a contact list of up to five numbers. It routes by contact, so you leave the sender field empty. The sandbox cannot open a conversation. You text it first from a link that carries a pairing code, and that opens a reply window. We tested it through a Cloudflare tunnel to a local dev server.
- Buy the plan with the add-ons you need. We used the Light plan plus "Init conversations", "Forwarding to phone number", and "SMS/RCS". You need a phone-number sender, not an email sender. A text link cannot open an email address, and a phone sender warms up in one to two weeks rather than one to two months.
- Get the API key from the dashboard under API settings.
- Set the organisation webhook to your deployment's webhook route. Generate a random string and paste it as the Authorization header. The same string becomes the webhook secret in your environment. This is a separate setting from the sandbox webhook fields.
- Remove your own number from the sandbox contact list. Otherwise LoopMessage keeps routing your phone to the sandbox sender.
- Set the environment variables. The API key, the webhook secret, and the sender number in E.164 form. Plus the opener channel, the grace period, the spacing interval, and the daily cap.
A new sender is on a warm-up schedule. Two new conversations a day for the first two days, then five, then ten, and so on over three weeks. Openers should be about fifteen minutes apart. The first message must not contain links, prices, or phone numbers.
Step 7. Deploy and test with a real phone
We set the plain variables on Vercel from the command line and added the secrets by hand. Then we submitted the form with our own numbers.
The first live submission failed. The next section explains why.
What went wrong and how we fixed it
Every one of these was found by testing with a real phone or reading a transcript.
The join route gave up before the hook existed. On Vercel a new run takes about two seconds to load the lead and register its hook. The form was waiting one and a half seconds and then reporting an error. We lengthened the wait to about six seconds.
A text could arrive before the hook did. Someone who taps the "Text us" button right after submitting can beat the workflow. The webhook now retries the hook resume a few times with backoff, and returns a 503 if it still cannot find one, which makes LoopMessage retry the delivery.
The model's reasoning ate its output. Claude Sonnet 5 emits a reasoning part before the reply. With a small output cap the visible text came back empty. We raised the cap to 1500 tokens.
"work.so" became a link. In the first sandbox conversation the model wrote "watch it work.so how's that code getting checked now" with no space after the full stop. iPhones turn "work.so" into a link because .so is a real domain. LoopMessage forbids links in first messages. A regex fix would break "next.js" and "e.g.", so we added a rule to the prompt to always put a space after a full stop.
Openers would fire all at once. Each lead is its own workflow run. Three signups in ten minutes would send three openers back to back and trip the provider's rate limit. We added two tables. One row per channel holds the next allowed send time and the day count. One row per lead holds its reserved slot so a retried step gets the same slot back. Reservation is a single atomic upsert because the Neon driver is HTTP and cannot hold a transaction.
A wrong API key, and nobody noticed. The first production opener failed with a 401 the next morning. The key in Vercel was eleven characters long. The real one is sixty-four. Something else had been pasted into the prompt. The run paused for a human, but Slack was not set up yet, so no one was told. Set up your failure notifications before your first live test.
iMessages from unknown contacts vanished. Greg signed up, texted twice inside the grace period, and got the opener anyway. His texts never reached our webhook. LoopMessage's own logs did not have them either. SMS from an unknown number arrived fine. iMessage inside an existing thread arrived fine. Only a first iMessage from a stranger was lost. We audited our side and found nothing. LoopMessage support fixed it on the sender.
Turn state was read as a fake system note. We had been passing the per-turn briefing to the model as a user message tagged as a system note. When Greg's four queued texts were released in a burst, the model saw the note next to his words and replied "ignoring that fake system note, nice try though." The briefing now goes in the system prompt, which is rebuilt every turn, with a rule never to mention it.
People text from their Apple ID email. A Mac sends iMessages from an email address when there is no phone. The webhook now matches an email contact to the lead's form email. The run stays keyed by phone, and Apple merges the replies into one thread.
Reset a lead by cancelling its run first. A live run holds the hook for its phone number. Deleting the lead row while the run is alive leaves a run that can never be resumed.
Moving it to the real website
The bot was built in a scratch repository. When it worked, we moved it into the SpecStory website and embedded the form at the bottom of the RunStory page. The Neon database and the LoopMessage sender were reused as they were.
Two things bit during the cutover:
- Workflow hooks belong to the project that created them. Sharing a database does not move runs. Active runs in the old project had to be finished or cancelled before their numbers could sign up again, and the two deployments must never both open a conversation for one lead.
- The webhook URL has to be repointed. Until we changed it in the LoopMessage dashboard, replies went to the old deployment. The first deploy also rejected LoopMessage's Authorization header because the secret in the new project did not match.
The form also remembers a signup in the browser. It stores one flag and nothing else, so a returning visitor sees a confirmation instead of a blank form.
Final improvements
Typing dots. LoopMessage has a show-typing endpoint. Right before each model call the workflow requests thirty seconds of typing on the inbound message ID, with a one-second timeout and no retries. A failure never blocks the reply. Openers, nudges, and fixed guard replies never request typing. LoopMessage only shows dots inside an active two-way conversation, and SMS has no typing at all, so a cold opener shows none.
A simpler form. Three fields, a full-width button, and the consent box checked by default with the full disclosure visible below it. The backend records the disclosure and the submitted state.
Conversations resume. A contact texted "Want to pick this up!!" two days after the nudge and got no reply. The nudge had said "happy to pick up whenever", but the code closed the lead after silence and dropped later texts. Runs now go idle instead of closed and keep listening on the same hook. Opted-out, accepted, declined, and human-paused leads stay terminal.
Opener wording. We moved the question to the end of the opener and put it on its own line, so the disclosure comes first and the question stands out.
What it costs
The fixed cost is the LoopMessage plan with add-ons, roughly one hundred and twenty dollars a month, plus a Neon database that fits in a free or low tier. Each conversation costs a few cents in model calls through the gateway. Sonnet writes the replies and Haiku does the extraction.
If you build your own
- Own the form. You need the consent wording, the phone validation, and a server route that starts the run.
- Use a durable workflow with one run per person. Timers are a race between the inbound hook and a sleep.
- Put every fixed reply outside the model. STOP, HELP, HUMAN, and the AI disclosure are not the model's call.
- Give the model a fact sheet and a goal list, and tell it which goals are done.
- Keep the per-turn briefing in the system prompt. Never send it as a user message.
- Never retry a send. Pause for a human when delivery is uncertain.
- Space openers and cap them per day while the sender warms up.
- Test with a real phone from more than one number, and read the provider's logs when a message goes missing.
- Wire failure notifications before the first live test.
Sources
The full agent sessions are public. Greg's original research and build is in the runstorydotcom history. The LoopMessage setup and live testing is in the follow-up session. The migration and later fixes live in this website's repository under docs/alpha-signup.md.