Personal Agent over SMS
Give each user their own agent that they sign in to with their phone number and talk to by text message.
What we’ll build:
- A sign-in where users enter their phone number, then the code they get by text.
- A
userActor per phone number that stores the sign-in in its SQLite database. - An
assistantagent per user that answers texts and can look up the weather. - A Twilio webhook that routes each text to the sender’s agent.
Install
npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai rivetkit hono @hono/node-server
Set up Twilio
In the Twilio Console, buy a phone number that can send SMS and create a Verify service for the sign-in codes. Then set:
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_PHONE_NUMBER=+15550100000
TWILIO_VERIFY_SERVICE_SID=VA...
TWILIO_WEBHOOK_URL=https://<your-worker>/sms/webhook
ANTHROPIC_API_KEY=sk-ant-...
Create the agent
Each user gets their own assistant agent, keyed by their phone number:
import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { getWeather } from "./weather";
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "personal-assistant",
tools: [getWeather],
sections: [
section(
"role",
() => "You are a personal assistant that talks over SMS. Reply in plain text, without Markdown, in a few short sentences.",
),
],
}),
);
// One assistant per user, keyed by their phone number.
export const assistant = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
// Texts every answer to the user whose agent this is.
onAnswer: async (c, answer) => {
const [phone] = c.key;
await sendSms(phone, answer.text ?? "Sorry, I couldn't answer that.");
},
});
async function sendSms(to: string, body: string) {
const sid = process.env.TWILIO_ACCOUNT_SID;
const auth = btoa(`${sid}:${process.env.TWILIO_AUTH_TOKEN}`);
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, {
method: "POST",
headers: { authorization: `Basic ${auth}` },
body: new URLSearchParams({ To: to, From: process.env.TWILIO_PHONE_NUMBER ?? "", Body: body }),
});
if (!response.ok) throw new Error(`Twilio returned ${response.status}.`);
}
- There’s no step that creates an agent. The HTTP server calls
assistant.getOrCreate([phone]), so a user’s first text creates their agent and every later text reaches the same one, with the whole conversation so far. onAnswerruns for every answer the agent gives, with the agent’s own context, soc.keyis the user’s phone number. It texts the answer back. It runs inside the agent’s run, so an answer that finishes after a crash or restart still gets sent.
Add tools
Tools are installed on the agent’s registry, next to its system prompt:
import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { getWeather } from "./weather";
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "personal-assistant",
tools: [getWeather],
sections: [
section(
"role",
() => "You are a personal assistant that talks over SMS. Reply in plain text, without Markdown, in a few short sentences.",
),
],
}),
);
// One assistant per user, keyed by their phone number.
export const assistant = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
// Texts every answer to the user whose agent this is.
onAnswer: async (c, answer) => {
const [phone] = c.key;
await sendSms(phone, answer.text ?? "Sorry, I couldn't answer that.");
},
});
async function sendSms(to: string, body: string) {
const sid = process.env.TWILIO_ACCOUNT_SID;
const auth = btoa(`${sid}:${process.env.TWILIO_AUTH_TOKEN}`);
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, {
method: "POST",
headers: { authorization: `Basic ${auth}` },
body: new URLSearchParams({ To: to, From: process.env.TWILIO_PHONE_NUMBER ?? "", Body: body }),
});
if (!response.ok) throw new Error(`Twilio returned ${response.status}.`);
}
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";
export const getWeather = defineTool({
name: "get_weather",
description: "Get the current weather in a city.",
parameters: Type.Object({ city: Type.String() }),
// A lookup changes nothing, so running it twice is harmless.
replay: "safe",
execute: async ({ city }) => {
// A mock. Call a real weather API here.
const weather = { city, temperatureC: 18, conditions: "sunny" };
const text = `${city}: ${weather.temperatureC}°C and ${weather.conditions}.`;
return { content: [{ type: "text", text }], details: weather };
},
});
get_weather returns mock weather. Replace its body with a call to a real weather API, or add your own tools the same way. See Custom Tools. To let the agent run code, edit files, or browse, give it a sandbox with Pi’s built-in tools.
Create the user Actor
Each phone number also gets a user Actor, keyed by the same number. It runs the two-step sign-in and remembers it in its SQLite database:
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
// One user per phone number. The key is the number in E.164 format, such as +15551234567.
export const user = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
verified_at INTEGER NOT NULL
)
`);
},
}),
actions: {
// Step 1: text the user a code. Twilio Verify creates, expires, and rate limits it.
sendCode: async (c) => {
const [phone] = c.key;
await twilioVerify("Verifications", { To: phone, Channel: "sms" });
},
// Step 2: check the code and remember the sign-in.
verify: async (c, code: string) => {
const [phone] = c.key;
const check = await twilioVerify("VerificationCheck", { To: phone, Code: code });
if (check?.status !== "approved") return false;
await c.db.execute(
"INSERT INTO profile (id, verified_at) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET verified_at = excluded.verified_at",
Date.now(),
);
return true;
},
isSignedIn: async (c): Promise<boolean> => {
const rows = await c.db.execute("SELECT verified_at FROM profile");
return rows.length > 0;
},
},
});
async function twilioVerify(path: string, params: Record<string, string>) {
const service = process.env.TWILIO_VERIFY_SERVICE_SID;
const auth = btoa(`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`);
const response = await fetch(`https://verify.twilio.com/v2/Services/${service}/${path}`, {
method: "POST",
headers: { authorization: `Basic ${auth}` },
body: new URLSearchParams(params),
});
// Verify answers 404 when there's no pending code for the number, such as after it expired.
if (response.status === 404) return undefined;
if (!response.ok) throw new Error(`Twilio Verify returned ${response.status}.`);
return (await response.json()) as { status: string };
}
sendCodetexts the user a code with Twilio Verify, which creates, expires, and rate limits it, so the Actor never stores it.verifychecks the code and saves the sign-in in theprofiletable.isSignedIntells the HTTP server whether a number has signed in.- Keys use the E.164 format, such as
+15551234567, because that’s how Twilio sends the sender’s number.
This sign-in is simplified. Any client can call user by phone number. In production, return a session after verify, such as a JWT, and protect the Actor with authentication.
Route requests
The HTTP server has one route. Twilio sends every text to /sms/webhook, which checks the sender’s user Actor and hands the text to their assistant:
import { createHmac, timingSafeEqual } from "node:crypto";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { setup } from "rivetkit";
import { createClient } from "rivetkit/client";
import { assistant } from "./assistant";
import { user } from "./user";
export const registry = setup({ use: { user, assistant } });
const client = createClient<typeof registry>();
registry.start();
const app = new Hono();
// Twilio calls this for every text to your number.
app.post("/sms/webhook", async (c) => {
const params = Object.fromEntries(new URLSearchParams(await c.req.text()));
if (!verifyTwilioSignature(params, c.req.header("x-twilio-signature") ?? "")) {
return c.text("invalid signature", 401);
}
// Ask the sender's user Actor whether they signed in.
const phone = params.From;
if (!(await client.user.getOrCreate([phone]).isSignedIn())) {
return twiml("<Message>Sign in with this number to talk to your assistant.</Message>");
}
// Hand the text to the sender's assistant without waiting for the answer. Their first text creates it.
// The message id stops a duplicate delivery from running the agent twice.
await client.assistant.getOrCreate([phone]).submit(params.Body, { requestId: params.MessageSid });
// An empty response sends nothing. The assistant texts its answer from onAnswer.
return twiml("");
});
serve({ fetch: app.fetch, port: 3000 });
// Twilio signs the webhook URL followed by each POST parameter's name and value, sorted by name.
function verifyTwilioSignature(params: Record<string, string>, signature: string): boolean {
const url = process.env.TWILIO_WEBHOOK_URL ?? "";
const payload = url + Object.keys(params).sort().map((key) => key + params[key]).join("");
const expected = createHmac("sha1", process.env.TWILIO_AUTH_TOKEN ?? "").update(payload).digest("base64");
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
function twiml(message: string) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><Response>${message}</Response>`, {
headers: { "content-type": "text/xml" },
});
}
- The webhook checks Twilio’s signature, which covers the URL Twilio called. Set
TWILIO_WEBHOOK_URLto exactly the URL you give Twilio, because a proxy or tunnel can change the URL the server sees. - It answers with TwiML. An empty
<Response>sends nothing, because the assistant replies on its own. A number that hasn’t signed in gets a message telling it to sign in. submithands the text to the agent and returns without waiting for the answer, so the webhook responds within Twilio’s 15-second limit. The agent texts its answer fromonAnswer.- If a text is delivered twice, the
requestIdstops the agent from answering it again.
Try it
In the Twilio Console, open your phone number. Under Messaging, set A message comes in to Webhook, https://<your-worker>/sms/webhook, HTTP POST. This is the same URL as TWILIO_WEBHOOK_URL.
If you haven’t deployed yet, test locally with ngrok. Run the server with npx tsx server.ts, run ngrok http 3000 in another terminal, and use the ngrok URL in place of https://<your-worker> for both the webhook and TWILIO_WEBHOOK_URL.
Sign in with your phone number:
import { createInterface } from "node:readline/promises";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const input = createInterface({ input: process.stdin, output: process.stdout });
// 1. Enter your number and send a code.
const phone = await input.question("Phone number, such as +15551234567: ");
const user = client.user.getOrCreate([phone]);
await user.sendCode();
// 2. Enter the code you got.
const code = await input.question("Code: ");
if (await user.verify(code)) {
console.log(`Signed in. Text ${process.env.TWILIO_PHONE_NUMBER} to talk to your assistant.`);
} else {
console.log("That code is wrong or expired.");
}
input.close();
Then text the Twilio number “What’s the weather in Tokyo?”
Next steps
- Sandboxes: give the agent a computer to run code and browse.
- Custom Tools: add tools for calendars, notes, or your own APIs.
- Schedules: text the user a forecast every morning without being asked.
- Human in the Loop: ask the user before the agent acts.
- Slack, Discord, GitHub, and Linear: reach agents from other places.
- User Subscriptions: run each user’s agent on their own Claude or ChatGPT plan.
Related docs
- Design Patterns: why each user gets their own agent.
- Custom Tools: how the weather tool works.
- Slack: the same webhook pattern for Slack.