Files
paseo/docs/plugins.md
Mohamed Boudra ad67c7030c docs(plugins): publish client entry guides
Restore the public client-entry reference, migration guide, links, and migration-table drift test when the feature is ready to launch.
2026-09-02 23:08:09 +02:00

21 KiB

Plugins

Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, client slash commands, timeline items, composer pills, app themes, and composer attachment sources. Paseo executes index.server.ts in a subprocess and index.client.tsx in every connected app.

Trust every plugin you add. paseo plugin add and paseo plugin install mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With --host, preparation runs on that remote daemon host.

Install a directory source

Create a typecheckable plugin project, install its development dependencies, then install it into the daemon. init only writes the project files; it does not run the package manager.

paseo plugin init /absolute/path/to/my-plugin
cd /absolute/path/to/my-plugin
npm install
npm run typecheck
paseo plugin install /absolute/path/to/my-plugin
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
paseo plugin ls

The daemon stores directory sources under the root plugins object:

{
  "pluginsEnabled": true,
  "plugins": {
    "my-plugin": {
      "source": "directory",
      "path": "/absolute/path/to/my-plugin",
      "enabled": true
    }
  }
}

The plugin system is disabled unless pluginsEnabled is true. Changing that root field is runtime-safe: run paseo reload after editing config.json. Enabling starts every configured, enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries remain lifecycle-owned and do not reload from manual config edits.

The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned directories, and local typechecking support. At least one entry is required.

my-plugin/
  paseo-plugin.json
  package.json
  tsconfig.json
  index.client.tsx
  index.server.ts
  client/greeting.tsx
  server/greeting.ts
  shared/greeting.ts

The generated package.json installs @getpaseo/plugin and the other host modules as development dependencies for local typechecking and tests. Paseo compiles TypeScript and TSX and supplies the runtime modules, so consumers do not install these packages when adding the plugin.

{
  "id": "my-plugin"
}

The config key is the runtime plugin ID. The manifest ID is the default selected during install; --id overrides it. Existing configuration is not renamed when the manifest changes, and the runtime does not compare the two IDs. The same directory can be installed under several config keys.

Never enable plugins on a user's behalf without explicit permission. Before asking, check the target daemon's current pluginsEnabled value. State that plugins are trusted, unsandboxed code: backend code can access the daemon machine, while client contributions run inside the Paseo app.

Source changes are explicit. Run paseo plugin reload <id> to stop and fully tear down the old plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore the old code. Use enable, disable, and remove to manage one plugin. Removing a directory source never deletes it. The global pluginsEnabled switch remains available.

Install a Git source

GitHub repositories use an owner/repository shorthand. Other hosts use a Git URL. An existing directory always wins over shorthand resolution.

paseo plugin add owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add https://git.example.com/owner/repository.git
paseo plugin add owner/monorepo:plugins/review
paseo plugin add owner/repository --ref main
paseo plugin status
paseo plugin update review
paseo plugin update --all

Append :relative/path to the source when the plugin lives below the repository root.

Omitting --ref tracks the remote's default branch. A branch passed with --ref also tracks; tags and commits stay pinned. status fetches tracked refs and reports the installed and available commits. Removing a Git source deletes Paseo's managed checkout.

Declare Git preparation

Most plugins should omit build. Use it only when the staged checkout must install a dependency that Paseo does not provide, generate source or assets, or perform another required preparation step:

{
  "id": "review",
  "build": [
    ["npm", "ci"],
    ["npm", "run", "build"]
  ]
}

build is an optional list of argv arrays. Each array must contain at least one non-empty string; shell command strings are rejected. Paseo starts the executable directly, without a shell, from the plugin directory in the staged checkout. It never detects lockfiles or chooses a package manager.

On install and every update, Paseo resolves the exact Git revision and manifest, runs the declared commands, then validates, compiles, and activates the candidate. It logs each argv command and its output in the daemon log. If a command fails, the error includes its output, Paseo discards the candidate, and the existing installed and running version stays untouched. On a remote daemon, all of this happens on the remote daemon host.

Server contributions can write to stdout and stderr with normal Node logging. Paseo adds [paseo] entries for loading, ready, stopping, and stopped transitions. Compilation and load failures are recorded as stderr entries before a subprocess exists. Inspect the recent in-memory tail from the host plugin settings or with paseo plugin logs <id>. Git preparation commands are recorded in $PASEO_HOME/daemon.log before a plugin exists, rather than the plugin log tail. Reload, disable, and process failure retain the tail; removing the plugin clears it. Daemon restarts do not retain the tail, but structured copies remain in $PASEO_HOME/daemon.log. Plugin output can contain secrets, so do not log credentials or tokens.

Contribute behavior and UI

Default export one contribution function from each runtime entry. Keep the entries to registration wiring. Runtime code lives behind directory boundaries:

Path Owns
index.client.tsx and client/ React, React Native, hooks, styles, surfaces, panels, and callbacks.
index.server.ts and server/ Node APIs, filesystem and process access, credentials, and handlers.
shared/ Zod RPC contracts and plain values used by both runtimes.

Do not put any other code modules in the plugin root.

Shared files import contract helpers and types from @getpaseo/plugin. Server handler files import PluginHandlerContext from @getpaseo/plugin/server. Client files import Paseo UI from @getpaseo/plugin/react-native. Its Icon resolves a Lucide name using the client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface. Its controlled modal keeps presentation metadata on <Modal title="…" icon={…}> and body UI in <Modal.Content>. Plugin UI runs on desktop and mobile across multiple themes: color every Text from theme.colors.foreground or theme.colors.foregroundMuted, and size layout from layout.compact. See public-docs/plugins/reference.md.

Module Use it for
@getpaseo/plugin contribution contracts, shared definitions, RPC input/output types, and client data hooks
@getpaseo/plugin/react-native Paseo React Native components and UI hooks
@getpaseo/plugin/server handler-only types such as PluginHandlerContext

The compiler rejects a client import of server/, a server import of client/, and every node: import reachable from client code. Shared modules cannot import runtime-owned modules. A relative import to any other code file in the plugin root is also rejected; move it into client/, server/, or shared/. These are compile errors naming the importing file and boundary rule. Top-level React Native calls such as StyleSheet.create belong in client/.

The scaffold omits "DOM" from tsconfig.json and does not use /// <reference lib="dom" />, so browser globals are not available across the plugin. Put sanctioned web-only APIs in client/web.ts, declare only the globals that module uses, gate each export with Platform.OS === "web", and provide a native implementation or no-op. See the public plugin reference for the complete pattern.

// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greetRpc } from "./shared/greeting";

export default function contribute(server: PluginServerContext) {
  server.handle(greetRpc, createGreeting);
  return () => {};
}
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { Greeting } from "./client/greeting";

export default function contribute(client: PluginClientContext) {
  client.addSurface("main", Greeting);
  client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
  return () => {};
}

The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.

Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts, and render error boundary. The contributed component owns the complete body below the header.

RPC contracts validate inputs and outputs in both the app and plugin subprocess. useRpc returns a typed async function. Use the host-provided @tanstack/react-query for request state and caching; Paseo gives each plugin installation its own query client.

usePaseo() and the handler's { paseo } context expose the same PaseoApi: projects, workspaces, agents, providers, and daemon config. They do not expose connection lifecycle. A surface borrows the selected host's existing connection; switching the screen's host changes both usePaseo() and useRpc() to that host. An offline selected host fails there and never falls through to another installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.

Each subprocess gets an exclusively owned plugin:<id> session. That identity is reserved from normal clients, never resumes another session, and is cleaned immediately on exit without reconnect grace. During daemon startup, plugin sessions may connect while application WebSockets remain paused; the daemon accepts clients only after configured plugins have settled and the initial catalog is complete.

When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and query cache. Plugin code cannot address another host.

Workspace panels, Command Center items, and client slash commands are client contributions. The daemon transports their compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted plugin workspace-tab target, so reload, disable, removal, and restoration resolve through the current installed-plugin catalog. A missing contribution renders unavailable inside the tab. Panels declare locations: ["workspace", "explorer"] to opt into Explorer hosting; omission means workspace only. Location controls hosting, not context. An agent panel target keeps its agentId when moved between hosts. Explorer configuration can create workspace-context panels and remove existing agent-context instances, but it cannot create an agent panel without an agent-aware command.

Command Center callbacks use the selected host's existing PaseoApi for normal Paseo operations. They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose optional client-owned agent and workspace navigation; its absence is the compatibility gate for older clients. Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not receive Expo Router or workspace-layout store access.

Contribute composer pills

Add and remove targeted pills from the client entry lifecycle. index.client.tsx runs once per plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or surface.

export function contributeClient(client: PluginClientContext) {
  const pills = new Map<string, () => void>();
  const unsubscribe = client.paseo.agents.subscribe((update) => {
    if (update.kind !== "upsert" || !update.agent.workspaceId) return;
    const { id: agentId, workspaceId } = update.agent;
    pills.get(agentId)?.();
    pills.set(
      agentId,
      client.addComposerPill({
        id: "review",
        title: "Open review",
        workspaceId,
        agentId,
        Component: ReviewPill,
        async onPress() {
          await client.rpc(refreshReview, { agentId });
          client.openPanel("review", { workspaceId, agentId });
        },
      }),
    );
  });
  return () => {
    unsubscribe();
    for (const remove of pills.values()) remove();
  };
}

Call contributeClient(client) from index.client.tsx, or move its body into that entry. addComposerPill returns an idempotent removal function. A pill appears only in the matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable, shared chrome, pending state, error reporting, and placement. The component owns its icon and text; the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting the host, or unloading the app tears down the contribution.

Contribute timeline items

Timeline transformers and renderers are client contributions. The daemon's canonical rows and built-in projection stay unchanged. The app transforms each source item while building the render model, for both fetched history and live events. The input includes phase: "streaming" | "complete". Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so streaming updates preserve mounted component identity.

query.itemType selects one public AgentTimelineItem.type. The callback owns any detailed recognition and returns plain plugin item objects. undefined keeps the source item, items replaces it, and an empty array removes it. Output data must be JSON-compatible. Paseo adds the runtime plugin ID, preserves the source timeline cursor and identity, validates renderer data with its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An optional output id distinguishes several stable replacements from the same source item; its output index is the default.

Transformers run synchronously and must be deterministic. When several transformers match, the first one that returns a result owns that source item. Plugin and registration ordering is stable. See plugin-examples/timeline-items for the complete contract.

A plugin subprocess can also append a canonical plugin row from a server handler:

await paseo.agents.ref(agentId).timeline.append({
  type: "plugin",
  id: "review",
  kind: "review-result",
  version: 1,
  data: { status: "ready" },
});

The daemon stamps the runtime pluginId; plugin code never supplies it. Reusing the same id replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon restart. A row without an installed renderer shows an unavailable placeholder. Serialized data must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot be rendered intact. The daemon advertises this RPC through server_info.features.pluginTimelineItems.

Contribute client slash commands

addSlashCommand registers an agent- or workspace-context command in the composer. The callback runs in the app, receives the trimmed text after the command name as args, and receives the same paseo, rpc, openSurface, workspace, agent, and openPanel capabilities as the matching Command Center callback.

client.addSlashCommand({
  name: "review",
  description: "Run the review bot",
  argumentHint: "[scope]",
  context: "agent",
  async onSubmit({ args, agent, rpc }) {
    await rpc(startReview, { agentId: agent.id, scope: args });
  },
});

Paseo owns the autocomplete row, input clearing, and error toast. It never sends the command text to the agent. Built-in client commands win name and alias collisions, plugin commands win provider-command collisions, and the first plugin in stable catalog order wins collisions between plugins. Plugin slash commands do not run when the composer has attachments.

Contribute composer attachments

Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu, search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots; credentials and vendor API calls stay in the daemon handler.

// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";

export default function contribute(server: PluginServerContext) {
  server.handle(searchIssues, search);
  return () => {};
}
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { issues } from "./shared/issues";

export default function contribute(client: PluginClientContext) {
  client.addAttachmentSource(issues);
  return () => {};
}

Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral external-resource presentation, so it remains readable if the plugin is removed or an older peer drops the optional presentation fields.

Contribute a theme

addTheme takes a small light or dark palette and a display name. Paseo expands it through the same semantic builders as the built-in themes, so plugins do not depend on the complete app token contract. Unistyles needs every theme name at StyleSheet.configure time, so packages/app/src/styles/theme.ts reserves one light and one dark plugin slot. The appearance provider rewrites the matching slot when the selection changes. See unistyles.md for the runtime-patching rules the appearance settings share.

addTheme is a client registration and belongs in index.client.tsx. A client that predates it cannot evaluate that entry. Daemons advertise features.pluginThemes in server_info; the plugin theme catalog is the one place the app reads it, and a host without it contributes no themes.

The selection persists as theme: "plugin" plus a pluginThemeId of <pluginId>/theme/<themeId>, so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer that id with different palettes, so picking a theme records its host through rememberPluginContributionHost and resolution prefers it; a peer connecting or dropping then does not repaint the app. Without a preference the sorted registry snapshot decides, so the result is stable rather than arrival-ordered. The app resolves that id against the installed catalog on every change; an id nothing contributes falls back to the default preference instead of painting the reserved slot's placeholder colors.

Existing plugin authors should follow the standalone runtime-entry migration guide.

See plugin-examples/local-plugin for a native surface, plugin-examples/linear for a complete attachment-source example, plugin-examples/timeline-items for timeline projection, and plugin-examples/catppuccin for a theme.