diff --git a/vendor/agents/docs/content/docs/custom-tools.mdx b/vendor/agents/docs/content/docs/custom-tools.mdx
index 33683ac0..67b56872 100644
--- a/vendor/agents/docs/content/docs/custom-tools.mdx
+++ b/vendor/agents/docs/content/docs/custom-tools.mdx
@@ -41,7 +41,7 @@ npm add @earendil-works/pi-ai
-Both `defineTool` and `defineExtension` come from `@earendil-works/pi-durable`. A tool is installed through an extension, and by default every conversation uses every installed extension.
+Both `defineTool` and `defineExtension` come from `@earendil-works/pi-durable`. A tool is installed through an [extension](/agents/docs/extensions), and by default every conversation uses every installed extension.
- The model reads the tool's `description` and calls it with arguments that match `parameters`. Pi rejects a call whose arguments don't match.
- The model reads `content` as the result. Clients get `details` for your UI.
diff --git a/vendor/agents/docs/content/docs/extensions.mdx b/vendor/agents/docs/content/docs/extensions.mdx
new file mode 100644
index 00000000..e9afa7f3
--- /dev/null
+++ b/vendor/agents/docs/content/docs/extensions.mdx
@@ -0,0 +1,102 @@
+---
+title: "Extensions"
+description: "Bundle tools, prompt sections, hooks, and tasks into named extensions, and choose which ones each conversation uses."
+---
+
+An extension is a named bundle of what an agent runs with: tools, prompt sections, hooks, tool wrappers, and tasks. You install extensions in a registry and pass it to `pi()`. Each conversation stores the names of the extensions it uses.
+
+
+
+
+
+| Field | Adds | See |
+| --- | --- | --- |
+| `tools` | Functions the model can call. | [Custom Tools](/agents/docs/custom-tools) |
+| `sections` | Parts of the system prompt. | [Instructions](/agents/docs/instructions) |
+| `hooks` | Code that runs around model requests, tool calls, and compaction. | [Hooks](#hooks) |
+| `wraps` | Decorators for a tool or section, by name. | [Wrap a tool](#wrap-a-tool) |
+| `tasks` | Multi-step work that resumes after a crash. | [Tools and tasks](/agents/docs/pi#tools-and-tasks) |
+
+By default, every conversation uses every installed extension, in install order. When two selected extensions have a tool with the same name, the later one wins.
+
+## Hooks
+
+A hook runs inside a built-in task, such as a tool call, in every conversation that selects its extension. This extension blocks the tools that change files or run commands:
+
+
+
+| Hook | Task | Runs | Can |
+| --- | --- | --- | --- |
+| `beforeTool` | `ToolTask` | Before a tool call runs. | Block the call or rewrite its arguments. A throw also blocks it. |
+| `afterTool` | `ToolTask` | After a tool call returns. | Replace the result. |
+| `beforeRequest` | `GenerationTask` | Before every model request. | Replace the messages of that request only. |
+| `afterResponse` | `GenerationTask` | After every model response. | Observe it. |
+| `afterTools` | `GenerationTask` | After every tool call of a round finishes. | Observe the results. |
+| `onYield` | `GenerationTask` | When the model gives a final answer. | Continue the run with another user message. |
+| `beforeCompact` | `CompactionTask` | Before a compaction summarizes the conversation. | Decline it or supply your own summary. |
+
+Hooks run in the agent Actor on your worker, so they can call your APIs. To enforce a rule in every conversation, keep its extension in the default selection. To approve tool calls one by one, see [Human in the Loop](/agents/docs/human-in-the-loop).
+
+## Wrap a tool
+
+`wrapTool` decorates a tool by name, whichever extension supplied it. This one logs every `bash` command:
+
+
+
+`wrapSection` does the same for a prompt section.
+
+## Choose extensions per conversation
+
+
+
+
+
+
+- **`settings.extensions`** is the default selection. Without it, conversations use every installed extension.
+- **`{ add, remove }`** changes the default for one conversation. An array selects exactly those extensions, in order. `null` goes back to the default.
+- **Clients pass names.** A name that isn't installed rejects with a `UserError`.
+- **Changes apply to the next step.** A model request that already started keeps its tools and prompt. A tool call that hasn't started yet uses the new hooks.
+
+The `conversation.agent` action returns the conversation's extensions, tools, and prompt sections by name.
+
+## Deploys
+
+The registry lives in your worker's code, and every agent Actor on that worker shares it. Pi saves only the names of the extensions a conversation selects.
+
+- **Install extensions at startup.** Installing one later changes only the worker that runs the code.
+- **Keep names stable.** Renaming an extension is the same as removing it.
+- **A removed extension stops applying.** Conversations that select it lose its tools, sections, and hooks, and get them back when a later deploy installs it again.
+- **Tasks wait for their extension.** A task from an extension's `tasks` resumes only once that extension is installed, so keep it installed while its tasks can run.
+- **Running tool calls finish on the old code** while the Actor drains. See [Upgrades and crashes](/agents/docs/pi#upgrades-and-crashes).
+
+**Next:** [User Subscriptions](/agents/docs/user-subscriptions), how users run agents on their own Claude or ChatGPT plan.
diff --git a/vendor/agents/docs/content/docs/pi.mdx b/vendor/agents/docs/content/docs/pi.mdx
index 6b1a14e1..fdf9ccd0 100644
--- a/vendor/agents/docs/content/docs/pi.mdx
+++ b/vendor/agents/docs/content/docs/pi.mdx
@@ -203,7 +203,7 @@ The `pi()` function accepts every [`actor()`](/actors/docs/actor-configuration)
| Option | Description |
| --- | --- |
-| `registry` | Required. Your tools and tasks, from `createRegistry()`. |
+| `registry` | Required. Your tools and tasks, from `createRegistry()`. See [Extensions](/agents/docs/extensions). |
| `model` | Starting model of new conversations, as `provider/modelId`. |
| `scopedModels` | Models a client may switch to. |
| `apiKeys` | API keys by provider. Defaults to the environment. See [LLM API Keys](/agents/docs/api-keys). |
diff --git a/vendor/agents/docs/content/docs/user-subscriptions.mdx b/vendor/agents/docs/content/docs/user-subscriptions.mdx
index f63c5eb2..f127e8b4 100644
--- a/vendor/agents/docs/content/docs/user-subscriptions.mdx
+++ b/vendor/agents/docs/content/docs/user-subscriptions.mdx
@@ -45,7 +45,7 @@ Store each user's logins in a `credentials` Actor and pass it to `pi({ credentia
The agent asks the `credentials` Actor on every model call, so a login or logout applies to the next model call, also in a run that is already going. The agent Actor never receives a refresh token and never writes credentials back. Errors from the `credentials` Actor reject the model call that needed the credential, so keep secrets out of their messages.
-Your app needs its own login flow, such as a settings page, that saves the result with the `credentials` Actor's `save` action. The files above are in `examples/docs/user-subscriptions`.
+Your app needs its own login flow, such as a settings page, that saves the result with the `credentials` Actor's `save` action. For a complete ChatGPT login flow, see [Sign in with ChatGPT](/guides/sign-in-with-chatgpt). The files above are in `examples/docs/user-subscriptions`.
## Share credentials across a team
diff --git a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
new file mode 100644
index 00000000..004dcc34
--- /dev/null
+++ b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
@@ -0,0 +1,143 @@
+---
+title: "Sign in with ChatGPT"
+description: "Let users connect their ChatGPT plan so your agents run on it, with no OpenAI API key."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+
+
+What we'll build:
+
+- A **Continue with ChatGPT** flow that connects a user's ChatGPT Plus or Pro plan to your app.
+- A `chatgptLogin` Actor per user that runs the sign-in with pi-ai and saves the result.
+- A `credentials` Actor per user that keeps the login and refreshes its access token.
+- An agent that runs on the user's ChatGPT plan, with no OpenAI API key.
+
+Related docs:
+
+- [User Subscriptions](/agents/docs/user-subscriptions), how agents read logins from a `credentials` Actor.
+- [LLM API Keys](/agents/docs/api-keys), the order the agent looks for credentials in.
+- ChatGPT plan usage, OpenAI's guide to the flow.
+
+
+This flow is for open-source and self-hosted apps. To offer it in a paid or hosted app, fill out OpenAI's interest form.
+
+
+
+
+
+
+```sh
+npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-coding-agent rivetkit
+```
+
+
+
+
+
+OpenAI identifies each deployment of your app by an agent host ID. Generate a UUID once and set it on every worker:
+
+```sh
+export CHATGPT_HOST_ID=$(node -e 'console.log(crypto.randomUUID())')
+```
+
+Keep the same value across deploys. It isn't a secret.
+
+
+
+
+
+The `credentials` Actor holds each user's login. It gives the agent the access token, never the refresh token, and refreshes it when it's about to expire:
+
+
+
+
+
+
+
+The `chatgptLogin` Actor drives pi-ai's Sign in with ChatGPT flow:
+
+
+
+- `start` returns the URL to open. Calling it again cancels the sign-in in progress.
+- OpenAI sends the browser back to `http://127.0.0.1:1455/auth/callback`. When the worker runs on another machine, that page doesn't load, and the user pastes its URL into your app.
+- `finish` exchanges the code and saves the login with the provider id `openai`.
+- A sign-in that isn't finished within ten minutes is cancelled.
+
+
+
+
+
+
+
+The agent reads the user's credential before every model call, so it can use OpenAI models as soon as the user signs in.
+
+
+
+
+
+
+
+In a web app, open the URL in a new tab and show a field for the pasted URL. Label the button **Continue with ChatGPT**, as OpenAI's guidelines require.
+
+
+
+
+
+## Limitations
+
+- **One sign-in at a time per worker.** pi-ai listens on port 1455 during a sign-in, so a second user's `start` on the same worker fails until the first finishes or is cancelled.
+- **Only the Responses API.** ChatGPT plan tokens can't call OpenAI classifier models.
+- **Usage counts against the user's plan.** Users set limits for your app in their ChatGPT settings.
+
+
+Protect both Actors with [authentication](/docs/authentication). `credentials` returns access tokens, and anyone who can call `finish` can save a login to that user.
+
diff --git a/vendor/agents/docs/sidebar.json b/vendor/agents/docs/sidebar.json
index 76054f1c..f83ac9e8 100644
--- a/vendor/agents/docs/sidebar.json
+++ b/vendor/agents/docs/sidebar.json
@@ -69,6 +69,10 @@
"title": "Instructions",
"href": "/agents/docs/instructions"
},
+ {
+ "title": "Extensions",
+ "href": "/agents/docs/extensions"
+ },
{
"title": "User Subscriptions",
"href": "/agents/docs/user-subscriptions"
@@ -169,5 +173,17 @@
}
]
}
+ ],
+ "guides": [
+ {
+ "title": "Agents",
+ "pages": [
+ {
+ "title": "Sign in with ChatGPT",
+ "href": "/guides/sign-in-with-chatgpt",
+ "icon": "faOpenai"
+ }
+ ]
+ }
]
}
diff --git a/vendor/agents/examples/docs/extensions/audit.ts b/vendor/agents/examples/docs/extensions/audit.ts
new file mode 100644
index 00000000..d1ed4b09
--- /dev/null
+++ b/vendor/agents/examples/docs/extensions/audit.ts
@@ -0,0 +1,16 @@
+import { defineExtension, wrapTool } from "@earendil-works/pi-durable";
+import { createBashTool } from "@earendil-works/pi-durable/tools";
+
+export const audit = defineExtension({
+ name: "audit",
+ wraps: [
+ // Wraps whichever `bash` tool the conversation ends up with.
+ wrapTool(createBashTool(), (bash) => ({
+ ...bash,
+ execute: (args, api, context) => {
+ console.log(`[audit] ${api.conversationId}: ${args.command}`);
+ return bash.execute(args, api, context);
+ },
+ })),
+ ],
+});
diff --git a/vendor/agents/examples/docs/extensions/client.ts b/vendor/agents/examples/docs/extensions/client.ts
new file mode 100644
index 00000000..375cfd4a
--- /dev/null
+++ b/vendor/agents/examples/docs/extensions/client.ts
@@ -0,0 +1,16 @@
+import { createClient } from "rivetkit/client";
+import type { registry } from "./server";
+
+const client = createClient();
+const agent = client.agent.getOrCreate(["acme", "checkout-review"]);
+const root = await agent.harness.root();
+
+// Add read-only to this conversation only. Extensions are passed by name.
+await agent.conversation.configure(root.id, { extensions: { add: ["read-only"] } });
+const answer = await agent.prompt("Where are checkout totals computed?");
+console.log(answer.status === "done" ? answer.text : `Unanswered: ${answer.reason}`);
+
+// Go back to the default selection, CodingTools and audit.
+await agent.conversation.configure(root.id, { extensions: null });
+const { extensions } = await agent.conversation.agent(root.id);
+console.log(extensions); // ["coding-tools", "audit"]
diff --git a/vendor/agents/examples/docs/extensions/read-only.ts b/vendor/agents/examples/docs/extensions/read-only.ts
new file mode 100644
index 00000000..c599771c
--- /dev/null
+++ b/vendor/agents/examples/docs/extensions/read-only.ts
@@ -0,0 +1,14 @@
+import { defineExtension, hook, section, ToolTask } from "@earendil-works/pi-durable";
+
+const changesFiles = new Set(["write", "edit", "bash"]);
+
+export const readOnly = defineExtension({
+ name: "read-only",
+ sections: [section("mode", () => "You are in read-only mode. Read files and explain them, but do not change anything.")],
+ hooks: [
+ hook(ToolTask, {
+ // A blocked call never runs. The model gets the message as an error result.
+ beforeTool: (call) => (changesFiles.has(call.name) ? { block: `${call.name} is turned off in read-only mode.` } : undefined),
+ }),
+ ],
+});
diff --git a/vendor/agents/examples/docs/extensions/server.ts b/vendor/agents/examples/docs/extensions/server.ts
new file mode 100644
index 00000000..1e7536ca
--- /dev/null
+++ b/vendor/agents/examples/docs/extensions/server.ts
@@ -0,0 +1,24 @@
+import { createRegistry } from "@earendil-works/pi-durable";
+import { CodingTools } from "@earendil-works/pi-durable/tools";
+import { pi } from "@rivet-dev/pi";
+import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
+import { setup } from "rivetkit";
+import { audit } from "./audit";
+import { readOnly } from "./read-only";
+
+const extensions = createRegistry();
+extensions.install(CodingTools);
+extensions.install(audit);
+extensions.install(readOnly);
+
+const agent = pi({
+ model: "anthropic/claude-opus-5-5",
+ registry: extensions,
+ sandbox: e2bProvider(),
+ // New conversations start without read-only. A client turns it on per conversation.
+ settings: { extensions: [CodingTools, audit] },
+});
+
+export const registry = setup({ use: { agent } });
+
+registry.start();
diff --git a/vendor/agents/examples/docs/react-sdk/useAgentChat.ts b/vendor/agents/examples/docs/react-sdk/useAgentChat.ts
index 5b7275df..4b273060 100644
--- a/vendor/agents/examples/docs/react-sdk/useAgentChat.ts
+++ b/vendor/agents/examples/docs/react-sdk/useAgentChat.ts
@@ -1,5 +1,5 @@
import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai";
-import type { AgentEvent, EntryRecord, SnapshotEvent } from "@earendil-works/pi-durable";
+import type { AgentEvent, ConversationId, EntryRecord, SnapshotEvent } from "@earendil-works/pi-durable";
import { createRivetKit } from "@rivetkit/react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { registry } from "../pi/server";
@@ -11,7 +11,7 @@ export type ChatMessage = { id: string; role: "user" | "assistant"; text: string
export function useAgentChat(key: string[]) {
const agent = useActor({ name: "agent", key });
const conn = agent.connection;
- const [rootId, setRootId] = useState(null);
+ const [rootId, setRootId] = useState(null);
const [messages, setMessages] = useState([]);
const [streaming, setStreaming] = useState("");
const [tool, setTool] = useState(null);
diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts
new file mode 100644
index 00000000..f024aedb
--- /dev/null
+++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts
@@ -0,0 +1,78 @@
+import { type Credential, createModels, InMemoryCredentialStore } from "@earendil-works/pi-ai";
+import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
+import { actor, type Registry, UserError } from "rivetkit";
+import type { credentials } from "./credentials";
+
+// A UUID you generate once for this deployment. OpenAI calls it the agent host ID.
+const HOST_ID = process.env.CHATGPT_HOST_ID ?? "";
+// Your app's name. Users see it on the ChatGPT consent screen.
+const APP_NAME = "Acme";
+
+interface PendingSignIn {
+ cancel: () => void;
+ pasteRedirect: (url: string) => void;
+ credential: Promise;
+}
+
+// One per user. Runs one sign-in at a time and saves the result to the user's credentials Actor.
+export const chatgptLogin = actor({
+ createVars: () => ({ pending: undefined as PendingSignIn | undefined }),
+ actions: {
+ // Starts a sign-in and returns the URL to open in the user's browser.
+ start: async (c) => {
+ c.vars.pending?.cancel();
+
+ const models = createModels({ credentials: new InMemoryCredentialStore() });
+ models.setProvider(openaiProvider());
+
+ const cancel = new AbortController();
+ const authUrl = Promise.withResolvers();
+ const redirect = Promise.withResolvers();
+ const credential = models.login(
+ "openai",
+ "oauth",
+ {
+ // Give up after ten minutes, or when the user starts over.
+ signal: AbortSignal.any([cancel.signal, AbortSignal.timeout(10 * 60_000)]),
+ notify: (event) => {
+ if (event.type === "auth_url") authUrl.resolve(event.url);
+ },
+ // pi-ai asks for the redirect URL the browser landed on. Rejecting on abort
+ // lets pi-ai close its callback server, so the next sign-in can start.
+ prompt: ({ signal }) =>
+ new Promise((resolve, reject) => {
+ signal?.addEventListener("abort", () => reject(new Error("Sign-in cancelled.")), { once: true });
+ redirect.promise.then(resolve);
+ }),
+ },
+ { getDeviceId: () => HOST_ID, agentName: APP_NAME },
+ );
+
+ c.vars.pending = { cancel: () => cancel.abort(), pasteRedirect: redirect.resolve, credential };
+ // Stay awake until the sign-in finishes, fails, or times out.
+ c.keepAwake(credential.catch(() => undefined));
+
+ // Rejects here if the sign-in fails before it has a URL.
+ return Promise.race([authUrl.promise, credential.then(() => authUrl.promise)]);
+ },
+
+ // Finishes the sign-in with the redirect URL the user pasted. Without one, it waits
+ // for the browser to reach the callback on its own, which works only when the worker
+ // runs on the same machine as the browser.
+ finish: async (c, redirectUrl?: string) => {
+ const pending = c.vars.pending;
+ if (!pending) throw new UserError("Start a sign-in first.");
+ if (redirectUrl) pending.pasteRedirect(redirectUrl);
+
+ try {
+ const credential = await pending.credential;
+ const client = c.client>();
+ await client.credentials.getOrCreate([c.key[0]]).save("openai", credential);
+ } catch (error) {
+ throw new UserError(error instanceof Error ? error.message : "ChatGPT sign-in failed.");
+ } finally {
+ c.vars.pending = undefined;
+ }
+ },
+ },
+});
diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts
new file mode 100644
index 00000000..8b6e1d53
--- /dev/null
+++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts
@@ -0,0 +1,21 @@
+import { createInterface } from "node:readline/promises";
+import { createClient } from "rivetkit/client";
+import type { registry } from "./server";
+
+const client = createClient();
+const userId = "user-123";
+
+// 1. Start the sign-in and send the user to ChatGPT.
+const login = client.chatgptLogin.getOrCreate([userId]);
+const url = await login.start();
+console.log(`Open this URL and choose Continue:\n${url}\n`);
+
+// 2. The browser lands on a 127.0.0.1 URL. The user copies it from the address bar.
+const terminal = createInterface({ input: process.stdin, output: process.stdout });
+const redirect = await terminal.question("Paste the URL you landed on, or press Enter if the page says you can close it: ");
+terminal.close();
+await login.finish(redirect.trim() || undefined);
+
+// 3. The agent now runs on the user's ChatGPT plan.
+const result = await client.agent.getOrCreate([userId, "chat"]).prompt("Say hi in five words.");
+console.log(result.status === "done" ? result.text : `Unanswered: ${result.reason}`);
diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts
new file mode 100644
index 00000000..062e4f82
--- /dev/null
+++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts
@@ -0,0 +1,31 @@
+import { type Credential, InMemoryCredentialStore } from "@earendil-works/pi-ai";
+import { ModelRuntime } from "@earendil-works/pi-coding-agent";
+import type { PiProviderCredential } from "@rivet-dev/pi";
+import { actor } from "rivetkit";
+
+// One per user. Holds the user's ChatGPT login and gives the agent fresh access tokens.
+export const credentials = actor({
+ state: { saved: {} as Record },
+ actions: {
+ save: (c, provider: string, credential: Credential) => {
+ c.state.saved[provider] = credential;
+ },
+ list: (c) => Object.entries(c.state.saved).map(([providerId, { type }]) => ({ providerId, type })),
+ read: (c, provider: string) => withoutRefreshToken(c.state.saved[provider]),
+ refresh: async (c, provider: string) => {
+ const store = new InMemoryCredentialStore();
+ await store.modify(provider, async () => c.state.saved[provider]);
+ const runtime = await ModelRuntime.create({ credentials: store, modelsPath: null });
+ await runtime.getAuth(provider, { minOAuthValidityMs: 10 * 60_000 });
+ const refreshed = await store.read(provider);
+ if (refreshed) c.state.saved[provider] = refreshed;
+ return withoutRefreshToken(refreshed);
+ },
+ },
+});
+
+function withoutRefreshToken(credential: Credential | undefined): PiProviderCredential | undefined {
+ if (credential?.type !== "oauth") return credential;
+ const { refresh: _refresh, ...rest } = credential;
+ return rest;
+}
diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts
new file mode 100644
index 00000000..7b3fee35
--- /dev/null
+++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts
@@ -0,0 +1,19 @@
+import { createRegistry } from "@earendil-works/pi-durable";
+import { pi } from "@rivet-dev/pi";
+import { type Registry, setup } from "rivetkit";
+import { chatgptLogin } from "./chatgpt-login";
+import { credentials } from "./credentials";
+
+const agent = pi({
+ model: "openai/gpt-6-sol",
+ registry: createRegistry(),
+ // The agent's key starts with the user id, so it runs on that user's ChatGPT plan.
+ credentials: (c) => {
+ const client = c.client>();
+ return client.credentials.getOrCreate([c.key[0]]);
+ },
+});
+
+export const registry = setup({ use: { credentials, chatgptLogin, agent } });
+
+registry.start();