Files
paseo/public-docs/plugins/providers.md
T
Mohamed Boudra 00ec4bc80c fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
2026-11-02 00:34:19 +01:00

501 lines
18 KiB
Markdown

---
title: Build a provider plugin
description: Add a coding agent to Paseo directly or adapt an ACP agent.
nav: Provider plugins
order: 46
category: Plugins
---
# Build a provider plugin
Start with the [plugin quickstart](/docs/plugins) if you have not built a Paseo plugin before.
A provider plugin connects a coding agent to Paseo without adding it to Paseo core. [Publish the plugin](/docs/plugins/publishing) on npm or in a Git repository so users can
install it with `paseo plugin install`.
Choose one implementation path:
| Agent | Path |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| It already implements ACP | Register it with `runAcpProvider()` and add only narrow vendor transformers. |
| It has a TypeScript SDK, JSON-RPC API, or custom process protocol | Implement `ProviderRegistration` directly. |
The examples are:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider.
## Register a direct provider
Add a server entry:
```ts
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createProvider } from "./server/provider";
export default function contribute(server: PluginServerContext) {
server.registerProvider(createProvider());
return () => {};
}
```
`createProvider()` returns one `ProviderRegistration`:
```ts
import {
negotiateProviderCapabilities,
type ProviderRegistration,
} from "@getpaseo/plugin/server/provider";
const supported = ["prompt.message"] as const;
export function createProvider(): ProviderRegistration {
return {
id: "my-agent",
label: "My agent",
icon: "icon.svg",
async connect(request) {
const capabilities = negotiateProviderCapabilities(request.capabilities, supported);
return createConnection(capabilities);
},
};
}
```
The connection has three operations:
- `send(input)` accepts work and returns. Results are events, not return values.
- `onEvent(listener)` publishes session state and complete timeline snapshots.
- `close()` stops every session and releases processes, subscriptions, and pending work.
Keep the native SDK, process, and stream inside the connection implementation. Convert its output
to `ProviderEvent` objects before publishing it.
## Launch the provider CLI
Use `spawnProcess` and `execCommand` from `@getpaseo/plugin/server` for process transports
and CLI probes. Both handle Windows `.cmd`/`.bat` launchers and quote shell arguments.
Raw Node `spawn` and `execFile` reject those launchers on Windows.
The daemon supplies `request.launch` with the resolved command, argument prefix, and complete
environment. Pass that environment explicitly; the helpers do not apply daemon environment policy.
Keep the process lifecycle and protocol deadlines inside your provider. End stdin for graceful
shutdown; use `terminateProcess(child)` for a forced stop. It kills the process tree on Windows,
including the CLI behind a command-script launcher. Bound your final wait for closed streams.
```ts
import { spawnProcess, execCommand } from "@getpaseo/plugin/server";
const child = spawnProcess(launch.command, [...launch.args, "serve"], {
env: launch.env,
cwd,
stdio: "pipe",
});
const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], {
env: launch.env,
timeout: 5000,
});
```
## Return models, modes, and thinking options
Paseo requests the catalog before creating a session. Return the choices needed by the agent form:
```ts
if (input.type === "catalog") {
emit({
type: "catalog",
requestId: input.requestId,
catalog: {
models: [
{ id: "agent-large", label: "Agent Large" },
{ id: "agent-small", label: "Agent Small" },
],
modes: [
{ id: "build", label: "Build" },
{ id: "plan", label: "Plan" },
],
thinkingOptions: [
{ id: "standard", label: "Standard" },
{ id: "extended", label: "Extended" },
],
defaultModel: "agent-large",
defaultMode: "build",
defaultThinkingOption: "standard",
},
});
}
```
Return an empty array for a category the agent does not expose. The selected `model`, `mode`, and
`thinkingOption` arrive in the session configuration.
The catalog describes choices available before a session exists. Session-specific controls come
later through `session.config`.
## Open a session
`session.open` contains the complete launch configuration: working directory, environment, system
prompt, MCP servers, tool policy, model, mode, `providerOptions`, and persistence
preference.
For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`.
Read it in catalog and session callbacks, and subscribe when the provider keeps in-memory
configuration. See [settings](./reference.md#persisted-values) for the `read()` and `subscribe()`
contract.
Create the native session with the selected catalog values, then publish its effective state:
```ts
const nativeSession = await sdk.createSession({
cwd: input.config.cwd,
model: input.config.model,
mode: input.config.mode,
thinking: input.config.thinkingOption,
systemPrompt: input.config.systemPrompt,
env: input.config.env,
mcpServers: input.config.mcpServers,
});
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: ["prompt.message"],
restoration: "core",
cwd: input.config.cwd,
});
emit({
type: "session.config",
sessionId: input.sessionId,
config: {
model: nativeSession.model,
mode: nativeSession.mode,
thinkingOption: nativeSession.thinking,
models,
modes,
thinkingOptions,
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
```
`session.config` contains the committed values. Publish what the native agent selected after
normalization, not a copy of the request.
## Complete the first prompt
Start with message prompts. Advertise `prompt.message`, pass the content to the native agent, and
publish the resulting lifecycle:
```ts
const turnId = crypto.randomUUID();
emit({
type: "timeline.item",
sessionId,
item: {
type: "user_message",
id: `user:${turnId}`,
text,
clientMessageId: prompt.clientMessageId,
},
});
emit({
type: "session.prompt_result",
sessionId,
clientMessageId: prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId, turnId, state: "started" });
// Publish complete snapshots as native output changes.
emit({
type: "timeline.item",
sessionId,
item: { type: "assistant_message", id: `assistant:${turnId}`, text: completeText },
});
emit({ type: "session.turn", sessionId, turnId, state: "completed" });
```
Publish exactly one `session.prompt_result` for each `clientMessageId`. Copy `clientMessageId` onto
the user timeline item so Paseo replaces its optimistic message. Every started turn needs one
terminal `completed`, `failed`, or `canceled` event.
At this point the provider is usable: users can select a model, open a session, send a message, and
see the response.
## Add composer controls for provider options
`providerOptions` is the provider's own configuration: one record of JSON values that Paseo
stores per agent and hands to `session.open`. It includes the defaults a user set in `config.json`
for your provider. Validate every key you read.
Add `controls` only when users should see and change some of those options in the composer. A
control draws one top-level key of `providerOptions` as a toggle or a select. It holds no value:
Paseo reads the value from `providerOptions`. Publish the controls and the values you run with in
`session.config`:
```ts
emit({
type: "session.config",
sessionId,
config: {
model: "agent-large",
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
key: "approval",
label: "Approvals",
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
`icon` is a Lucide icon name. `defaultValue` is what a control shows for a key that has no value.
Paseo stores every value you report when a session opens or a change is applied, including values
at the default and values nobody set. The same controls appear in the New workspace screen, in draft
tabs, and on running agents. Offer a control only for a key you can change on a running session.
The provider may change its controls at runtime, including after a model change.
A change arrives as `session.configure` with `changes.providerOptions` holding only the keys
that changed, whether or not they have a control. Apply every key and publish the committed
`session.config` before `request.completed`. Fail the request with `request.failed` and leave
every key as it was when you cannot apply one, for example a key you read only when a session
opens. A
value of `null` returns that key to the provider's default. Publish what the session ended up
with, not a copy of the request: Paseo stores your published values so the stored options match
what runs.
If the provider changes an option on its own, for example a model switch turns Fast off, publish
a new `session.config` with the new values. Paseo stores them and does not set the old ones
back.
Not every option needs a control. A provider-specific token budget, for example, belongs in
`providerOptions` and is not a dedicated prompt field. It is delivered in `session.open`, a caller
can change it on a running agent, and your `session.configure` handler decides whether it can
apply it live. Report it in `providerOptions` so Paseo stores what runs.
## Add persistence and replay
Advertise `session.persistence` when a native session can be reopened. Open a new native session
when `session.open.persistence` is absent. Resume the identified native session when it is present.
Return an opaque persistence value in `session.opened` or `session.persistence`; Paseo stores it
without inspecting it.
When `history` is `"replay"`, publish the native session's existing `timeline.item` snapshots before
`session.ready`. Use `history: "skip"` to open without replaying old rows.
Paseo refreshes an agent by closing its current provider session and opening it again with current
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
servers during `session.open`. There is no separate reload operation.
Conversation and combined rewind require a core-restored session with persistence. After a
successful rewind, Paseo closes the session and reopens its latest persistence value with
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
children. File-only rewind keeps the current session and conversation history.
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
recreate it while restoring the parent.
## Add commands and steering
All user input arrives through `session.prompt`:
- `input.type: "message"` carries text, images, and attachments;
- `input.type: "command"` carries a selected command and its arguments;
- `delivery: "steer"` asks you to add input to the active turn;
- `delivery: "auto"` lets you start a turn or complete a command without one.
Publish `session.commands` to add structured commands to the composer. A command can start a normal
turn or finish as a side effect with a `completed` prompt result.
Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo
can replace the active turn instead.
## Permit idle runtime cleanup
Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup.
Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the
session's `session.opened` capabilities. Without this capability, the same baseline and one-hour
idle period apply, and the plugin receives no safety request.
Handle the safety query in your existing `send(input)` handler. It addresses the provider's own
session ID, including when one connection serves several sessions:
```ts
case "session.can_stop_runtime": {
try {
const session = sessions.get(input.sessionId);
if (!session) throw new Error("Session not found");
emit({
type: "session.can_stop_runtime",
requestId: input.requestId,
sessionId: input.sessionId,
canStop: await session.canStopRuntime(),
});
} catch (error) {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
```
`canStopRuntime(): Promise<boolean>` belongs to your provider's session implementation. Return
`true` only when releasing the runtime loses no background shells, monitors, native children,
queued completion reports, or pending autonomous replies. This query must not stop or mutate the
session. Return `false` while work remains. A rejected query, invalid response, or timeout also
keeps the runtime alive. Never respond with `request.completed` for this query.
Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations,
working managed/native descendants, and outstanding local or remote finish subscriptions for the
caller. Subscription protection lasts through registration, recovery and notification dispatch
acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks
activity before the existing `session.close` operation. After protected work finishes, a fresh
one-hour idle period starts. The saved provider persistence must resume the same conversation.
The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider.
## Publish timeline items
Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text,
a running tool, or a todo list. Paseo derives the live delta and keeps the normal timeline.
Built-in item types cover user and assistant messages, reasoning, tools, todos, errors,
notifications, and compaction. Use a plugin item when your provider has a presentation that those
types cannot express:
```ts
emit({
type: "timeline.item",
sessionId,
item: {
type: "plugin",
id: "review-42",
pluginId: "my-provider-plugin",
kind: "review-verdict",
version: 1,
data: { verdict: "ship", summary: "All checks passed" },
},
});
```
Register the renderer independently in `index.client.tsx`:
```tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { z } from "zod";
import { ReviewVerdict } from "./client/review-verdict";
const reviewVerdictSchema = z.object({
verdict: z.enum(["ship", "hold"]),
summary: z.string(),
});
export default function contribute(client: PluginClientContext) {
client.addTimelineRenderer({
kind: "review-verdict",
version: 1,
schema: reviewVerdictSchema,
Component: ReviewVerdict,
});
return () => {};
}
```
The same renderer can display items emitted by a provider, a client timeline transformer, or a
daemon timeline append. A renderer does not require a provider implementation.
## Adapt an ACP agent
Use the ACP shim when the agent already speaks ACP:
```ts
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { runAcpProvider } from "@getpaseo/plugin/server/acp";
export default function contribute(server: PluginServerContext) {
server.registerProvider(
runAcpProvider({
id: "vendor-agent",
label: "Vendor agent",
icon: "icon.svg",
command: ["vendor-agent", "acp"],
}),
);
return () => {};
}
```
The shim owns the ACP process, capability mapping, session lifecycle, prompts, permissions, and
timeline conversion. Do not copy that machinery into the plugin.
Use `transformers` only for vendor differences ACP cannot describe. Validate vendor payloads with
Zod and leave malformed or unrelated values unchanged:
```ts
import type { AcpTransformer } from "@getpaseo/plugin/server/acp";
import { z } from "zod";
const editSchema = z.object({
path: z.string(),
before: z.string(),
after: z.string(),
});
export const vendorEdits: AcpTransformer = {
toolCall(toolCall) {
if (toolCall.name !== "vendor_file_edit") return toolCall;
const input = editSchema.safeParse(toolCall.input);
if (!input.success) return toolCall;
return {
...toolCall,
kind: "edit",
input: {
filePath: input.data.path,
oldString: input.data.before,
newString: input.data.after,
},
};
},
};
```
## Test and publish
Test the provider against the real agent, not only mocked frames:
1. install the plugin as a directory source;
2. confirm its SVG, models, modes, and option controls appear;
3. create a session and complete a real prompt;
4. exercise permissions, steering, cancellation, persistence, and replay when supported;
5. render every custom timeline item on desktop and mobile-width layouts;
6. reload and remove the plugin while a session is active and confirm the session terminates.
Follow [Publish a plugin](/docs/plugins/publishing) to distribute the provider through npm or Git.
Keep agent compatibility and releases in the plugin project.
See the [plugin reference](/docs/plugins/reference#providers) for the exact runtime and SVG
rules.