Files
paseo/public-docs/plugins/providers.md
T
Mohamed Boudra 389db81784 feat(plugin): provider plugins declare option controls and receive option changes
A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
2026-11-02 00:29:50 +01:00

18 KiB

title, description, nav, order, category
title description nav order category
Build a provider plugin Add a coding agent to Paseo directly or adapt an ACP agent. Provider plugins 46 Plugins

Build a provider plugin

Start with the plugin quickstart 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 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: sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer;
  • 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: a renderer-only plugin that does not implement a provider.

Register a direct provider

Add a server entry:

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

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.

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:

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 for the read() and subscribe() contract.

Create the native session with the selected catalog values, then publish its effective state:

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:

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:

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 an unset key shows. A key you report at its defaultValue that nobody set stays unset, so the agent keeps following the default; report any other value and Paseo stores it. 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 user change arrives as session.configure with changes.providerOptions holding only the keys that changed. Apply the change and publish the committed session.config before request.completed, or fail the request with request.failed and leave every key as it was. 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.

Keys without a control are still delivered in session.open and stored. A provider-specific token budget, for example, belongs in providerOptions and is not a dedicated prompt field.

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:

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:

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:

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:

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:

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 to distribute the provider through npm or Git. Keep agent compatibility and releases in the plugin project.

See the plugin reference for the exact runtime and SVG rules.