Skip to content

Extending

Extension API

A Pi extension reaches the NativePi window by importing @nativepi/extension-api, describing the slots it contributes, and default-exporting the result of defineRenderer.

Install

bun add @nativepi/extension-api

React is a peer dependency (^18.3.1 || ^19.0.0). NativePi provides both React and this package at runtime, so your components share NativePi's React instance rather than bundling a second one.

Declaring a renderer

Add a nativepi.renderer entry to your Pi package manifest, pointing at a browser entry file. NativePi compiles that entry with esbuild and mounts what it exports.

package.json
{
  "name": "my-pi-package",
  "nativepi": {
    "renderer": "./src/renderer.tsx"
  }
}

defineRenderer

The entry's default export. It is an identity function that exists for its types: it gives you completion and checking on the object you hand back.

function defineRenderer(renderer: NativePiRenderer): NativePiRenderer;

interface NativePiRenderer {
  tools?: Record<string, ToolRenderer>;
  entries?: Record<string, EntryRenderer>;
  composerWidgets?: ComposerWidget[];
  panels?: Panel[];
}

Every field is optional. Extensions contribute to controlled slots only: they cannot replace the composer, the transcript, the sidebar, or routing. NativePi places extension output behind an error boundary, but render functions should still avoid throwing because a failure can prevent that slot from mounting.

NativePiContext

Every renderer and every slot receives the same context object.

interface NativePiContext {
  session: {
    projectDir: string;
    sessionFile?: string;
    sessionName?: string;
  } | null;
  dark: boolean;
}

session is null when no project is active. With a project open, sessionFile remains undefined until a conversation is selected.darkreports the window's appearance; NativePi is currently dark only, so treat a future light value as something to handle rather than something to assume.

Tool renderers

Keyed by tool name. When Pi reports a call to that tool, your component draws it in place of the default tool container.

type ToolRenderer = (props: {
  call: ToolCall;
  result?: ToolResult;
  ctx: NativePiContext;
}) => ReactNode;

interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, unknown>;
}

interface ToolResult {
  toolName: string;
  text: string;
  details?: unknown;
  isError: boolean;
}

result is undefined while the call is still running, which is the state to design for first. Respect isError: a failed call should read as failed, because in the default interface it is the loudest element of a turn.

Example

src/renderer.tsx
import { defineRenderer } from "@nativepi/extension-api";

export default defineRenderer({
  tools: {
    "db.query": ({ call, result }) => {
      const sql = String(call.arguments.sql ?? "");

      if (!result) return <RunningQuery sql={sql} />;
      if (result.isError) return <QueryError sql={sql} text={result.text} />;

      return <ResultTable sql={sql} rows={result.details} />;
    },
  },
});

Entry renderers

Keyed by session entry type. Use these when your extension writes its own entries into the session and you want them drawn as something other than raw text.

type EntryRenderer = (props: {
  entry: SessionEntry;
  ctx: NativePiContext;
}) => ReactNode;

interface SessionEntry {
  id: string;
  type: string;
  [key: string]: unknown;
}

Composer widgets

A widget sits directly above or below the composer. This is the slot for state that belongs to the next message rather than to the transcript.

interface ComposerWidget {
  key: string;
  placement: "aboveComposer" | "belowComposer";
  render: (ctx: NativePiContext) => ReactNode;
}

Context panels

A titled panel added to the context pane, alongside Git changes and diffs. This is the slot with the most room, and the right home for anything a reader consults rather than reads inline.

interface Panel {
  key: string;
  title: string;
  render: (ctx: NativePiContext) => ReactNode;
}

version

The package exports the version extensions see at runtime, which is useful when you need to branch on what the host supports.

import { version } from "@nativepi/extension-api";
// e.g. "0.1.1"

A complete renderer

src/renderer.tsx
import { defineRenderer } from "@nativepi/extension-api";

export default defineRenderer({
  tools: {
    "db.query": ({ call, result, ctx }) => (
      <ResultTable
        sql={String(call.arguments.sql ?? "")}
        rows={result?.details}
        projectDir={ctx.session?.projectDir}
      />
    ),
  },

  entries: {
    "migration.applied": ({ entry }) => (
      <MigrationBadge name={String(entry.name ?? "")} />
    ),
  },

  composerWidgets: [
    {
      key: "target-db",
      placement: "aboveComposer",
      render: (ctx) => <TargetDatabase projectDir={ctx.session?.projectDir} />,
    },
  ],

  panels: [
    {
      key: "schema",
      title: "Schema",
      render: (ctx) =>
        ctx.session ? (
          <SchemaTree dir={ctx.session.projectDir} />
        ) : (
          <p>Open a project to inspect its schema.</p>
        ),
    },
  ],
});

Practical notes

  • Handle the empty case. ctx.session is null with no project active; with an empty project,sessionFile is undefined. Panels render regardless.
  • Match the surrounding density. The window is compact by design. A component built at marketing-page scale will look wrong next to everything around it.
  • Keep motion honest. The app neutralizes animation under prefers-reduced-motion and expects every animated state to have a static equivalent.
  • Normal Pi extensions need none of this. Without a nativepi.renderer entry, your extension runs inside Pi exactly as it does on the command line.

The whole public surface is one small file. Read it at packages/extension-api.