| name | creating-runner-services |
| description | Use when building a new runner service — background processes with optional UI panels, custom triggers agents can subscribe to, sigils that render inline references in the UI, or lifecycle-aware context injection into agent turns |
Creating Runner Services
Runner services are background processes on the runner daemon. Pick the right API for your needs:
| API | Directory | Use when |
|---|
| ExtensionProvider | ~/.pizzapi/providers/ | You need lifecycle hooks (session start/end, turn end), context injection into the LLM's system prompt, or session metadata attachment. Preferred for new services. |
| ServiceHandler | ~/.pizzapi/services/ | You only need a panel, triggers, or sigils — no lifecycle awareness. Simpler, less boilerplate for panel-only services. |
ExtensionProvider API (Recommended)
The ExtensionProvider interface gives services direct access to:
- Context injection — inject text into the LLM's system prompt before each turn
- Lifecycle hooks — react to session_start, turn_end, session_shutdown, session_close
- UI extension — sidebar widgets, metadata cards, and panels visible in the web UI
- Session metadata — attach typed metadata to session records
Folder Structure
~/.pizzapi/providers/<provider-id>/
index.ts # ExtensionProvider module (default export)
panel/ # Optional — static panel assets (HTML/CSS/JS)
ExtensionProvider Template
import type { ExtensionProvider, ProviderInitContext, ProviderContext } from "@pizzapi/cli/providers/types";
class MyProvider implements ExtensionProvider {
id = "my-provider";
capabilities = ["context", "lifecycle", "ui-panel", "metadata"] as const;
init(ctx: ProviderInitContext) {
}
dispose() {
}
async onBeforeAgentStart(event, ctx) {
return [
{
text: "Memory: use pnpm, not npm",
placement: "prepend",
order: 50,
dedupeKey: "pkg-mgr",
summary: "Package manager preference",
referencedArtifacts: [{
id: "mem-123",
type: "memory",
label: "Use pnpm"
}],
},
];
}
async onSessionStart(event, ctx) {
}
async onTurnEnd(event, ctx) {
}
async onSessionShutdown(event, ctx) {
}
async onSessionClose(event, ctx) {
return { label: "Finalizing memory…", jobRef: { jobId: "123" } };
}
get panel() {
return { dir: "./panel", requires: ["SESSION_ID", "PROJECT_DIR"] };
}
get sidebarWidgets() {
return [{
id: "my-timeline",
label: "Recent Activity",
source: { type: "html", dir: "./widgets/timeline" },
}];
}
get sessionMetadataCards() {
return [{
id: "my-stats",
label: "Provider Stats",
source: { type: "api", endpoint: "./api/stats" },
}];
}
async getSessionMetadata(sessionId, ctx) {
return {
activeDirectives: 3,
recentMemories: ["Use pnpm", "Write tests first"],
};
}
}
export default MyProvider;
Capability Interfaces
A provider implements capability interfaces by duck-typing. PizzaPi discovers what a provider supports by checking its method signatures. Declare which capabilities you implement in the capabilities array:
| Capability | Key methods/properties |
|---|
"context" | onBeforeAgentStart(event, ctx) → ContextContribution[] |
"lifecycle" | onSessionStart, onTurnEnd, onSessionClose, onSessionShutdown |
"ui-panel" | panel, sidebarWidgets, sessionMetadataCards |
"metadata" | getSessionMetadata(sessionId, ctx) → Record<string, unknown> |
Provider Configuration
Providers can be configured in ~/.pizzapi/config.json:
{
"providers": {
"my-provider": {
"enabled": true,
"dbPath": "/custom/path/to/db.sqlite"
}
}
}
The config object is passed to ProviderInitContext.config during init(). Set "enabled": false to disable a provider.
Trust gate: Project-local providers (.pizzapi/providers/) are disabled by default. Set "allowProjectProviders": true in config.json to enable them for a specific project.
ProviderContext Fields
Every hook method receives ProviderContext:
| Field | Description |
|---|
signal | AbortSignal — respect this to cancel work |
timeoutMs | Timeout budget for this hook (default 5000ms) |
sessionId | Current session ID |
sessionFile | Path to session JSONL file |
cwd | Working directory |
promptId | Stable across all turns of one user prompt (turn-scoped only) |
turnId | Incrementing turn index within the current prompt |
isFirstTurn | true only for turn 0 |
Error Isolation
PizzaPi catches provider errors and continues without the failing provider's contribution. After 3 consecutive errors, a provider is temporarily disabled for the remainder of the session. Errors are logged and surfaced in the provider's panel.
SessionStart Model Information
onSessionStart now receives the active model in the event payload. This lets providers and hooks gate behavior on the model at session start — for example, adjusting context injection strategy per-model.
async onSessionStart(event, ctx) {
console.log(`Session started with model: ${event.model?.id ?? 'unknown'}`);
}
The same model information is also available to native hooks via the SessionStart hook:
{
"hooks": {
"SessionStart": [{
"command": "echo $PIZZAPI_MODEL_ID > /tmp/session-model.txt"
}]
}
}
Hook scripts receive JSON on stdin:
{
"event": "SessionStart",
"reason": "startup",
"previous_session_file": null,
"session_id": "abc123",
"model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514", "name": "Claude 4 Sonnet" }
}
When no model is available yet, model is null. This is a fire-and-forget hook — exit codes are ignored.
ServiceHandler API (Legacy)
Use the ServiceHandler interface for simple services that only need a panel, triggers, or sigils — no lifecycle awareness.
manifest.json
{
"id": "my-service",
"label": "My Service",
"icon": "activity",
"entry": "./index.ts",
"panel": {
"dir": "./panel",
"requires": ["PROJECT_DIR", "SESSION_ID"]
},
"triggers": [
{
"type": "my-service:something_happened",
"label": "Something Happened",
"description": "Emitted when something noteworthy occurs",
"schema": {
"type": "object",
"properties": {
"itemId": { "type": "string" },
"timestamp": { "type": "number" }
}
},
"params": [
{
"name": "itemId",
"label": "Item ID",
"type": "string",
"description": "Only receive events for this specific item",
"required": false
}
]
}
],
"sigils": [
{
"type": "item",
"label": "Item",
"description": "Reference an item from My Service",
"resolve": "/api/resolve/item/{id}"
}
]
}
| Field | Required | Default | Description |
|---|
id | Yes | | Unique service ID (must match ServiceHandler.id) |
label | Yes | | Button label shown in the PizzaPi header bar |
icon | No | "square" | Lucide icon name (kebab-case) |
entry | No | ./index.ts | Service module path relative to folder |
panel.dir | No | ./panel | Panel static files directory (omit if no panel) |
panel.requires | No | [] | Variable names the panel needs resolved as query params (e.g. ["PROJECT_DIR", "SESSION_ID"]) |
triggers | No | [] | Array of trigger type definitions (see below). Can also live in triggers.json. |
sigils | No | [] | Array of sigil type definitions (see below). Can also live in sigils.json. |
Variable Substitution
The entry and panel.dir fields support @VARIABLE@ tokens that are expanded at load time. This lets services reference paths relative to the session, project, or home directory without hardcoding absolute paths.
Additionally, panel.requires declares which variables the panel needs at runtime. The daemon resolves these values and passes them as query parameters to the panel's iframe URL (e.g., ?projectDir=/path&sessionId=abc). This is the recommended way to pass session context to panels — prefer it over @VARIABLE@ in static file paths when the value changes per session.
| Variable | Resolves to |
|---|
@PWD@ | Current working directory |
@SESSION_ID@ | Current session ID (PIZZAPI_SESSION_ID env var) |
@HOME@ | User home directory ($HOME) |
@USER@ | Current username ($USER) |
@PROJECT_DIR@ | Project directory (PIZZAPI_PROJECT_DIR env var, falls back to cwd) |
Example — project-relative entry point:
{
"id": "project-watcher",
"label": "Project Watcher",
"entry": "@PROJECT_DIR@/scripts/watcher.ts",
"panel": {
"dir": "@HOME@/.pizzapi/services/project-watcher/panel"
}
}
Unknown variables are left as-is (not replaced). These same variables are available across MCP server configs and hooks as well.
Trigger Definitions
Each entry in triggers declares a trigger type this service can emit:
| Field | Required | Description |
|---|
type | Yes | Namespaced trigger type, e.g. "my-service:event_name" |
label | Yes | Human-readable label for the UI and agent tools |
description | No | When/why this trigger fires |
schema | No | JSON Schema describing the trigger payload |
params | No | Array of configurable parameters for subscriber filtering (see below) |
Trigger Parameters
Triggers can declare params — configurable values that subscribers provide when subscribing. At broadcast time, delivery is filtered: a subscriber only receives the trigger if every param they specified matches the corresponding field in the trigger payload. Subscribers with no params receive all events (wildcard).
Each entry in params:
| Field | Required | Description |
|---|
name | Yes | Parameter name — must match a key in the trigger payload |
label | Yes | Human-readable label for the UI |
type | No | Value type: "string" (default), "number", "boolean", or "json" |
description | No | Help text for the subscriber |
required | No | If true, subscriber must provide this param |
default | No | Default value if not provided |
enum | No | Array of allowed values — renders as a dropdown in the UI |
multiselect | No | If true (requires enum), subscriber can pick multiple values. Subscribers send an actual JSON array, the UI renders selected values as chips, and delivery matches if the payload value is in the selected set (OR semantics). |
+Use type: "json" when the subscription param should carry an arbitrary JSON value such as an object or array. The UI renders a JSON textarea for these params and forwards the parsed value to the service unchanged.
Example — scalar param with enum:
{
"type": "github:pr_comment_added",
"label": "PR Comment Added",
"params": [
{ "name": "prNumber", "label": "PR Number", "type": "number", "required": true },
{ "name": "repo", "label": "Repository", "type": "string", "enum": ["pizzapi", "pi-mono", "docs"] }
]
}
An agent subscribes with: subscribe_trigger(triggerType: "github:pr_comment_added", params: { prNumber: 42, repo: "pizzapi" })
Only events with prNumber: 42 and repo: "pizzapi" in their payload are delivered.
Example — JSON param:
{
"type": "review:requested",
"label": "Review Requested",
"params": [
{ "name": "config", "label": "Config", "type": "json", "description": "Arbitrary review routing config" }
]
}
An agent subscribes with: subscribe_trigger(triggerType: "review:requested", params: { config: { reviewers: ["jordanpizza"], labels: ["bug"], dryRun: true } })
The service receives the parsed object exactly as provided.
Example — multiselect param:
{
"type": "demo:message_sent",
"label": "Message Sent",
"params": [
{ "name": "channel", "label": "Channels", "type": "string", "enum": ["general", "alerts", "debug"], "multiselect": true }
]
}
An agent subscribes with: subscribe_trigger(triggerType: "demo:message_sent", params: { channel: ["alerts", "debug"] })
Events with channel: "alerts" or channel: "debug" in their payload are delivered. Events with channel: "general" are not. Sessions subscribed without specifying channel receive all events.
Important contract:
multiselect only works when enum is also declared
- subscribers send a real JSON array, not a comma-separated string
- matching is currently subscriber array vs payload scalar (
params.channel = ["alerts", "debug"] matches payload channel: "alerts")
- array-valued payload fields also work with scalar subscription params: if the payload has
labels: ["bug", "urgent"], a subscriber with labels: "bug" receives the event
- if you need arbitrary freeform lists (for example usernames not known ahead of time),
multiselect is the wrong fit today unless you can declare those values in enum
For substring filtering, name the param with a Contains suffix. For example, a trigger with bodyContains will match when the payload's body field includes the subscriber's text.
Trigger types are advertised to agents via service_announce so they can be discovered with list_available_triggers() and subscribed to with subscribe_trigger().
Split file note: Triggers can live in a separate triggers.json file (bare array or { "triggers": [...] } format). When triggers.json exists, it takes precedence over inline triggers in manifest.json.
sigils.json
Define sigil types the service teaches the UI to render as [[type:id]] inline tokens.
Can be a bare array or wrapped in { "sigils": [...] }.
[
{
"type": "pr",
"label": "Pull Request",
"description": "A GitHub pull request reference",
"resolve": "/api/resolve/pr/{id}",
"aliases": ["pull-request", "mr"]
},
{
"type": "commit",
"label": "Commit",
"resolve": "/api/resolve/commit/{id}"
}
]
| Field | Required | Description |
|---|
type | Yes | Sigil type name used in [[type:id]] syntax |
label | Yes | Human-readable label for the UI |
description | No | What this sigil represents |
resolve | No | API path to resolve a sigil ID to display data (e.g. PR number → title) |
schema | No | JSON Schema for valid sigil params |
aliases | No | Alternative type names that resolve to this sigil |
When sigils.json exists, it takes precedence over inline sigils in manifest.json.
ServiceHandler Template
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import type { Server } from "bun";
function readRunnerId(): string | null {
try {
const home = process.env.HOME || homedir();
const raw = JSON.parse(readFileSync(join(home, ".pizzapi", "runner.json"), "utf-8"));
return typeof raw?.runnerId === "string" ? raw.runnerId : null;
} catch { return null; }
}
function resolveRelayUrl(): string {
const home = process.env.HOME || homedir();
let raw = process.env.PIZZAPI_RELAY_URL?.trim();
if (!raw) {
try {
const cfg = JSON.parse(readFileSync(join(home, ".pizzapi", "config.json"), "utf-8"));
if (typeof cfg?.relayUrl === "string" && cfg.relayUrl !== "off") raw = cfg.relayUrl.trim();
} catch { }
}
raw = raw || "http://localhost:7492";
if (raw.startsWith("ws://")) return raw.replace(/^ws:/, "http:").replace(/\/$/, "");
if (raw.startsWith("wss://")) return raw.replace(/^wss:/, "https:").replace(/\/$/, "");
return raw.replace(/\/$/, "");
}
function getApiKey(): string | null {
return process.env.PIZZAPI_RUNNER_API_KEY ?? process.env.PIZZAPI_API_KEY ?? null;
}
async function broadcastTrigger(
type: string,
payload: Record<string, unknown>,
opts?: { deliverAs?: "steer" | "followUp"; summary?: string },
): Promise<void> {
const runnerId = readRunnerId();
const apiKey = getApiKey();
if (!runnerId || !apiKey) return;
await fetch(`${resolveRelayUrl()}/api/runners/${runnerId}/trigger-broadcast`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
body: JSON.stringify({
type,
payload,
source: "my-service",
deliverAs: opts?.deliverAs ?? "followUp",
summary: opts?.summary,
}),
}).catch(err => console.error("[my-service] trigger broadcast failed:", err));
}
class MyService {
get id() { return "my-service"; }
#server: Server | null = null;
init(_socket: any, { announcePanel }: any) {
const panelDir = join(dirname(fileURLToPath(import.meta.url)), "panel");
const indexHtml = readFileSync(join(panelDir, "index.html"), "utf-8");
this.#server = Bun.serve({
port: 0,
fetch: async (req) => {
const url = new URL(req.url);
if (url.pathname.endsWith("/api/data")) {
const projectDir = url.searchParams.get("projectDir") || "unknown";
const sessionId = url.searchParams.get("sessionId") || "unknown";
return Response.json({
hello: "world",
projectDir,
sessionId,
}, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (url.pathname.endsWith("/api/resolve/item/abc-123")) {
return Response.json({
id: "abc-123",
title: "Example Item",
href: "https://example.com/items/abc-123",
subtitle: "Open in My Service",
}, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (url.pathname.endsWith("/api/do-thing") && req.method === "POST") {
void broadcastTrigger("my-service:something_happened", {
itemId: "abc-123",
timestamp: Date.now(),
}, { summary: "A thing happened" });
return Response.json({ ok: true }, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return new Response(indexHtml, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
},
});
if (announcePanel) {
announcePanel(this.#server.port);
}
}
dispose() {
if (this.#server) {
this.#server.stop(true);
this.#server = null;
}
}
}
export default MyService;
Firing Triggers
Triggers are broadcast via the relay's HTTP API:
POST /api/runners/{runnerId}/trigger-broadcast
Headers: x-api-key: {apiKey}, Content-Type: application/json
Body: {
"type": "my-service:something_happened",
"payload": { "itemId": "abc-123", "timestamp": 1711600000 },
"source": "my-service",
"deliverAs": "followUp",
"summary": "Human-readable description"
}
| Field | Required | Description |
|---|
type | Yes | Must match a type declared in manifest triggers[] |
payload | Yes | Arbitrary JSON object delivered to subscribers |
source | No | Identifier shown in trigger history (e.g. service name) |
deliverAs | No | "steer" (interrupts current turn) or "followUp" (queues after turn, default) |
summary | No | Human-readable one-liner for trigger history |
The relay fans out to all sessions subscribed to that trigger type on this runner.
Where to get runnerId and apiKey:
runnerId — read from ~/.pizzapi/runner.json (written by the daemon on startup)
apiKey — from PIZZAPI_API_KEY or PIZZAPI_RUNNER_API_KEY env vars
relayUrl — from PIZZAPI_RELAY_URL env var or relayUrl in ~/.pizzapi/config.json
Agent Interaction
Once a service advertises triggers, agents can:
- Discover —
list_available_triggers() returns all triggers from runner services
- Subscribe —
subscribe_trigger("my-service:something_happened") starts receiving events
- Receive — triggers arrive as injected messages in the agent's conversation
- Unsubscribe —
unsubscribe_trigger("my-service:something_happened") stops delivery
Panel HTML Guidelines
The panel renders inside a 280px-tall iframe. Key constraints:
Reading panel.requires query params — if your manifest declares panel.requires, the UI appends them to the iframe URL as query params. Read them in the panel:
<script>
const params = new URLSearchParams(location.search);
const projectDir = params.get("projectDir");
const sessionId = params.get("sessionId");
fetch(`./api/state?${params.toString()}`)
.then(r => r.json())
.then(data => console.log(data));
</script>
Services Without Panels
A service doesn't need a panel. Omit panel from manifest.json and skip the announcePanel() call. The service still runs in the background and can fire triggers:
{
"id": "my-watcher",
"label": "File Watcher",
"entry": "./index.ts",
"triggers": [
{ "type": "my-watcher:file_changed", "label": "File Changed" }
]
}
How It Works
ExtensionProvider
1. Daemon discovers folder in ~/.pizzapi/providers/
2. Reads index.ts → validates capabilities against declared methods
3. Calls provider.init(config) → provider initializes its DB/state
4. On session_start → bridge fires onSessionStart on all providers
5. On before_agent_start → bridge collects ContextContribution[], sorts, dedupes, injects into system prompt
6. On turn_end → bridge fires onTurnEnd for incremental indexing
7. On session archival → daemon calls onSessionClose for final flush
8. On session_shutdown → bridge fires onSessionShutdown, then calls provider.dispose()
ServiceHandler
1. Daemon discovers folder in ~/.pizzapi/services/
2. Reads manifest.json → extracts panel metadata + trigger definitions
3. Loads service module → calls init(socket, { announcePanel })
4. Service starts Bun.serve() on port 0 → calls announcePanel(port)
5. Daemon aggregates all trigger defs and sigil defs from all services
6. Daemon emits service_announce with panels[] + triggerDefs[] + sigilDefs[]
7. UI renders iframe; agents discover triggers via list_available_triggers()
8. Service fires triggers via POST /api/runners/{runnerId}/trigger-broadcast
9. Relay fans out to all subscribed sessions
Quick Reference
| Task | How |
|---|
| Declare triggers | Add triggers[] to manifest.json or triggers.json |
| Declare sigils | Add sigils[] to manifest.json or sigils.json |
| Resolve sigils | Expose an API route matching each sigil's resolve template |
| Fire a trigger | POST /api/runners/{runnerId}/trigger-broadcast with API key |
| Serve static files | Bun.serve() with readFileSync for index.html |
| Expose an API | Add route checks in the fetch handler |
| Get a random port | Bun.serve({ port: 0 }) then read .port |
| Announce the panel | Call announcePanel(server.port) in init() |
| Match PizzaPi theme | Use #0a0a0b bg, #e4e4e7 text, #27272a borders |
| Choose an icon | Browse lucide.dev/icons, use kebab-case name |
Common Mistakes
| Mistake | Fix |
|---|
| Triggers declared but never fired | Use the relay broadcast API — console.log doesn't deliver triggers |
| Sigils declared but not resolvable | Implement the resolve API route or omit resolve until you have one |
| Missing runnerId or apiKey | Read from ~/.pizzapi/runner.json and env vars at call time, not init time |
Forgetting announcePanel() | Panel won't appear in UI — always call it after server starts |
| Using absolute API URLs | Tunnel proxy rewrites paths; use relative URLs (./api/...) |
Not cleaning up server in dispose() | Call server.stop(true) to avoid port leaks |
| Large panel height assumptions | Panel container is 280px tall — design accordingly |
Missing Access-Control-Allow-Origin | Iframe requests need CORS headers on API responses |