diff --git a/docs/plugins.md b/docs/plugins.md
index 46ece9307a..3490b908c5 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -121,14 +121,16 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
-Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
-`@getpaseo/plugin`. 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`.
+Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
+host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
+client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
+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` | hooks and UI types |
+| `@getpaseo/plugin` | host UI components, hooks, and UI types |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server
diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts
index 238ab2e69f..a4975a2046 100644
--- a/packages/app/src/plugins/evaluate.test.ts
+++ b/packages/app/src/plugins/evaluate.test.ts
@@ -333,6 +333,28 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function");
});
+ it("provides the host Icon component through @getpaseo/plugin", () => {
+ const plugin = evaluatePluginClientBundle(
+ "example",
+ `(function(require) {
+ const { Icon } = require("@getpaseo/plugin");
+ const module = { exports: {} };
+ module.exports.default = function(plugin) {
+ plugin.addSurface("main", function Surface() {
+ return Icon({ name: "Settings", size: 18, color: "#123456" });
+ });
+ return function() {};
+ };
+ return module.exports;
+ })`,
+ );
+
+ const Component = plugin.surfaces[0]?.Component;
+ expect(Component).toBeTypeOf("function");
+ const element = (Component as (props: never) => { props: unknown })({} as never);
+ expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
+ });
+
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts
index ed528f10aa..8e7a5e5f04 100644
--- a/packages/app/src/plugins/evaluate.ts
+++ b/packages/app/src/plugins/evaluate.ts
@@ -24,7 +24,7 @@ import {
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
-import { resolvePluginIcon } from "./icons";
+import { Icon, resolvePluginIcon } from "./icons";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -247,6 +247,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
return {
defineAttachmentSource,
defineRpc,
+ Icon,
usePaseo,
useAgent,
useWorkspace,
diff --git a/packages/app/src/plugins/icons.test.ts b/packages/app/src/plugins/icons.test.ts
new file mode 100644
index 0000000000..4a4969d6fb
--- /dev/null
+++ b/packages/app/src/plugins/icons.test.ts
@@ -0,0 +1,18 @@
+import { Settings } from "lucide-react-native";
+import { describe, expect, it } from "vitest";
+import { Icon } from "./icons";
+
+describe("Icon", () => {
+ it("renders a host Lucide icon with the requested presentation", () => {
+ expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
+ type: Settings,
+ props: { size: 18, color: "#123456" },
+ });
+ });
+
+ it("renders nothing for an unknown icon name", () => {
+ expect(Icon({ name: "NotALucideIcon" })).toBeNull();
+ expect(Icon({ name: "Icon" })).toBeNull();
+ expect(Icon({ name: "createLucideIcon" })).toBeNull();
+ });
+});
diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts
index de36d962d6..1e3fdb4cbf 100644
--- a/packages/app/src/plugins/icons.ts
+++ b/packages/app/src/plugins/icons.ts
@@ -1,11 +1,24 @@
+import { createElement, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
+import type { PluginIconProps } from "@getpaseo/plugin";
import type { LucideIcon } from "lucide-react-native";
-export function resolvePluginIcon(name: string): LucideIcon {
+function findPluginIcon(name: string): LucideIcon | null {
const candidate = Reflect.get(LucideIcons, name);
+ if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
- if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
- return candidate as LucideIcon;
+ return isComponent ? (candidate as LucideIcon) : null;
+}
+
+export function resolvePluginIcon(name: string): LucideIcon {
+ const icon = findPluginIcon(name);
+ if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
+ return icon;
+}
+
+export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
+ const icon = findPluginIcon(name);
+ return icon ? createElement(icon, { size, color }) : null;
}
diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts
index 8655a1ca01..11ae66466d 100644
--- a/packages/cli/src/commands/plugin/scaffold.test.ts
+++ b/packages/cli/src/commands/plugin/scaffold.test.ts
@@ -79,6 +79,7 @@ export async function inspectConfig(
`import React from "react";
import { Text } from "react-native";
import {
+ Icon,
type PluginAgentPanelProps,
useAgent,
usePaseo,
@@ -91,7 +92,7 @@ export function Surface() {
source: { kind: "directory", path: "/repo" },
});
void createWorkspace;
- return Paseo API;
+ return <>Paseo API>;
}
export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts
index 643fad81db..ee15dbe12f 100644
--- a/packages/cli/src/commands/plugin/scaffold.ts
+++ b/packages/cli/src/commands/plugin/scaffold.ts
@@ -103,6 +103,12 @@ declare module "@getpaseo/plugin" {
export interface PluginSurfaceProps extends PluginHostProps {}
+ export interface PluginIconProps {
+ name: string;
+ size?: number;
+ color?: string;
+ }
+
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
@@ -261,6 +267,8 @@ declare module "@getpaseo/plugin" {
export type PluginCleanup = () => void | Promise;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
+ export const Icon: ComponentType;
+
export function useRpc(
contract: PluginRpcContract,
): (input: ZodInput) => Promise>;
diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts
index 43b472a4af..1faf4924c4 100644
--- a/packages/plugin/src/contracts.ts
+++ b/packages/plugin/src/contracts.ts
@@ -34,6 +34,12 @@ export interface PluginHostProps {
export interface PluginSurfaceProps extends PluginHostProps {}
+export interface PluginIconProps {
+ name: string;
+ size?: number;
+ color?: string;
+}
+
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 9f7ed808ae..67dc1b512f 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -1,3 +1,6 @@
+import type { ComponentType } from "react";
+import type { PluginIconProps } from "./contracts.js";
+
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
@@ -7,6 +10,8 @@ export {
type PluginAttachmentSearchPayload,
type PluginRpcContract,
} from "./server.js";
+
+export declare const Icon: ComponentType;
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
@@ -21,6 +26,7 @@ export type {
PluginHandlerContext,
PluginHostProps,
PluginOpenPanelOptions,
+ PluginIconProps,
PluginPanelLocation,
PluginTheme,
PluginSidebarContribution,
diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts
index df35eff59d..27c6121d3a 100644
--- a/packages/server/src/server/plugins/compiler.test.ts
+++ b/packages/server/src/server/plugins/compiler.test.ts
@@ -83,7 +83,7 @@ describe("plugin author module externals", () => {
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
- `import type { PluginContext } from "${sdk}";
+ `import { Icon, type PluginContext } from "${sdk}";
import { defineRpc } from "${sdk}/server";
import { z } from "zod";
@@ -93,8 +93,13 @@ const ping = defineRpc({
output: z.object({ ok: z.boolean() }),
});
+function Surface() {
+ return Icon({ name: "Settings", size: 18 });
+}
+
export default function contribute(plugin: PluginContext) {
plugin.handle(ping, async () => ({ ok: true }));
+ plugin.addSurface("main", Surface);
return () => undefined;
}
`,
@@ -103,6 +108,8 @@ export default function contribute(plugin: PluginContext) {
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain(`${sdk}/server`);
expect(serverBundle).toContain(`${sdk}/server`);
+ expect(clientBundle).toContain("Settings");
+ expect(serverBundle).not.toContain("Settings");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
expect(serverBundle).not.toContain("Invalid plugin RPC method");
},
diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts
index 89c848f957..31eac46b15 100644
--- a/packages/server/src/server/plugins/plugin-process.ts
+++ b/packages/server/src/server/plugins/plugin-process.ts
@@ -62,7 +62,13 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
-const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
+const pluginAuthorRuntime = {
+ defineAttachmentSource,
+ defineRpc,
+ Icon() {
+ throw new Error("Icon is available only in plugin client code");
+ },
+};
function runtimeRequire(name: string): unknown {
if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime;
diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md
index a19c2e5ad3..f4ebca30e5 100644
--- a/public-docs/plugins/reference.md
+++ b/public-docs/plugins/reference.md
@@ -67,15 +67,15 @@ Paseo builds separate client and server bundles from `index.ts`. It rejects impo
Paseo provides these modules to client code:
-| Module | Use it for |
-| ------------------------- | ----------------------------------- |
-| `@getpaseo/plugin` | Hooks and UI types |
-| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
-| `@tanstack/react-query` | Request state and caching |
-| `react` | Components and hooks |
-| `react/jsx-runtime` | Compiled JSX |
-| `react-native` | Cross-platform UI |
-| `zod` | Shared schemas |
+| Module | Use it for |
+| ------------------------- | --------------------------------------- |
+| `@getpaseo/plugin` | Host UI components, hooks, and UI types |
+| `@getpaseo/plugin/server` | Shared RPC and attachment contracts |
+| `@tanstack/react-query` | Request state and caching |
+| `react` | Components and hooks |
+| `react/jsx-runtime` | Compiled JSX |
+| `react-native` | Cross-platform UI |
+| `zod` | Shared schemas |
These exact module specifiers use the host's runtime instances. A client bundle that requests another host module fails with `Module "" is not available in plugin client code`.
@@ -166,6 +166,16 @@ export default function contribute(plugin: PluginContext) {
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.
+Use the host-provided `Icon` component for Lucide icons inside client surfaces. It renders the icon from Paseo's installed Lucide version, so plugin bundles do not import `lucide-react-native` or `react-native-svg`. An unknown name renders nothing instead of failing the plugin surface.
+
+```tsx
+import { Icon } from "@getpaseo/plugin";
+
+;
+```
+
+Keep `Icon` in a `*.client.tsx` module.
+
## Timeline items
A plugin can replace a projected timeline entry with its own data and React Native renderer. Both