Skip to content

Runner Services

Runner services are background processes on the runner daemon. They can expose interactive UI panels in the PizzaPi web interface and advertise custom triggers that agent sessions can subscribe to.

A service ships inside a pi package that declares it under pi.pizzapi.services. That is the only way to load one — a service runs on the daemon, outside any session sandbox, so it needs an explicit trust grant that a package install can carry and a loose file cannot.

Service panel showing a System Monitor with CPU, memory, disk, and process information


  1. Create the package:

    Terminal window
    mkdir -p ~/my-service/service/panel
    cd ~/my-service
  2. Declare the service in package.json:

    package.json
    {
    "name": "@me/my-service",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "pi": {
    "pizzapi": {
    "schemaVersion": 1,
    "services": [
    {
    "id": "my-service",
    "label": "My Service",
    "icon": "activity",
    "entry": "./service/index.ts",
    "panel": { "dir": "./service/panel" },
    "triggers": [
    {
    "type": "my-service:something_happened",
    "label": "Something Happened",
    "description": "Emitted when something noteworthy occurs"
    }
    ]
    }
    ]
    }
    }
    }
  3. Add service/index.ts with a ServiceHandler class (see ServiceHandler API below).

  4. Add service/panel/index.html with self-contained HTML/CSS/JS (see Panel Guidelines below).

  5. Install and grant the service:

    Terminal window
    pizza install ~/my-service --allow-daemon-services
  6. Restart the runner — the panel button appears in the session toolbar header, and triggers become discoverable by agents.


  • Directorymy-service/
    • package.json — the pi.pizzapi.services declaration
    • Directoryservice/
      • index.ts — ServiceHandler module (default export)
      • triggers.json — trigger definitions (optional)
      • sigils.json — sigil type definitions (optional)
      • Directorypanel/
        • index.html — self-contained UI (HTML/CSS/JS)
        • — additional static assets

Layout inside the package is up to you — only the paths named in the declaration matter, and they are confined to the package root.


Each entry in pi.pizzapi.services describes one service:

package.json
{
"id": "my-service",
"label": "My Service",
"icon": "activity",
"entry": "./service/index.ts",
"panel": { "dir": "./service/panel" },
"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" }
}
}
}
]
}
FieldRequiredDefaultDescription
idYesUnique service ID (must match ServiceHandler.id)
labelYesButton label shown in the PizzaPi header bar
iconNo"square"Lucide icon name (kebab-case)
entryNo"./index.ts"Service module path, relative to the package root
panel.dirNoPanel static files directory (omit if no panel)
triggersNo[]Inline definitions, or a path to a JSON file (see Custom Triggers)
sigilsNo[]Inline definitions, or a path to a JSON file (see Custom Sigils)

For services with many triggers or sigils, point triggers and sigils at their own JSON files instead of inlining the arrays in package.json.

package.json
{
"id": "my-service",
"label": "My Service",
"entry": "./service/index.ts",
"triggers": "./service/triggers.json",
"sigils": "./service/sigils.json"
}
  • Directorymy-service/
    • package.json — core identity (id, label, icon, entry, panel)
    • Directoryservice/
      • triggers.json — trigger definitions (optional)
      • sigils.json — sigil type definitions (optional)
      • index.ts — ServiceHandler module
      • Directorypanel/
        • index.html — self-contained UI (HTML/CSS/JS)

Can be a bare array or an object with a triggers key:

[
{
"type": "my-service:event_happened",
"label": "Event Happened",
"description": "Fired when an event occurs",
"schema": {
"type": "object",
"properties": {
"path": { "type": "string" }
}
}
}
]

Or wrapped in an object:

{
"triggers": [
{
"type": "my-service:event_happened",
"label": "Event Happened"
}
]
}

Defines sigil types this service teaches the UI to render. Can be a bare array or an object with a sigils key:

[
{
"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}"
}
]
FieldRequiredDescription
typeYesSigil type name (used in [[type:id]] syntax)
labelYesHuman-readable label
descriptionNoWhat this sigil represents
resolveNoAPI endpoint path for resolving sigil IDs to display data
schemaNoJSON Schema for sigil params
aliasesNoAlternative type names that resolve to this sigil
  1. Copy the triggers array out of the service declaration into a new triggers.json file (as a bare JSON array).

  2. Replace the triggers value with the path to that file.

  3. (Optional) Do the same for sigils.

  4. Restart the runner — the daemon resolves the paths automatically.

Before — everything inline:

package.json
{
"id": "my-service",
"label": "My Service",
"icon": "activity",
"entry": "./service/index.ts",
"panel": { "dir": "./service/panel" },
"triggers": [
{ "type": "my-service:event_a", "label": "Event A" },
{ "type": "my-service:event_b", "label": "Event B" }
]
}

After — split into focused files:

package.json
{
"id": "my-service",
"label": "My Service",
"icon": "activity",
"entry": "./service/index.ts",
"panel": { "dir": "./service/panel" },
"triggers": "./service/triggers.json"
}
triggers.json
[
{ "type": "my-service:event_a", "label": "Event A" },
{ "type": "my-service:event_b", "label": "Event B" }
]

Services can advertise custom trigger types that agent sessions subscribe to at runtime. This is the primary mechanism for services to push events into agent conversations.

Add a triggers array to the service declaration. Each entry describes a trigger type:

FieldRequiredDescription
typeYesNamespaced trigger type, e.g. "my-service:event_name"
labelYesHuman-readable label for the UI and agent tools
descriptionNoWhen/why this trigger fires
schemaNoJSON Schema describing the trigger payload
paramsNoArray of subscription params the service accepts. Each: name, label, type (string/number/boolean/json), description, required, default, enum, multiselect
"triggers": [
{
"type": "my-service:file_changed",
"label": "File Changed",
"description": "A watched file was modified",
"schema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"changeType": { "type": "string", "enum": ["created", "modified", "deleted"] }
}
}
}
]

Trigger types are advertised to all connected viewers and agent sessions via the service_announce event when the runner starts.

To deliver a trigger to subscribed sessions, POST to the relay’s broadcast endpoint:

POST /api/runners/{runnerId}/trigger-broadcast
await fetch(`${relayUrl}/api/runners/${runnerId}/trigger-broadcast`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify({
type: "my-service:file_changed",
payload: { path: "/src/app.ts", changeType: "modified" },
source: "my-service",
deliverAs: "followUp",
summary: "File changed: /src/app.ts",
}),
});
FieldRequiredDescription
typeYesMust match a type declared in the manifest triggers[] array
payloadYesArbitrary JSON object delivered to subscribers
sourceNoIdentifier shown in trigger history (typically the service name)
deliverAsNo"steer" interrupts the current turn; "followUp" queues after the turn ends. Default: "followUp".
summaryNoHuman-readable one-liner for trigger history
expectsResponseNoWhether the delivered trigger expects a response from the agent/viewer

The relay fans out the trigger to every session subscribed to that type on this runner.

Subscription params are matched against the trigger payload at delivery time. Scalar params use loose equality, array payload fields match if they contain the subscriber’s value, and param names ending in Contains do substring matching against string payload fields.

Services need three values to fire triggers:

ValueSource
runnerIdRead from ~/.pizzapi/runner.json (written by the daemon on startup)
apiKeyPIZZAPI_API_KEY or PIZZAPI_RUNNER_API_KEY environment variable
relayUrlPIZZAPI_RELAY_URL env var, or relayUrl in ~/.pizzapi/config.json

Once triggers are advertised, agents can interact with them using built-in tools:

Agent actionTool
Discover available triggerslist_available_triggers()
Subscribe to a trigger typesubscribe_trigger("my-service:file_changed")
Unsubscribeunsubscribe_trigger({ subscriptionId }) preferred; unsubscribe_trigger("my-service:file_changed") is legacy bulk behavior
Update filters/paramsupdate_trigger_subscription({ subscriptionId, filters, filterMode })

Subscribed triggers arrive as injected messages in the agent’s conversation, with the payload and metadata from the broadcast.

By default, subscribing to a trigger type delivers every event of that type. Filters let you narrow delivery to only payloads that match specific criteria — evaluated server-side before the trigger reaches the agent.

ConceptWhere evaluatedPurpose
paramsPassed to the serviceSubscription parameters forwarded to the service (e.g. which repo to watch)
filtersServer-side, on deliveryEvaluated against the trigger payload — only matching events are delivered

You can use both on the same subscription. Params tell the service what to emit; filters tell the relay what to deliver.

Each filter is a {field, value, op} object:

FieldRequiredDescription
fieldYesDot-path into the trigger payload (e.g. "status", "meta.priority")
valueYesExpected value — string, number, boolean, or array (array = OR within this filter)
opNo"eq" (exact match, default) or "contains" (substring match on string fields)

When a subscription has multiple filters, filterMode controls how they combine:

ModeBehavior
"and" (default)All filters must match for the trigger to be delivered
"or"Any filter matching is enough
subscribe_trigger("orders:status_changed", {
filters: [
{ field: "status", value: "shipped", op: "eq" }
],
filterMode: "and"
})

Only orders:status_changed events whose payload contains status === "shipped" will reach the agent.

Use the update_trigger_subscription tool to change filters or filterMode on an existing subscription without unsubscribing and re-subscribing. When subscribe_trigger returns a subscriptionId, use that ID for later edits so multiple same-type subscriptions stay distinct:

update_trigger_subscription({
subscriptionId: "sub_abc123",
filters: [
{ field: "status", value: "delivered", op: "eq" }
],
filterMode: "or"
})

Passing only a trigger type remains supported as a legacy bulk operation, but it is not precise when multiple subscriptions of the same trigger type exist.


Trigger listeners are persistent configurations on a runner that automatically spawn a new agent session when a matching trigger fires. Unlike regular subscriptions (which deliver events into an existing session), listeners create a fresh session for each event.

  • Auto-review: Spawn a code review session whenever a git:push trigger fires
  • Scheduled runs: Pair with a cron service to auto-spawn sessions on a schedule
  • Reactive automation: Kick off deployment, test, or analysis sessions in response to service events
Trigger ListenersWebhooks
InputInternal PizzaPi triggersExternal HTTP POST requests
ScopeBound to a runnerBound to a user/org
ActionSpawns a new agent session on the runnerFires a trigger into existing subscribed sessions

All endpoints require runner authentication (x-api-key header).

List listeners:

GET /api/runners/{runnerId}/trigger-listeners

Add a listener:

POST /api/runners/{runnerId}/trigger-listeners
Content-Type: application/json
{
"triggerType": "my-service:deploy_requested",
"prompt": "Run the deploy checklist for the provided payload.",
"cwd": "/home/user/project",
"model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514" },
"params": { "environment": "production" }
}

Update a listener:

PUT /api/runners/{runnerId}/trigger-listeners/{listenerId}

Remove a listener:

DELETE /api/runners/{runnerId}/trigger-listeners/{listenerId}

When multiple listeners share the same trigger type, use listenerId for edit/delete operations so you only affect the intended listener. Trigger-type targets are legacy behavior.

FieldRequiredDescription
listenerIdAutoStable listener identity returned by the API; use it for precise update/delete operations
triggerTypeYesThe trigger type to listen for (e.g. "my-service:event_name")
promptNoInitial prompt for the spawned session
cwdNoWorking directory for the spawned session
modelNoModel override ({ provider, id })
paramsNoSubscription params — only triggers matching these params will spawn a session
createdAtAutoISO timestamp set when the listener is created

Listeners are stored durably in both SQLite and Redis, so they survive runner restarts.


Every trigger delivered to a session is recorded in a per-session history log. This provides an audit trail of what events reached the agent and how they were handled.

  • Backed by Redis (list per session)
  • Maximum 200 entries per session (oldest trimmed on insert)
  • 24-hour TTL — history expires automatically if the session is idle

Get trigger history:

GET /api/sessions/{sessionId}/triggers?limit=50

Returns { triggers: TriggerHistoryEntry[] }, most recent first. The limit query parameter defaults to 50 (max 200).

Clear trigger history:

DELETE /api/sessions/{sessionId}/triggers

Removes all history entries for the session.

FieldTypeDescription
triggerIdstringUnique ID for this trigger delivery
typestringTrigger type (e.g. "my-service:file_changed")
sourcestringWho fired the trigger (service name or "external:api")
summarystring?Human-readable one-liner
payloadobjectThe full trigger payload
deliverAs"steer" | "followUp"Delivery mode used
tsstringISO timestamp of delivery
direction"inbound" | "outbound"Whether the trigger was received or sent by this session
responseobject?Agent’s response — { action?, text?, ts }

The Triggers panel in the session viewer shows the live trigger history for the active session. Each entry displays the trigger type, summary, timestamp, and delivery status. Entries update in real time as triggers are delivered.



Sigils are [[type:id]] tokens in agent output that render as interactive UI elements — clickable chips, status badges, or rich previews. Services define sigil types so the UI knows how to recognise and render them.

Add a sigils array to the service declaration, or point it at a standalone sigils.json file (see Split Configuration Files).

Each entry describes a sigil type using the ServiceSigilDef schema:

FieldRequiredDescription
typeYesSigil type name — the token in [[type:id]] syntax, e.g. "pr"
labelYesHuman-readable label shown in the UI, e.g. "Pull Request"
descriptionNoWhat this sigil represents
resolveNoAPI endpoint path to resolve a sigil ID to display data (e.g. PR number → title/status)
schemaNoJSON Schema for sigil params ([[type:id key=val]])
aliasesNoAlternative type names that resolve to this sigil (e.g. ["pull-request", "mr"])
iconNoLucide icon name rendered with the sigil

A GitHub integration service might define pr and commit sigil types:

{
"id": "github",
"label": "GitHub",
"icon": "github",
"entry": "./index.ts",
"sigils": [
{
"type": "pr",
"label": "Pull Request",
"description": "A GitHub pull request — renders as a clickable chip with status",
"resolve": "/api/resolve/pr/{id}",
"aliases": ["pull-request", "mr"]
},
{
"type": "commit",
"label": "Commit",
"description": "A Git commit hash — renders with short SHA and message",
"resolve": "/api/resolve/commit/{id}"
}
]
}

With these definitions registered, when an agent writes [[pr:42]] or [[commit:abc1234]] in its output, the UI renders them as interactive elements instead of plain text.

Sigil types are advertised to all connected viewers via the service_announce event, the same way trigger definitions are.


A service doesn’t need a UI panel. Omit panel from the manifest and skip announcePanel(). 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" }
]
}

A session mode is a workspace with its own identity in the UI. A mode claims a directory; every session whose working directory is inside it belongs to that mode, including sessions started from the terminal.

Modes exist so a package can make PizzaPi feel like the right tool for non-coding work. A mode declares what it wants and PizzaPi renders it natively — modes do not ship UI code.

{
"id": "pizzawork",
"label": "PizzaWork",
"entry": "./src/service.ts",
"sessionModes": [
{
"id": "work",
"label": "Work",
"icon": "briefcase",
"workspace": "~/Documents/Workspace",
"ui": {
"preset": "work",
"toolRendering": "activity",
"vocabulary": { "session": "task", "sessions": "tasks" },
"accent": "#7c3aed",
"composerPlaceholder": "What do you need done?",
"home": {
"greeting": "What are we working on?",
"suggestions": [
{ "label": "Daily report", "icon": "sun", "prompt": "Write my daily report" }
]
},
"artifacts": { "enabled": true },
"scheduled": true
}
}
]
}
FieldTypeDescription
idstringUnique mode ID within the package
labelstringName shown on the mode picker
iconstringLucide icon name
workspacestringHome-relative path (~/...), resolved per runner
uiobjectOptional — how PizzaPi should render this mode’s sessions

Every field is optional, and omitting ui entirely gives the standard coding UI. A mode only declares what differs.

FieldTypeDefaultDescription
preset"coding" | "work""coding"work hides git, terminal and process chrome
chromeobjectfrom presetPer-surface overrides: git, terminal, processes, diffs, files
toolRendering"detailed" | "activity"preset-derivedactivity collapses each tool call to a human-language line that expands on click
vocabularyobjectsession, sessions, newSession noun overrides
accentstringColor token or hex used for the mode badge
composerPlaceholderstringPlaceholder text in the composer
homeobjectgreeting, suggestions[] (label, icon, prompt), recent
artifactsobjectdisabledenabled, plus optional extensions[]
scheduledbooleanfalseShow standing time:cron / time:at instructions

Chrome. preset: "work" hides the git, terminal and process affordances. A hidden panel is also closed, so switching from a coding session cannot strand a panel with no button to reopen it. chrome overrides individual flags:

"ui": { "preset": "work", "chrome": { "git": true } }

Activity rendering. With toolRendering: "activity", a tool call renders as one line — Created q3-review.md, Searched the web for "freight rates" — that expands to the full card. Nothing is hidden; it just stops being the default way the transcript reads.

Artifacts. When enabled, a file written into the workspace whose extension the mode claims renders as an artifact card with an inline preview: Markdown, images, PDF, CSV tables and sandboxed HTML. Formats browsers cannot render (docx, pptx, xlsx) get a download card. Omit extensions for a document-centric default set.

Mode home. Selecting a mode with no session open shows a composer that starts a task directly in the mode’s workspace, its suggestion chips, and its recent tasks. Suggestions prefill the composer rather than sending, so an opening like "Research " can be completed before it runs.

Scheduled. Lists standing time:cron / time:at / time:timer_fired subscriptions across the mode’s sessions, with what runs, when, which task owns it, and a cancel. See Time triggers.


A connectivity service owns a live connection to an outside network — a chat gateway, a webhook receiver, a message queue, an MQTT broker. This is the shape to reach for whenever sessions need to talk to a third-party system that keeps a persistent connection open.

The rule is simple: the daemon owns one connection, and sessions never open their own.

The tempting design is a per-session object — every session gets a bridge it can connect and send through. It does not survive contact with a real service:

ProblemWhat happens with N sessions
N connectionsTen sessions means ten gateway connections on one bot token. Discord rate-limits identifies per token; other providers ban outright.
Duplicate inboundEvery connection receives every event. Ten sessions each see the same message and ten agents answer it.
Lifetime mismatchConnections belong to the machine, not the conversation. Sessions start and stop constantly; a connection that dies with a session cannot receive anything between sessions — including the message that should start one.
Credential spreadThe token has to be readable by every session process instead of living in one place on the daemon.
No routing authority”Which session owns this thread?” has no answer if every session holds its own map.

The daemon is the only component with the right lifetime and the right cardinality. Everything else follows from putting the connection there.

Inbound and outbound use different transports, because they have different routing problems.

Inbound — outside world → session. The service already knows which session the event belongs to (it owns the mapping), so it delivers straight to that one session over HTTP:

// Targeted: this session only. No subscription required.
await fetch(`${relayUrl}/api/sessions/${sessionId}/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
body: JSON.stringify({
type: "discord:message",
payload: { threadId, text },
source: "discord",
deliverAs: "steer",
}),
});

Use POST /api/runners/{runnerId}/trigger-broadcast instead only when the event has no single owner and any interested session should hear it — a CI result, a repo-wide push. Broadcast fans out to every subscriber; it cannot address one conversation.

Outbound — session → outside world. The session emits a service_message envelope on its own relay socket. The relay forwards it to the daemon, which routes it to the service by serviceId:

// In a session-side extension.
socket.emit("service_message", {
serviceId: "discord",
type: "discord_post",
payload: { sessionId, content },
});

The service picks it up in the handler it registered during init():

init(socket, opts) {
this.onMessage = (envelope) => {
if (envelope.serviceId !== "discord") return;
if (envelope.type === "discord_post") void this.post(envelope.payload);
};
socket.on("service_message", this.onMessage);
}

Note what the session does not do: it does not look up a thread, hold a token, or know whether the bridge is even configured. It emits one envelope and the service drops it if that session has no mapping. Routing lives in exactly one place.

The service owns the mapping and is responsible for its whole lifecycle:

  • Persist it. Write bindings to the service’s settings.json (write-to-temp then rename) so a daemon restart does not orphan every live conversation.
  • Index both ways. Keep sessionId → conversation in memory for outbound routing; the persisted map is keyed by conversation for inbound.
  • Clean up on session end. Implement handleSessionEnded(sessionId) to drop the binding, and tell the remote side the conversation is over.
  • Unbind on delivery failure. A 404 from the trigger endpoint means the session is gone. Drop the binding rather than silently swallowing every subsequent message.

A connectivity service usually wants to control the session, not just relay text. Most of that already exists over HTTP with API-key auth:

ActionEndpoint
Spawn a sessionPOST /api/runners/:runnerId/spawn
Send input to a sessionPOST /api/sessions/:id/trigger
List sessionsGET /api/sessions
List modelsGET /api/runners/:runnerId/models
Switch a session’s modelPOST /api/sessions/:id/model

Anything a viewer drives over a socket event has no HTTP equivalent unless one is added. POST /api/sessions/:id/model exists precisely because switching a live model was viewer-only (model_set), and a runner service is not a viewer. There is still no HTTP route to stop a session — kill_session is server→runner only.

The Discord bridge implements exactly this pattern — one gateway connection for the runner, thread-per-session bindings, session spawning from a mention, native slash commands, and a panel for status and controls. Its session-side half is packages/cli/src/extensions/discord-mirror.ts, which forwards each settled assistant turn and nothing else.

Commands that pick from a list render a select menu rather than asking the user to type an exact id — /models shows the available models with the current one preselected, /sessions binds the thread to a running session. Two details worth copying:

  • Defer before any network call. Discord expires an interaction after 3 seconds, so deferReply() first and editReply() once the data arrives.
  • Encode the target in custom_id. The menu carries the thread it was built for, so a stale menu left open elsewhere cannot retarget a different binding.

The service module must default-export a class implementing the ServiceHandler interface:

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";
// ── Relay helpers (for firing triggers) ───────────────────────────────────
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 { /* ignore */ }
}
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; expectsResponse?: boolean },
): 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 ?? "steer",
summary: opts?.summary,
}),
}).catch(err => console.error("[my-service] trigger broadcast failed:", err));
}
// ── Service ───────────────────────────────────────────────────────────────
class MyService {
get id() { return "my-service"; }
#server: Server | null = null;
init(_socket: any, { announcePanel, announceSigilServer }: 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")) {
return Response.json({ hello: "world" }, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
if (url.pathname.endsWith("/api/do-thing") && req.method === "POST") {
// Fire a trigger to all subscribed agent sessions
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);
}
if (announceSigilServer) {
announceSigilServer(this.#server.port);
}
}
dispose() {
if (this.#server) {
this.#server.stop(true);
this.#server = null;
}
}
}
export default MyService;

Lifecycle:

  • init(socket, context) — called when the runner starts. Start your HTTP server, call announcePanel(port) to register a panel or announceSigilServer(port) to register a sigil-resolve server, and set up any trigger-firing logic.
  • dispose() — called on shutdown. Must clean up the HTTP server to avoid port leaks.

Panels render inside an iframe in the PizzaPi web interface. The bottom dock is 280px tall; the side dock is 320px wide. Key constraints:

  • Self-contained — all CSS and JS must be inline (no build step required)
  • Dark theme — match PizzaPi’s dark UI:
    body { background: #0a0a0b; color: #e4e4e7; font-size: 11px; }
    /* Borders: #27272a */
  • Relative API URLs — use ./api/data (the tunnel proxy preserves the path)
  • Polling for live data — use setInterval + fetch (typically 3–5s intervals)
  • Keep panels self-contained — external resources may fail depending on CSP/network
  • CORS headers — API responses need Access-Control-Allow-Origin: *

  1. The runner daemon reads pi.pizzapi.services from every configured package that holds a daemon-service grant
  2. It resolves panel metadata and trigger definitions from the declaration (or the split JSON files it points at)
  3. It loads the service module and calls init(socket, { announcePanel })
  4. The service starts Bun.serve() on port 0 and calls announcePanel(port)
  5. The daemon aggregates all trigger defs and sigil defs from all loaded services
  6. The daemon emits a service_announce event with panels, trigger defs, and sigil defs
  7. The UI renders panel iframes; agents discover triggers via list_available_triggers()
  8. When a service fires a trigger via POST /api/runners/{runnerId}/trigger-broadcast, the relay fans it out to all subscribed sessions

TaskHow
Declare triggersAdd triggers[] to the service declaration, or point it at triggers.json
Declare sigilsAdd sigils[] to the service declaration, or point it at sigils.json
Fire a triggerPOST /api/runners/{runnerId}/trigger-broadcast with API key
Serve static filesBun.serve() with readFileSync for index.html
Expose an APIAdd route checks in the fetch handler
Get a random portBun.serve({ port: 0 }) then read .port
Announce the panelCall announcePanel(server.port) in init()
Announce a sigil serverCall announceSigilServer(server.port) in init()
Match PizzaPi theme#0a0a0b bg, #e4e4e7 text, #27272a borders
Choose an iconBrowse lucide.dev/icons, use kebab-case

ProblemFix
Triggers declared but not deliveredYou must fire them via the relay broadcast API — declaring them in the manifest only advertises them
Missing runnerId or apiKeyRead from ~/.pizzapi/runner.json and env vars at call time, not init time
Panel doesn’t appear in UICheck that announcePanel() is called after the server starts
Blank panel iframeTunnel proxy can’t reach local server — check the service is still running
API calls fail in panelAdd Access-Control-Allow-Origin: * header to API responses
Large panel doesn’t fitBottom dock is 280px tall; side dock is 320px wide — design accordingly
Absolute API URLs breakUse relative URLs (./api/...) — the tunnel proxy rewrites paths
Port leak after restartEnsure dispose() calls server.stop(true)