diff --git a/vendor/agents/docs/content/docs/pi.mdx b/vendor/agents/docs/content/docs/pi.mdx
index edacb813..b4c53eac 100644
--- a/vendor/agents/docs/content/docs/pi.mdx
+++ b/vendor/agents/docs/content/docs/pi.mdx
@@ -139,14 +139,27 @@ The `prompt` action sends input, waits, and returns `{ status: "done", text }` o
| To | Call |
| --- | --- |
| Prompt and wait | `prompt(text, { requestId })` |
-| Start a run without waiting | `conversation.submit(id, { type: "input", content, requestId })` |
+| Start a run without waiting | `submit(text, { requestId })` |
| Steer a run | `prompt(text, { whenBusy: "steer" })` |
| Cancel a run | `conversation.abort(id)` |
| Switch models | `conversation.configure(id, { model: { provider, modelId } })` |
| Get the root conversation | `harness.root()` |
| Start a conversation | `harness.createConversation({ ownership: { kind: "ownerless" } })` |
-Actions wait up to ten minutes, and a timed-out wait leaves the run going. A prompt sent during a run queues as a follow-up. See the source for every action.
+Actions wait up to ten minutes, and a timed-out wait leaves the run going. A prompt sent during a run queues as a follow-up. `submit` takes the same options as `prompt` and returns `{ submissionId }` at once. Pair it with `onAnswer` to deliver the answer. See the source for every action.
+
+## Deliver answers
+
+`onAnswer` runs in the agent Actor for every final answer, with the Actor's context. Use it to send answers somewhere, such as a chat app or a text message, without a client waiting on `prompt`:
+
+
+
+- It gets `{ conversationId, text, message }`. Responses that only call tools aren't final answers, so they don't call it.
+- It runs inside the run, so an answer that finishes after a crash or restart is still delivered. If the Actor stops while `onAnswer` is running, it can run again for the same answer.
+- A throw goes to `onReport` and leaves the answer in the conversation.
+- It runs in conversations that use the default extensions. A conversation that selects its extensions by name doesn't get it.
+
+See [Personal Agent over SMS](/guides/personal-agent) for a complete example.
## Stream a conversation
@@ -211,6 +224,7 @@ The `pi()` function accepts every [`actor()`](/actors/docs/actor-configuration)
| `sandbox` | Where `CodingTools` run. Without one, files live in the Actor's database. See [Sandboxes](/agents/docs/sandboxes). |
| `documents` | Documents clients may read. |
| `settings` | Pi Durable settings, such as `retry` and `compaction`. |
+| `onAnswer` | Runs for every final answer. See [Deliver answers](#deliver-answers). |
Other options, such as `thinkingLevel` and `env`, pass through to Pi Durable. By default, `actionTimeout` is ten minutes and `sleepGracePeriod` is 15 minutes. A `ConversationBusy` error reaches clients as a `UserError`, and other Pi Durable errors as internal errors.
diff --git a/vendor/agents/docs/content/guides/personal-agent.mdx b/vendor/agents/docs/content/guides/personal-agent.mdx
new file mode 100644
index 00000000..e612bf4d
--- /dev/null
+++ b/vendor/agents/docs/content/guides/personal-agent.mdx
@@ -0,0 +1,198 @@
+---
+title: "Personal Agent over SMS"
+description: "Give each user their own agent that they sign in to with their phone number and talk to by text message."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+
+
+What we'll build:
+
+- A sign-in where users enter their phone number, then the code they get by text.
+- A `user` Actor per phone number that stores the sign-in in its SQLite database.
+- An `assistant` agent per user that answers texts and can look up the weather.
+- A Twilio webhook that routes each text to the sender's agent.
+
+
+
+
+
+```sh
+npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai rivetkit hono @hono/node-server
+```
+
+
+
+
+
+In the Twilio Console, buy a phone number that can send SMS and create a Verify service for the sign-in codes. Then set:
+
+```sh
+TWILIO_ACCOUNT_SID=AC...
+TWILIO_AUTH_TOKEN=...
+TWILIO_PHONE_NUMBER=+15550100000
+TWILIO_VERIFY_SERVICE_SID=VA...
+TWILIO_WEBHOOK_URL=https:///sms/webhook
+ANTHROPIC_API_KEY=sk-ant-...
+```
+
+
+
+
+
+Each user gets their own `assistant` agent, keyed by their phone number:
+
+
+
+- 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.
+- `onAnswer` runs for every answer the agent gives, with the agent's own context, so `c.key` is 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.
+
+
+
+
+
+Tools are installed on the agent's registry, next to its system prompt:
+
+
+
+
+
+
+`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](/agents/docs/custom-tools). To let the agent run code, edit files, or browse, give it a [sandbox](/agents/docs/sandboxes) with Pi's built-in tools.
+
+
+
+
+
+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:
+
+
+
+- `sendCode` texts the user a code with Twilio Verify, which creates, expires, and rate limits it, so the Actor never stores it.
+- `verify` checks the code and saves the sign-in in the `profile` table.
+- `isSignedIn` tells 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](/docs/jwt), and protect the Actor with [authentication](/docs/authentication).
+
+
+
+
+
+
+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`:
+
+
+
+- The webhook checks Twilio's signature, which covers the URL Twilio called. Set `TWILIO_WEBHOOK_URL` to exactly the URL you give Twilio, because a proxy or tunnel can change the URL the server sees.
+- It answers with TwiML. An empty `` sends nothing, because the assistant replies on its own. A number that hasn't signed in gets a message telling it to sign in.
+- `submit` hands 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 from `onAnswer`.
+- If a text is delivered twice, the `requestId` stops the agent from answering it again.
+
+
+
+
+
+In the Twilio Console, open your phone number. Under **Messaging**, set **A message comes in** to **Webhook**, `https:///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://` for both the webhook and `TWILIO_WEBHOOK_URL`.
+
+
+Sign in with your phone number:
+
+
+
+Then text the Twilio number "What's the weather in Tokyo?"
+
+
+
+
+
+## Next steps
+
+- [Sandboxes](/agents/docs/sandboxes): give the agent a computer to run code and browse.
+- [Custom Tools](/agents/docs/custom-tools): add tools for calendars, notes, or your own APIs.
+- [Schedules](/agents/docs/schedules): text the user a forecast every morning without being asked.
+- [Human in the Loop](/agents/docs/human-in-the-loop): ask the user before the agent acts.
+- [Slack](/agents/docs/connectors/slack), [Discord](/agents/docs/connectors/discord), [GitHub](/agents/docs/connectors/github), and [Linear](/agents/docs/connectors/linear): reach agents from other places.
+- [User Subscriptions](/agents/docs/user-subscriptions): run each user's agent on their own Claude or ChatGPT plan.
+
+## Related docs
+
+- [Design Patterns](/agents/docs/design-patterns): why each user gets their own agent.
+- [Custom Tools](/agents/docs/custom-tools): how the weather tool works.
+- [Slack](/agents/docs/connectors/slack): the same webhook pattern for Slack.
diff --git a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
index 004dcc34..ea38bda6 100644
--- a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
+++ b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
@@ -5,7 +5,7 @@ description: "Let users connect their ChatGPT plan so your agents run on it, wit
import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
-
+
diff --git a/vendor/agents/docs/sidebar.json b/vendor/agents/docs/sidebar.json
index e980f209..5b5b7697 100644
--- a/vendor/agents/docs/sidebar.json
+++ b/vendor/agents/docs/sidebar.json
@@ -12,6 +12,12 @@
"title": "Quickstart",
"href": "/agents/docs/quickstart",
"icon": "faForwardFast"
+ },
+ {
+ "title": "Guides",
+ "icon": "faBookOpen",
+ "collapsible": true,
+ "mirror": "guides"
}
]
},
@@ -182,6 +188,11 @@
{
"title": "Agents",
"pages": [
+ {
+ "title": "Personal Agent over SMS",
+ "href": "/guides/personal-agent",
+ "icon": "faCommentSms"
+ },
{
"title": "Sign in with ChatGPT",
"href": "/guides/sign-in-with-chatgpt",
diff --git a/vendor/agents/examples/docs/package.json b/vendor/agents/examples/docs/package.json
index 644f0c84..227f973c 100644
--- a/vendor/agents/examples/docs/package.json
+++ b/vendor/agents/examples/docs/package.json
@@ -11,6 +11,7 @@
"@earendil-works/pi-ai": "^1.0.4",
"@earendil-works/pi-coding-agent": "^1.0.4",
"@earendil-works/pi-durable": "^1.0.4",
+ "@hono/node-server": "^2.1.5",
"@linear/sdk": "^97.0.0",
"@octokit/rest": "^22.0.1",
"@octokit/webhooks-methods": "^6.0.0",
diff --git a/vendor/agents/examples/docs/personal-agent/assistant.ts b/vendor/agents/examples/docs/personal-agent/assistant.ts
new file mode 100644
index 00000000..1e4a6a0e
--- /dev/null
+++ b/vendor/agents/examples/docs/personal-agent/assistant.ts
@@ -0,0 +1,43 @@
+import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
+import { pi } from "@rivet-dev/pi";
+import { getWeather } from "./weather";
+
+// docs:start tools
+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.",
+ ),
+ ],
+ }),
+);
+// docs:end tools
+
+// One assistant per user, keyed by their phone number.
+export const assistant = pi({
+ model: "anthropic/claude-opus-5-5",
+ registry: extensions,
+ // docs:start answer
+ // 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.");
+ },
+ // docs:end answer
+});
+
+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}.`);
+}
diff --git a/vendor/agents/examples/docs/personal-agent/client.ts b/vendor/agents/examples/docs/personal-agent/client.ts
new file mode 100644
index 00000000..d7637bba
--- /dev/null
+++ b/vendor/agents/examples/docs/personal-agent/client.ts
@@ -0,0 +1,20 @@
+import { createInterface } from "node:readline/promises";
+import { createClient } from "rivetkit/client";
+import type { registry } from "./server";
+
+const client = createClient();
+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();
diff --git a/vendor/agents/examples/docs/personal-agent/server.ts b/vendor/agents/examples/docs/personal-agent/server.ts
new file mode 100644
index 00000000..0c51d083
--- /dev/null
+++ b/vendor/agents/examples/docs/personal-agent/server.ts
@@ -0,0 +1,52 @@
+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();
+
+registry.start();
+
+const app = new Hono();
+
+// docs:start webhook
+// 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("Sign in with this number to talk to your assistant.");
+ }
+
+ // 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("");
+});
+// docs:end webhook
+
+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, 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(`${message}`, {
+ headers: { "content-type": "text/xml" },
+ });
+}
diff --git a/vendor/agents/examples/docs/personal-agent/user.ts b/vendor/agents/examples/docs/personal-agent/user.ts
new file mode 100644
index 00000000..11d98b1a
--- /dev/null
+++ b/vendor/agents/examples/docs/personal-agent/user.ts
@@ -0,0 +1,58 @@
+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({
+ // docs:start schema
+ 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
+ )
+ `);
+ },
+ }),
+ // docs:end schema
+ actions: {
+ // docs:start sign-in
+ // 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;
+ },
+ // docs:end sign-in
+ // docs:start signed-in
+ isSignedIn: async (c): Promise => {
+ const rows = await c.db.execute("SELECT verified_at FROM profile");
+ return rows.length > 0;
+ },
+ // docs:end signed-in
+ },
+});
+
+async function twilioVerify(path: string, params: Record) {
+ 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 };
+}
diff --git a/vendor/agents/examples/docs/personal-agent/weather.ts b/vendor/agents/examples/docs/personal-agent/weather.ts
new file mode 100644
index 00000000..bcc0b6e0
--- /dev/null
+++ b/vendor/agents/examples/docs/personal-agent/weather.ts
@@ -0,0 +1,16 @@
+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 };
+ },
+});