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/react2. Declare both entries
{
"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
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
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
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-counterOpen 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.