Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ docs/
sidebar.json
content/
docs/**.mdx -> /agents/docs/...
guides/*.mdx -> /guides/... (merged with other products' guides)
examples/
docs/<topic>/*.ts snippets embedded with <CodeSnippet>
```
Expand All @@ -43,10 +44,26 @@ Navigation for the bundle. Icons travel as Font Awesome **export names** (for
example `"faSquareInfo"`) or `{ "src": "/images/..." }` for website-hosted
images, so this repo needs no dependency on the website's icon package.

- `href` is the full site path the page renders at: `/agents/docs/...`.
- `href` is the full site path the page renders at: `/agents/docs/...` under
`docs`, `/guides/...` under `guides`.
- Adding a page to `content/` does not add it to the nav. Add it here too.
- Deploy and self-hosting guides are website-owned and generated there.

## Guides

The Guides tab at `/guides/` merges the guides of every product repo (this
repo, `rivet-dev/rivet`'s Actors bundle, and so on). Put a guide in the repo
whose code it teaches, so its `<CodeSnippet>` paths resolve against that repo's
`examples/`.

- Put a guide in `content/guides/<slug>.mdx` and link it from the `guides` key
of `sidebar.json` as `/guides/<slug>`. Slugs share one namespace across
repos, and the website build fails on a duplicate.
- `guides` is a list of groups. The website lists every repo's groups in
product order and merges groups with the same title.
- Do not add an overview page or link. The website owns `/guides/`.
- Link to a guide as `/guides/<slug>`, from docs pages and other guides alike.

## Code

- **Never inline a fenced TypeScript block.** Real examples live in
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/custom-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ npm add @earendil-works/pi-ai
<CodeSnippet file="examples/docs/custom-tools/server.ts" title="server.ts" />
</CodeGroup>

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.
Expand Down
102 changes: 102 additions & 0 deletions docs/content/docs/extensions.mdx
Original file line number Diff line number Diff line change
@@ -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.

<div style="overflow-x:auto">
<svg viewBox="0 0 660 236" role="img" aria-label="Your worker's registry holds three extensions: coding-tools, audit, and read-only. Conversation A selects coding-tools and audit. Conversation B selects all three, including read-only." style="width:100%;min-width:520px;max-width:660px;height:auto;display:block;margin:2.5rem auto;font-family:system-ui,sans-serif">
<defs>
<marker id="extensions-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="rgb(var(--site-ink, 27 25 22))"/></marker>
</defs>
<rect x="20" y="40" width="260" height="178" rx="10" style="fill:rgb(var(--site-paper-mid, 250 248 243));stroke:rgb(var(--site-pine, 46 64 52));stroke-width:1.4;stroke-dasharray:7 6"/>
<text x="20" y="30" font-size="11" font-family="ui-monospace, monospace" font-weight="600" letter-spacing="0.14em" style="fill:rgb(var(--site-pine, 46 64 52))">YOUR WORKER</text>
<text x="150" y="70" text-anchor="middle" font-size="14" font-weight="600" fill="#1b1916">Registry</text>
<g fill="#ffffff" stroke="#1b1916" stroke-width="1.4">
<rect x="50" y="86" width="200" height="30" rx="6"/>
<rect x="50" y="126" width="200" height="30" rx="6"/>
<rect x="50" y="166" width="200" height="30" rx="6"/>
</g>
<g text-anchor="middle" font-size="13" font-family="ui-monospace, monospace" fill="#1b1916">
<text x="150" y="106">coding-tools</text>
<text x="150" y="146">audit</text>
<text x="150" y="186">read-only</text>
</g>
<rect x="400" y="52" width="240" height="60" rx="7" fill="#c7e4fb" stroke="#3d9df3" stroke-width="2"/>
<rect x="400" y="146" width="240" height="60" rx="7" fill="#c7e4fb" stroke="#3d9df3" stroke-width="2"/>
<g text-anchor="middle">
<text x="520" y="77" font-size="14" font-weight="600" fill="#1b1916">Conversation A</text>
<text x="520" y="97" font-size="12" font-family="ui-monospace, monospace" fill="#56524a">coding-tools, audit</text>
<text x="520" y="171" font-size="14" font-weight="600" fill="#1b1916">Conversation B</text>
<text x="520" y="191" font-size="12" font-family="ui-monospace, monospace" fill="#56524a">coding-tools, audit, read-only</text>
</g>
<g stroke="rgb(var(--site-ink, 27 25 22))" stroke-width="1.4" fill="none">
<path d="M281 112 L398 82" marker-end="url(#extensions-arrow)"/>
<path d="M281 146 L398 176" marker-end="url(#extensions-arrow)"/>
</g>
<text x="340" y="133" text-anchor="middle" font-size="12" fill="#56524a">by name</text>
</svg>
</div>

| 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:

<CodeSnippet file="examples/docs/extensions/read-only.ts" title="read-only.ts" />

| 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:

<CodeSnippet file="examples/docs/extensions/audit.ts" title="audit.ts" />

`wrapSection` does the same for a prompt section.

## Choose extensions per conversation

<CodeGroup>
<CodeSnippet file="examples/docs/extensions/server.ts" title="server.ts" />
<CodeSnippet file="examples/docs/extensions/client.ts" title="client.ts" />
</CodeGroup>

- **`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.
2 changes: 1 addition & 1 deletion docs/content/docs/pi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/user-subscriptions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://github.com/rivet-dev/agents/tree/main/examples/docs/user-subscriptions" target="_blank" rel="noopener noreferrer">`examples/docs/user-subscriptions`</a>.
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 <a href="https://github.com/rivet-dev/agents/tree/main/examples/docs/user-subscriptions" target="_blank" rel="noopener noreferrer">`examples/docs/user-subscriptions`</a>.

## Share credentials across a team

Expand Down
143 changes: 143 additions & 0 deletions docs/content/guides/sign-in-with-chatgpt.mdx
Original file line number Diff line number Diff line change
@@ -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";

<ExampleLinkBar href="https://github.com/rivet-dev/agents/tree/main/examples/docs/sign-in-with-chatgpt" label="Prefer to read code? See the full example on GitHub." />

<div style="overflow-x:auto">
<svg viewBox="0 0 700 430" role="img" aria-label="The user calls start on the chatgptLogin Actor, which returns a sign-in URL. The user signs in at OpenAI and chooses Continue. OpenAI redirects the browser to a 127.0.0.1 URL. The user passes that URL to finish. The chatgptLogin Actor exchanges the code with OpenAI for tokens and saves the login to the user's credentials Actor." style="width:100%;min-width:600px;max-width:700px;height:auto;display:block;margin:2.5rem auto;font-family:system-ui,sans-serif">
<defs>
<marker id="siwc-request-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="rgb(var(--site-ink, 27 25 22))"/></marker>
<marker id="siwc-response-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" style="fill:rgb(var(--site-pine, 46 64 52))"/></marker>
</defs>
<rect x="20" y="20" width="120" height="44" rx="7" fill="#ffffff" stroke="#1b1916" stroke-width="1.4"/>
<rect x="190" y="20" width="140" height="44" rx="7" fill="#c7e4fb" stroke="#3d9df3" stroke-width="2"/>
<rect x="380" y="20" width="120" height="44" rx="7" fill="#ffffff" stroke="#1b1916" stroke-width="1.4"/>
<rect x="550" y="20" width="130" height="44" rx="7" fill="#c7e4fb" stroke="#3d9df3" stroke-width="2"/>
<g text-anchor="middle" font-size="14" font-weight="600" fill="#1b1916">
<text x="80" y="47">User</text>
<text x="260" y="47" font-family="ui-monospace, monospace" font-size="13">chatgptLogin</text>
<text x="440" y="47">OpenAI</text>
<text x="615" y="47" font-family="ui-monospace, monospace" font-size="13">credentials</text>
</g>
<g stroke="#8a8578" stroke-width="1.3" stroke-dasharray="5 5">
<line x1="80" y1="64" x2="80" y2="410"/>
<line x1="260" y1="64" x2="260" y2="410"/>
<line x1="440" y1="64" x2="440" y2="410"/>
<line x1="615" y1="64" x2="615" y2="410"/>
</g>
<g font-size="12" fill="#56524a" text-anchor="middle">
<text x="170" y="102" font-family="ui-monospace, monospace">start()</text>
<text x="170" y="142">sign-in URL</text>
<text x="350" y="182">sign in, Continue</text>
<text x="350" y="222">redirect to 127.0.0.1</text>
<text x="170" y="262" font-family="ui-monospace, monospace">finish(url)</text>
<text x="350" y="302">exchange code</text>
<text x="350" y="342">tokens</text>
<text x="527" y="382">save login</text>
</g>
<g stroke="rgb(var(--site-ink, 27 25 22))" stroke-width="1.4" fill="none">
<line x1="80" y1="110" x2="258" y2="110" marker-end="url(#siwc-request-arrow)"/>
<line x1="80" y1="190" x2="438" y2="190" marker-end="url(#siwc-request-arrow)"/>
<line x1="80" y1="270" x2="258" y2="270" marker-end="url(#siwc-request-arrow)"/>
<line x1="260" y1="310" x2="438" y2="310" marker-end="url(#siwc-request-arrow)"/>
<line x1="260" y1="390" x2="613" y2="390" marker-end="url(#siwc-request-arrow)"/>
</g>
<g stroke-width="1.4" stroke-dasharray="5 4" fill="none" style="stroke:rgb(var(--site-pine, 46 64 52))">
<line x1="260" y1="150" x2="82" y2="150" marker-end="url(#siwc-response-arrow)"/>
<line x1="440" y1="230" x2="82" y2="230" marker-end="url(#siwc-response-arrow)"/>
<line x1="440" y1="350" x2="262" y2="350" marker-end="url(#siwc-response-arrow)"/>
</g>
</svg>
</div>

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.
- <a href="https://developers.openai.com/siwc/token-sharing-open-source" target="_blank" rel="noopener noreferrer">ChatGPT plan usage</a>, OpenAI's guide to the flow.

<Note>
This flow is for open-source and self-hosted apps. To offer it in a paid or hosted app, fill out OpenAI's <a href="https://openai.com/form/sign-in-with-chatgpt-interest/" target="_blank" rel="noopener noreferrer">interest form</a>.
</Note>

<Steps>

<Step title="Install">

```sh
npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-coding-agent rivetkit
```

</Step>

<Step title="Create a host ID">

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.

</Step>

<Step title="Store the login">

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:

<CodeSnippet file="examples/docs/sign-in-with-chatgpt/credentials.ts" title="credentials.ts" />

</Step>

<Step title="Run the sign-in">

The `chatgptLogin` Actor drives pi-ai's Sign in with ChatGPT flow:

<CodeSnippet file="examples/docs/sign-in-with-chatgpt/chatgpt-login.ts" title="chatgpt-login.ts" />

- `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.

</Step>

<Step title="Define the agent">

<CodeSnippet file="examples/docs/sign-in-with-chatgpt/server.ts" title="server.ts" />

The agent reads the user's credential before every model call, so it can use OpenAI models as soon as the user signs in.

</Step>

<Step title="Connect from your app">

<CodeSnippet file="examples/docs/sign-in-with-chatgpt/client.ts" title="client.ts" />

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 <a href="https://developers.openai.com/siwc/ui-ux-guidelines" target="_blank" rel="noopener noreferrer">guidelines</a> require.

</Step>

</Steps>

## 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.

<Warning>
Protect both Actors with [authentication](/docs/authentication). `credentials` returns access tokens, and anyone who can call `finish` can save a login to that user.
</Warning>
16 changes: 16 additions & 0 deletions docs/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"title": "Instructions",
"href": "/agents/docs/instructions"
},
{
"title": "Extensions",
"href": "/agents/docs/extensions"
},
{
"title": "User Subscriptions",
"href": "/agents/docs/user-subscriptions"
Expand Down Expand Up @@ -169,5 +173,17 @@
}
]
}
],
"guides": [
{
"title": "Agents",
"pages": [
{
"title": "Sign in with ChatGPT",
"href": "/guides/sign-in-with-chatgpt",
"icon": "faOpenai"
}
]
}
]
}
16 changes: 16 additions & 0 deletions examples/docs/extensions/audit.ts
Original file line number Diff line number Diff line change
@@ -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);
},
})),
],
});
Loading
Loading