---
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

```typescript
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;

  // ── Core Lifecycle ────────────────────────────────────────

  init(ctx: ProviderInitContext) {
    // ctx.config — per-provider config from config.json
    // ctx.fireTrigger(sessionId, type, payload) — fire triggers
    // ctx.publishMetadata(sessionId, metadata) — push metadata to UI
    // ctx.socket — relay socket for custom protocols
  }

  dispose() {
    // Cleanup
  }

  // ── Context Injection ─────────────────────────────────────

  async onBeforeAgentStart(event, ctx) {
    // Search your DB for relevant context based on event.prompt
    return [
      {
        text: "Memory: use pnpm, not npm",
        placement: "prepend",        // "prepend" | "append"
        order: 50,                   // Lower = sorted first (default 100)
        dedupeKey: "pkg-mgr",        // Same key + providerId = dedup
        summary: "Package manager preference",
        referencedArtifacts: [{
          id: "mem-123",
          type: "memory",
          label: "Use pnpm"
        }],
      },
    ];
  }

  // ── Lifecycle Hooks ───────────────────────────────────────

  async onSessionStart(event, ctx) {
    // event.reason — "startup" | "reload" | "new" | "resume" | "fork"
    // event.model  — { provider, id, name } | undefined  (model active at session start)
    // Use event.model to gate provider behavior on the current model
    // Rehydrate session state, load pending jobs
  }

  async onTurnEnd(event, ctx) {
    // event.turnIndex, event.message, event.toolResults
    // Incremental indexing — scan messages for signals
  }

  async onSessionShutdown(event, ctx) {
    // event.reason — "quit" | "reload" | "new" | "resume" | "fork"
    // Flush pending writes
  }

  async onSessionClose(event, ctx) {
    // event.reason — "close" | "error" | "complete"
    // Best-effort final flush before archival
    return { label: "Finalizing memory…", jobRef: { jobId: "123" } };
  }

  // ── UI Extension ──────────────────────────────────────────

  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" },
    }];
  }

  // ── Session Metadata ──────────────────────────────────────

  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`:

```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.

```typescript
async onSessionStart(event, ctx) {
  // event.model — { provider: string; id: string; name: string } | undefined
  // undefined only during very early startup before model resolution
  console.log(`Session started with model: ${event.model?.id ?? 'unknown'}`);
}
```

The same model information is also available to **native hooks** via the `SessionStart` hook:

```json
// ~/.pizzapi/config.json
{
  "hooks": {
    "SessionStart": [{
      "command": "echo $PIZZAPI_MODEL_ID > /tmp/session-model.txt"
    }]
  }
}
```

Hook scripts receive JSON on stdin:

```json
{
  "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

```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](https://lucide.dev/icons) 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:**

```json
{
  "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:**

```json
{
  "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:**

```json
{
  "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:**

```json
{
  "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": [...] }`.

```json
[
  {
    "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

```typescript
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 (needed 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;
}

/** Broadcast a trigger to all subscribed sessions on this runner. */
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));
}

// ── Service ───────────────────────────────────────────────────────────────

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")) {
                    // Read panel context from query params (from panel.requires)
                    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") {
                    // Fire a trigger when something happens
                    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:

1. **Discover** — `list_available_triggers()` returns all triggers from runner services
2. **Subscribe** — `subscribe_trigger("my-service:something_happened")` starts receiving events
3. **Receive** — triggers arrive as injected messages in the agent's conversation
4. **Unsubscribe** — `unsubscribe_trigger("my-service:something_happened")` stops delivery

## Panel HTML Guidelines

The panel renders inside a 280px-tall iframe. Key constraints:

- **Self-contained** — all CSS and JS inline (no build step)
- **Dark theme** — match PizzaPi's dark UI:
  ```css
  body { background: #0a0a0b; color: #e4e4e7; font-size: 11px; }
  ```
- **Relative API URLs** — use `./api/data` (the tunnel proxy preserves the path)
- **Polling** — use `setInterval` + `fetch` for live data (typically 3–5s)
- **No external dependencies** — the iframe is sandboxed; CDN scripts may be blocked

**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:

```html
<script>
  // Values injected as query params from panel.requires
  const params = new URLSearchParams(location.search);
  const projectDir = params.get("projectDir"); // from requires: ["PROJECT_DIR"]
  const sessionId = params.get("sessionId");   // from requires: ["SESSION_ID"]

  // Pass them along to API calls so the backend also has context
  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:

```json
{
  "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](https://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 |
