Skip to content

Extension guides

Build your first renderer

This package keeps a counter in the Pi process and adds a compact button to NativePi's composer row. The shared protocol types and validates every value crossing between them.

1. Create the package

mkdir nativepi-counter
cd nativepi-counter
bun init -y
bun add @nativepi/extension-api
bun add -d typescript @types/react

2. Declare both entries

package.json
{
  "name": "nativepi-counter",
  "version": "1.0.0",
  "type": "module",
  "keywords": ["pi-package"],
  "dependencies": {
    "@nativepi/extension-api": "^1.0.0"
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "typescript": "^6.0.0"
  },
  "pi": {
    "extensions": ["./src/extension.ts"]
  },
  "nativepi": {
    "renderer": "./src/renderer.tsx"
  }
}

3. Define the protocol

src/protocol.ts
import { defineProtocol } from "@nativepi/extension-api";
import { z } from "@nativepi/extension-api/schema";

const counterState = z.object({ count: z.number().int().nonnegative() });

export const counterProtocol = defineProtocol({
  methods: {
    state: { result: counterState },
    increment: {
      params: z.object({ by: z.number().int().positive() }),
      result: counterState,
    },
  },
  events: {
    changed: counterState,
  },
});

The exact method names, arguments, results, event names, and payloads are now available to both entries through TypeScript inference. The schemas also run at the process boundary.

4. Connect the Pi entry

src/extension.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { connect } from "@nativepi/extension-api/host";
import { counterProtocol } from "./protocol.ts";

export default function counterExtension(pi: ExtensionAPI) {
  let count = 0;

  const host = connect("nativepi-counter", counterProtocol, {
    state: () => ({ count }),
    increment: ({ by }) => {
      count += by;
      const state = { count };
      host.emit("changed", state);
      return state;
    },
  });

  pi.registerCommand("counter", {
    description: "Show the current counter",
    handler: async (_args, context) => {
      context.ui.notify(`Count: ${count}`, "info");
    },
  });
}

5. Define the renderer

src/renderer.tsx
import { useEffect, useState } from "react";
import { defineRenderer } from "@nativepi/extension-api";
import type { RendererContext } from "@nativepi/extension-api";
import { Badge, Button } from "@nativepi/extension-api/ui";
import { counterProtocol } from "./protocol.ts";

function Counter({ context }: { context: RendererContext<typeof counterProtocol> }) {
  const { call, on } = context.channel;
  const [count, setCount] = useState(0);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let active = true;
    void call("state")
      .then((state) => {
        if (active) setCount(state.count);
      })
      .catch((reason) => {
        if (active) setError(String(reason));
      });
    const unsubscribe = on("changed", (state) => {
      setCount(state.count);
      setError(null);
    });
    return () => {
      active = false;
      unsubscribe();
    };
  }, [call, on]);

  if (error) return <span style={{ color: "var(--destructive)" }}>Counter unavailable: {error}</span>;

  return (
    <Button
      variant="ghost"
      onClick={async () => {
        try {
          const state = await call("increment", { by: 1 });
          setCount(state.count);
        } catch (reason) {
          setError(String(reason));
        }
      }}
    >
      Count <Badge variant="secondary">{count}</Badge>
    </Button>
  );
}

export default defineRenderer({
  apiVersion: 1,
  protocol: counterProtocol,
  composerControls: [
    {
      id: "counter",
      render: (context) => <Counter context={context} />,
    },
  ],
});

6. Load the package

Install the local directory through NativePi's package settings, or add it with Pi and reload packages:

pi install /absolute/path/to/nativepi-counter

Open a project in NativePi. The counter appears beside the model and thinking controls. If the renderer fails to compile or its API version is incompatible, NativePi shows a package load error while the ordinary Pi extension continues to load.

Next steps