Skip to content

Overlay Packages & the Extension SDK

A pi package is the standard unit of distribution for pi extensions, skills, prompts, and themes. An overlay package is a normal pi package that also declares PizzaPi-specific capabilities — runner services, agents, rules, and MCP servers — under a pi.pizzapi key in its package.json.

The point of the overlay is that it is additive. One package installs cleanly in vanilla pi and in PizzaPi; vanilla pi simply ignores the pi.pizzapi block, and PizzaPi mounts the extra capabilities on top.


Everything PizzaPi-specific lives under pi.pizzapi in package.json:

package.json
{
"name": "@example/my-tools",
"version": "1.0.0",
"type": "module",
"pi": {
"skills": ["./skills"],
"pizzapi": {
"schemaVersion": 1,
"agents": ["./agents"],
"rules": ["./rules"],
"mcp": "./.mcp.json",
"services": [
{
"id": "my-service",
"label": "My Service",
"icon": "activity",
"entry": "./service/index.ts",
"panel": { "dir": "./service/panel" },
"triggers": "./service/triggers.json",
"sigils": "./service/sigils.json"
}
]
}
}
}
  • package.json
  • Directoryskills/
  • Directoryagents/
  • Directoryrules/
  • .mcp.json
  • Directoryservice/
    • index.ts
    • triggers.json
    • sigils.json
    • Directorypanel/
      • index.html

FieldTypePurpose
schemaVersion1Required. Pins the overlay schema.
servicesPizzaPiServiceDeclaration[]Runner services. Require a separate trust grant — see Trust.
agentsstring[]Directories of agent definitions.
rulesstring[]Directories of rules injected into session context.
mcpstringPath to an MCP server definition file.
FieldTypePurpose
idstringUnique service ID. Cannot collide with a built-in (terminal, file-explorer, git, memory, process, time, tunnel).
labelstringDisplay name in the web UI.
entrystringModule exporting the service handler.
iconstringOptional Lucide icon name.
panel.dirstringOptional directory of static panel assets.
panel.requiresPanelVariable[]Optional. One or more of PWD, SESSION_ID, HOME, USER, PROJECT_DIR.
triggersstring | ServiceTriggerDef[]Path to a JSON file or an inline array.
sigilsstring | ServiceSigilDef[]Path to a JSON file or an inline array.

triggers and sigils accept either form, so you can keep large definitions in their own file or inline a short list directly.

Values in an overlay MCP definition expand these tokens:

TokenExpands to
@PACKAGE_ROOT@The installed package’s root directory
@HOME@The user’s home directory
@PWD@Current working directory
@PROJECT_DIR@Project directory
@SESSION_ID@Active session ID
@USER@Current username

@PACKAGE_ROOT@ is what makes a package relocatable — use it for binaries and scripts shipped inside the package:

.mcp.json
{
"mcp": {
"servers": [
{
"name": "my-server",
"transport": "stdio",
"command": "@PACKAGE_ROOT@/bin/my-server",
"args": ["serve"],
"env": { "MY_CONFIG": "@HOME@/.config/my-tool.json" }
}
]
}
}

@pizzapi/extension-sdk is the public authoring contract — the TypeScript types and the host-detection helpers that overlay packages are written against.

import type {
// Overlay manifest
PizzaPiOverlayV1, PizzaPiServiceDeclaration, PanelVariable,
// Runner services
ServiceHandler, ServiceInitOptions, ServiceEnvelope, PizzaPiSocket,
ReconcileResult, ReconcileOptions,
TriggerSubscriptionEntry, TriggerSubscriptionDelta,
// Host detection
PizzaPiHostInfo, PizzaPiHostAPI,
} from "@pizzapi/extension-sdk";
import {
isPizzaPiHostInfo, detectPizzaPiHost, onPizzaPiHost,
} from "@pizzapi/extension-sdk";

Types are erased at build time; only the three host-detection helpers are runtime code.


A service handler registers socket listeners in init() and tears them down in dispose():

service/index.ts
import type { ServiceHandler, ServiceInitOptions, PizzaPiSocket } from "@pizzapi/extension-sdk";
export default function createService(): ServiceHandler {
let server: ReturnType<typeof Bun.serve> | null = null;
let onMessage: ((envelope: unknown) => void) | null = null;
return {
id: "my-service",
init(socket: PizzaPiSocket, options: ServiceInitOptions) {
onMessage = (envelope: any) => {
if (options.isShuttingDown()) return;
if (envelope.serviceId !== "my-service") return;
// handle envelope.type / envelope.payload
};
socket.on("service_message", onMessage);
// Port 0 lets the OS pick a free port; announce it so the UI can reach the panel.
server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
options.announcePanel?.(server.port);
},
dispose() {
server?.stop();
server = null;
onMessage = null;
},
};
}
MemberPurpose
isShuttingDown()Returns true once the daemon is tearing down. Check it before doing work.
announcePanel(port)Announce a panel HTTP server. Only provided to services declaring a panel.
announceSigilServer(port)Announce a sigil-resolve HTTP server for services with no UI panel.
HookWhen to implement
handleSessionEnded(sessionId)You hold per-session state (processes, buffers, temp files).
reconcileSubscriptions(subs, opts)You hold per-subscription runtime state such as timers or watchers. Called after runner reconnect with a snapshot, and on individual delta changes.

The same package may be loaded by vanilla pi, where no runner daemon, relay, or web UI exists. Use host detection so PizzaPi-only code paths simply don’t activate there.

detectPizzaPiHost() is a synchronous probe — it returns host info only if a PizzaPi host answers on the same tick:

import { detectPizzaPiHost } from "@pizzapi/extension-sdk";
export default function myExtension(pi) {
const host = detectPizzaPiHost(pi);
if (!host) return; // vanilla pi — degrade quietly
// PizzaPi-only setup here
}

Because packages can load before the host announces itself, prefer onPizzaPiHost(), which fires immediately if the host is already up and otherwise waits for its ready event. It delivers at most once and returns an unsubscribe function:

import { onPizzaPiHost } from "@pizzapi/extension-sdk";
export default function myExtension(pi) {
const unsubscribe = onPizzaPiHost(pi, (host) => {
if (!host.capabilities.includes("services")) return;
// safe to use PizzaPi capabilities
});
return { dispose: unsubscribe };
}

PizzaPiHostInfo carries apiVersion: 1 and a capabilities string array. Check capabilities before relying on a feature rather than assuming a version implies it. isPizzaPiHostInfo() is exported for validating a payload you received yourself.


Installing a package and letting it run daemon services are two separate decisions. Code in a runner service runs on the daemon, outside any session sandbox, so it always requires an explicit grant:

Terminal window
# Install and grant declared runner services in one step
pizza install ./my-package --allow-daemon-services
# Install now, decide later
pizza install ./my-package --no-allow-daemon-services
pizza config grant ./my-package
pizza config grant ./my-package my-service # grant one service
pizza config revoke ./my-package my-service

Grants are recorded in overlayServiceGrants in ~/.pizzapi/config.json.

ScopeAgents / rules / MCPRunner services
User (pizza install ...)YesYes, with a grant
Project (pizza install ... -l)Yes, after pi project trustNo — not in schema version 1

Project packages that declare services still install; PizzaPi warns that those services stay inactive. The daemon owns a single service registry shared by every workspace, so mounting a service found in one checkout would activate that project’s code for unrelated sessions. See Runner Services for the full scope rule.

Built-in service IDs (terminal, file-explorer, git, memory, process, time, tunnel) are reserved and always win. Between packages, the first to claim an ID in a discovery pass keeps it and later claimants are skipped with a warning.


Terminal window
pizza list
PizzaPi overlay:
● local:/path/to/my-package (user) — agents:1 rules:1 mcp services:[my-service:granted]

Confirm the daemon actually mounted it:

Terminal window
grep "loaded package service" ~/.pizzapi/logs/runner.log
[services] loaded package service "my-service" from local:/path/to/my-package