@versdotsh/reef 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,698 @@
1
+ ---
2
+ name: create-service
3
+ description: Create a new service module for reef. Use when adding a new capability to the server — a new store, API routes, LLM tools, behaviors, or dashboard widget.
4
+ ---
5
+
6
+ # Create a Service Module
7
+
8
+ Service modules are self-contained plugins — a folder in `services/` with an `index.ts` that exports a `ServiceModule`. Modules present at startup are discovered automatically. New modules added at runtime are loaded via the services manager (`POST /services/reload`) or the installer (`POST /installer/install`). No import wiring, no registration.
9
+
10
+ ## Before You Start
11
+
12
+ Read these files to understand the system:
13
+
14
+ 1. `src/core/types.ts` — the `ServiceModule` interface (the plugin contract)
15
+ 2. `src/core/discover.ts` — how modules are found and loaded
16
+ 3. `src/core/server.ts` — dynamic dispatch, error handling, lifecycle
17
+ 4. `src/core/client.ts` — the `FleetClient` injected into tools/behaviors
18
+ 5. `src/core/events.ts` — the `ServiceEventBus` for inter-module communication
19
+
20
+ Look at `examples/services/log/` for a minimal example and `examples/services/board/` for a full-featured one.
21
+
22
+ ## Architecture
23
+
24
+ ```
25
+ services/
26
+ your-service/
27
+ index.ts — Module definition (required)
28
+ store.ts — Data layer
29
+ routes.ts — HTTP API (Hono routes)
30
+ tools.ts — LLM-callable tools (pi extension)
31
+ behaviors.ts — Automatic behaviors (event handlers, timers)
32
+ ```
33
+
34
+ At startup, the server scans `services/*/index.ts` and loads everything it finds. Each must **default-export** a `ServiceModule` object. At runtime, use `POST /services/reload` to pick up new or changed modules.
35
+
36
+ A module has two halves:
37
+
38
+ | Side | Runs on | Files | Purpose |
39
+ |------|---------|-------|---------|
40
+ | **Server** | Infra VM | `routes.ts`, `store.ts` | HTTP API + persistence |
41
+ | **Client** | Agent VMs | `tools.ts`, `behaviors.ts` | LLM tools + automatic behaviors |
42
+
43
+ Modules that only have server-side code (no tools, behaviors, or widget) are automatically excluded from the pi extension.
44
+
45
+ ## Runtime Management
46
+
47
+ You don't need to restart the server to work with modules. The server provides runtime management via two built-in service modules:
48
+
49
+ **Services manager** (`/services`):
50
+ - `GET /services` — list loaded modules
51
+ - `POST /services/reload` — re-scan directory, add new, update changed, remove deleted
52
+ - `POST /services/reload/:name` — reload a specific module
53
+ - `DELETE /services/:name` — unload a module
54
+ - `GET /services/export/:name` — export a module as a tarball
55
+
56
+ **Installer** (`/installer`):
57
+ - `POST /installer/install` — install from git, local path, or another reef instance
58
+ - `POST /installer/update` — pull latest and reload
59
+ - `POST /installer/remove` — unload and delete
60
+ - `GET /installer/installed` — list externally installed packages
61
+
62
+ Workflow during development:
63
+ 1. Write your module in `services/your-service/`
64
+ 2. `POST /services/reload/your-service` to hot-load it (or reload to pick up changes)
65
+ 3. Test via curl
66
+ 4. Iterate without restarting
67
+
68
+ ## Step-by-Step
69
+
70
+ ### 1. Create the directory
71
+
72
+ ```bash
73
+ mkdir -p services/your-service
74
+ ```
75
+
76
+ ### 2. Write the store (`store.ts`)
77
+
78
+ The store owns all data and persistence. Three patterns:
79
+
80
+ **JSON file** (simple key-value or list data):
81
+
82
+ ```ts
83
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
84
+ import { dirname } from "node:path";
85
+
86
+ export class YourStore {
87
+ private items = new Map<string, Item>();
88
+ private filePath: string;
89
+ private writeTimer: ReturnType<typeof setTimeout> | null = null;
90
+
91
+ constructor(filePath = "data/your-service.json") {
92
+ this.filePath = filePath;
93
+ this.load();
94
+ }
95
+
96
+ private load(): void {
97
+ try {
98
+ if (existsSync(this.filePath)) {
99
+ const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
100
+ if (Array.isArray(data.items)) {
101
+ for (const item of data.items) this.items.set(item.id, item);
102
+ }
103
+ }
104
+ } catch { this.items = new Map(); }
105
+ }
106
+
107
+ private scheduleSave(): void {
108
+ if (this.writeTimer) return;
109
+ this.writeTimer = setTimeout(() => {
110
+ this.writeTimer = null;
111
+ this.flush();
112
+ }, 100);
113
+ }
114
+
115
+ flush(): void {
116
+ if (this.writeTimer) { clearTimeout(this.writeTimer); this.writeTimer = null; }
117
+ const dir = dirname(this.filePath);
118
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
119
+ writeFileSync(this.filePath,
120
+ JSON.stringify({ items: Array.from(this.items.values()) }, null, 2), "utf-8");
121
+ }
122
+
123
+ // ... your CRUD methods, each calling this.scheduleSave() after mutations
124
+ }
125
+ ```
126
+
127
+ **JSONL file** (append-only event/log data):
128
+
129
+ ```ts
130
+ import { appendFileSync } from "node:fs";
131
+
132
+ // Use appendFileSync for writes — no debounce needed
133
+ appendFileSync(this.filePath, JSON.stringify(entry) + "\n");
134
+ ```
135
+
136
+ **SQLite** (relational queries, aggregations):
137
+
138
+ ```ts
139
+ import { Database } from "bun:sqlite";
140
+
141
+ // See services/usage/store.ts for a complete example
142
+ ```
143
+
144
+ Key conventions:
145
+ - Store files go in `data/` (gitignored)
146
+ - Default file path in the constructor — no config needed
147
+ - Expose `flush()` for graceful shutdown
148
+ - Expose `close()` if using SQLite or other resources that need cleanup
149
+
150
+ ### 3. Write the routes (`routes.ts`)
151
+
152
+ HTTP API using Hono. Routes are mounted at `/{name}/*` automatically.
153
+
154
+ ```ts
155
+ import { Hono } from "hono";
156
+ import type { YourStore } from "./store.js";
157
+
158
+ export function createRoutes(store: YourStore): Hono {
159
+ const routes = new Hono();
160
+
161
+ routes.post("/", async (c) => {
162
+ try {
163
+ const body = await c.req.json();
164
+ const result = store.create(body);
165
+ return c.json(result, 201);
166
+ } catch (e) {
167
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
168
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
169
+ throw e;
170
+ }
171
+ });
172
+
173
+ routes.get("/", (c) => {
174
+ const results = store.list();
175
+ return c.json({ items: results, count: results.length });
176
+ });
177
+
178
+ routes.get("/:id", (c) => {
179
+ const item = store.get(c.req.param("id"));
180
+ if (!item) return c.json({ error: "not found" }, 404);
181
+ return c.json(item);
182
+ });
183
+
184
+ return routes;
185
+ }
186
+ ```
187
+
188
+ Routes are **bearer-auth protected by default**. If your service needs unauthenticated access (like docs), set `requiresAuth: false` in the module definition.
189
+
190
+ **Error handling**: If a route handler throws, the server catches it and returns `{ "error": "internal service error" }` with status 500. This prevents one broken module from taking down the server. But you should still handle expected errors explicitly with proper status codes.
191
+
192
+ ### 4. Add route documentation (`routeDocs`)
193
+
194
+ Add `routeDocs` to your module definition so the `/docs` service can generate API documentation automatically.
195
+
196
+ ```ts
197
+ routeDocs: {
198
+ "POST /": {
199
+ summary: "Create a new item",
200
+ detail: "Creates an item and returns it with a generated ID.",
201
+ body: {
202
+ name: { type: "string", required: true, description: "Item name" },
203
+ status: { type: "string", required: false, description: "Initial status (default: active)" },
204
+ },
205
+ response: "{ id, name, status, createdAt }",
206
+ },
207
+ "GET /": {
208
+ summary: "List all items",
209
+ params: {
210
+ status: { type: "string", required: false, description: "Filter by status" },
211
+ },
212
+ response: "{ items: Item[], count }",
213
+ },
214
+ "GET /:id": {
215
+ summary: "Get a specific item",
216
+ response: "Item object or 404",
217
+ },
218
+ },
219
+ ```
220
+
221
+ The key format is `"METHOD /path"` (relative to the module's mount point). The `/docs` service combines this with auto-detected routes to produce both JSON (`GET /docs/your-service`) and HTML (`GET /docs/ui`) documentation.
222
+
223
+ Modules without `routeDocs` still appear in the docs — they just show method + path without descriptions.
224
+
225
+ ### 5. Write the tools (`tools.ts`)
226
+
227
+ LLM-callable tools registered on the pi extension. These are the agent's interface to your service.
228
+
229
+ ```ts
230
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
231
+ import type { FleetClient } from "../src/core/types.js";
232
+ import { Type } from "@sinclair/typebox";
233
+
234
+ export function registerTools(pi: ExtensionAPI, client: FleetClient) {
235
+ pi.registerTool({
236
+ name: "your_service_action", // snake_case, prefixed with service name
237
+ label: "Your Service: Action", // Human-readable, shown in UI
238
+ description:
239
+ "What this tool does and when the LLM should use it. "
240
+ + "Be specific — the LLM reads this to decide whether to call the tool.",
241
+ parameters: Type.Object({
242
+ requiredParam: Type.String({ description: "What this param is for" }),
243
+ optionalParam: Type.Optional(Type.String({ description: "Optional context" })),
244
+ }),
245
+ async execute(_toolCallId, params) {
246
+ if (!client.getBaseUrl()) return client.noUrl();
247
+
248
+ try {
249
+ const result = await client.api("POST", "/your-service", {
250
+ ...params,
251
+ agent: client.agentName,
252
+ });
253
+ return client.ok(JSON.stringify(result, null, 2), { result });
254
+ } catch (e: any) {
255
+ return client.err(e.message);
256
+ }
257
+ },
258
+ });
259
+ }
260
+ ```
261
+
262
+ Tool conventions:
263
+ - **Name**: `servicename_verb` — e.g. `board_create_task`, `log_append`, `feed_publish`
264
+ - **Description**: Write for the LLM. Explain *when* to use it, not just what it does
265
+ - **Parameters**: Use TypeBox schemas. Add `description` to every field
266
+ - **Execute pattern**: Check `client.getBaseUrl()` → call `client.api()` → return `client.ok()` or `client.err()`
267
+ - **Agent attribution**: Pass `client.agentName` so entries are tagged with who created them
268
+
269
+ The `FleetClient` provides:
270
+ - `client.api(method, path, body?)` — authenticated HTTP call to the reef server
271
+ - `client.agentName` — this agent's name (from `VERS_AGENT_NAME` env var)
272
+ - `client.vmId` — this agent's VM ID, if set
273
+ - `client.ok(text, details?)` — successful tool result
274
+ - `client.err(text)` — error tool result
275
+ - `client.noUrl()` — standard error when `VERS_INFRA_URL` is not set
276
+
277
+ ### 6. Write behaviors (`behaviors.ts`) — optional
278
+
279
+ Behaviors are automatic event handlers that run without the LLM deciding to call them. Use for:
280
+ - Auto-publishing events on agent lifecycle (start, end, turn)
281
+ - Heartbeats and periodic tasks
282
+ - Reacting to other extensions' events
283
+
284
+ ```ts
285
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
286
+ import type { FleetClient } from "../src/core/types.js";
287
+
288
+ export function registerBehaviors(pi: ExtensionAPI, client: FleetClient) {
289
+ pi.on("agent_start", async () => {
290
+ if (!client.getBaseUrl()) return;
291
+ try {
292
+ await client.api("POST", "/your-service/events", {
293
+ agent: client.agentName,
294
+ type: "started",
295
+ });
296
+ } catch { /* best-effort — never crash the agent */ }
297
+ });
298
+
299
+ // Periodic task
300
+ let timer: ReturnType<typeof setInterval> | null = null;
301
+
302
+ pi.on("session_start", async () => {
303
+ timer = setInterval(async () => {
304
+ // periodic work
305
+ }, 60_000);
306
+ });
307
+
308
+ pi.on("session_shutdown", async () => {
309
+ if (timer) { clearInterval(timer); timer = null; }
310
+ });
311
+ }
312
+ ```
313
+
314
+ Behavior conventions:
315
+ - **Always guard** with `if (!client.getBaseUrl()) return` — agents may not have infra configured
316
+ - **Always try/catch** — a behavior error should never crash the agent
317
+ - **Clean up timers** on `session_shutdown`
318
+
319
+ ### 7. Write the module definition (`index.ts`)
320
+
321
+ This ties everything together.
322
+
323
+ ```ts
324
+ import type { ServiceModule } from "../src/core/types.js";
325
+ import { YourStore } from "./store.js";
326
+ import { createRoutes } from "./routes.js";
327
+ import { registerTools } from "./tools.js";
328
+ import { registerBehaviors } from "./behaviors.js";
329
+
330
+ const store = new YourStore();
331
+
332
+ const yourService: ServiceModule = {
333
+ name: "your-service", // URL prefix: /your-service/*
334
+ description: "What this service does",
335
+
336
+ // Server side
337
+ routes: createRoutes(store),
338
+ store,
339
+
340
+ // Client side (omit if server-only)
341
+ registerTools,
342
+ registerBehaviors,
343
+
344
+ // Route documentation for /docs
345
+ routeDocs: {
346
+ "POST /": {
347
+ summary: "Create an item",
348
+ body: {
349
+ name: { type: "string", required: true, description: "Item name" },
350
+ },
351
+ response: "{ id, name, createdAt }",
352
+ },
353
+ "GET /": {
354
+ summary: "List all items",
355
+ response: "{ items: Item[], count }",
356
+ },
357
+ },
358
+
359
+ // Optional: init hook for cross-module wiring
360
+ init(ctx) {
361
+ ctx.events.on("board:task_created", (data) => {
362
+ // react to events from other modules
363
+ });
364
+ },
365
+
366
+ // Optional
367
+ dependencies: ["feed"], // Load after "feed"
368
+ requiresAuth: true, // Default — set false for public endpoints
369
+ };
370
+
371
+ export default yourService;
372
+ ```
373
+
374
+ ### 8. Test it
375
+
376
+ **No restart needed** — use the services manager:
377
+
378
+ ```bash
379
+ # Hot-load your new module
380
+ curl -X POST http://localhost:3000/services/reload/your-service \
381
+ -H "Authorization: Bearer $VERS_AUTH_TOKEN"
382
+
383
+ # Check it's loaded
384
+ curl http://localhost:3000/health
385
+
386
+ # Check the auto-generated docs
387
+ curl http://localhost:3000/docs/your-service
388
+
389
+ # Test your routes
390
+ curl -X POST http://localhost:3000/your-service \
391
+ -H "Authorization: Bearer $VERS_AUTH_TOKEN" \
392
+ -H "Content-Type: application/json" \
393
+ -d '{"name": "test"}'
394
+
395
+ curl http://localhost:3000/your-service \
396
+ -H "Authorization: Bearer $VERS_AUTH_TOKEN"
397
+ ```
398
+
399
+ After making changes, reload without restarting:
400
+
401
+ ```bash
402
+ curl -X POST http://localhost:3000/services/reload/your-service \
403
+ -H "Authorization: Bearer $VERS_AUTH_TOKEN"
404
+ ```
405
+
406
+ ## Error Handling
407
+
408
+ The server is designed to be resilient to bad modules:
409
+
410
+ - **Import errors** (syntax, missing deps): Module is skipped at startup, others keep loading
411
+ - **`init()` throws**: Module is skipped and removed from the registry, others keep running
412
+ - **Route handler throws**: Returns `500 { error: "internal service error" }` — doesn't crash the server
413
+ - **`loadModule()` fails at runtime**: Rolled back — module is not left in a half-initialized state
414
+
415
+ Your module should still handle errors properly:
416
+ - Return appropriate HTTP status codes (400, 404, 409, etc.)
417
+ - Wrap behaviors in try/catch (never crash the agent)
418
+ - Check `client.getBaseUrl()` before making API calls in tools
419
+
420
+ ## ServiceModule Interface Reference
421
+
422
+ ```ts
423
+ interface ServiceModule {
424
+ name: string; // Route prefix, must be unique
425
+ description?: string; // Shown in server startup log and docs
426
+
427
+ // Server side
428
+ routes?: Hono; // Mounted at /{name}/*
429
+ mountAtRoot?: boolean; // Mount at / instead (for UI, webhooks)
430
+ requiresAuth?: boolean; // Default: true
431
+ store?: { flush?(); close?(); }; // For graceful shutdown
432
+ init?(ctx: ServiceContext): void; // Cross-module wiring
433
+
434
+ // Client side
435
+ registerTools?(pi, client): void;
436
+ registerBehaviors?(pi, client): void;
437
+ widget?: { getLines(client): Promise<string[]> };
438
+
439
+ // Documentation
440
+ routeDocs?: Record<string, RouteDocs>; // "METHOD /path" → docs
441
+
442
+ // Metadata
443
+ dependencies?: string[]; // Load after these modules
444
+ }
445
+
446
+ interface RouteDocs {
447
+ summary: string;
448
+ detail?: string;
449
+ params?: Record<string, ParamDoc>; // Query/path parameters
450
+ body?: Record<string, ParamDoc>; // Request body fields
451
+ response?: string; // Response shape description
452
+ }
453
+
454
+ interface ParamDoc {
455
+ type: string;
456
+ required?: boolean;
457
+ description: string;
458
+ }
459
+ ```
460
+
461
+ ## Common Patterns
462
+
463
+ ### Emitting server-side events
464
+
465
+ Let other modules react to your changes:
466
+
467
+ ```ts
468
+ // In index.ts
469
+ let events: ServiceEventBus | null = null;
470
+
471
+ const mod: ServiceModule = {
472
+ init(ctx) { events = ctx.events; },
473
+ routes: createRoutes(store, () => events),
474
+ };
475
+
476
+ // In routes.ts — emit after mutations
477
+ events?.emit("your-service:item_created", { item });
478
+ ```
479
+
480
+ ### Server-only module (no agent tools)
481
+
482
+ Just omit `registerTools`, `registerBehaviors`, and `widget`. The module will be auto-excluded from the pi extension:
483
+
484
+ ```ts
485
+ const serverOnly: ServiceModule = {
486
+ name: "webhooks",
487
+ routes: createRoutes(),
488
+ requiresAuth: false,
489
+ };
490
+ export default serverOnly;
491
+ ```
492
+
493
+ ### Installing from another reef instance
494
+
495
+ If another reef instance has a service you want:
496
+
497
+ ```bash
498
+ curl -X POST http://localhost:3000/installer/install \
499
+ -H "Authorization: Bearer $VERS_AUTH_TOKEN" \
500
+ -H "Content-Type: application/json" \
501
+ -d '{"from": "http://other-reef:3000", "name": "their-service", "token": "their-token"}'
502
+ ```
503
+
504
+ ## UI Panels
505
+
506
+ Services can contribute a panel to the web dashboard. The UI service discovers panels dynamically — no hardcoded knowledge of which services exist.
507
+
508
+ **Convention**: Add a `GET /_panel` route that returns an HTML fragment with scoped `<style>` and `<script>` tags.
509
+
510
+ ```ts
511
+ // In routes.ts
512
+ routes.get("/_panel", (c) => {
513
+ return c.html(`
514
+ <style>
515
+ .panel-myservice { padding: 8px; }
516
+ .panel-myservice .card {
517
+ background: var(--bg-card, #1a1a1a);
518
+ border: 1px solid var(--border, #2a2a2a);
519
+ border-radius: 4px; padding: 10px; margin: 4px 0;
520
+ }
521
+ .panel-myservice .empty {
522
+ color: var(--text-dim, #666); font-style: italic;
523
+ padding: 20px; text-align: center;
524
+ }
525
+ </style>
526
+
527
+ <div class="panel-myservice" id="myservice-root">
528
+ <div class="empty">Loading…</div>
529
+ </div>
530
+
531
+ <script>
532
+ (function() {
533
+ const root = document.getElementById('myservice-root');
534
+ const API = typeof PANEL_API !== 'undefined' ? PANEL_API : '/ui/api';
535
+
536
+ function esc(s) {
537
+ const d = document.createElement('div');
538
+ d.textContent = s || '';
539
+ return d.innerHTML;
540
+ }
541
+
542
+ async function load() {
543
+ try {
544
+ const res = await fetch(API + '/myservice/items');
545
+ if (!res.ok) throw new Error(res.status);
546
+ const data = await res.json();
547
+ render(data.items || []);
548
+ } catch (e) {
549
+ root.innerHTML = '<div class="empty">Unavailable: ' + esc(e.message) + '</div>';
550
+ }
551
+ }
552
+
553
+ function render(items) {
554
+ if (!items.length) {
555
+ root.innerHTML = '<div class="empty">No items</div>';
556
+ return;
557
+ }
558
+ root.innerHTML = items.map(item =>
559
+ '<div class="card">' + esc(item.name) + '</div>'
560
+ ).join('');
561
+ }
562
+
563
+ load();
564
+ setInterval(load, 10000); // poll every 10s
565
+ })();
566
+ </script>
567
+ `);
568
+ });
569
+ ```
570
+
571
+ **Panel rules**:
572
+ - **Scope CSS** to `.panel-<name>` — prevents conflicts with other panels
573
+ - **Wrap JS in an IIFE** — prevents global namespace pollution
574
+ - **Use `PANEL_API`** for API calls — this goes through the UI's auth proxy
575
+ - **Use CSS variables** (`var(--bg-card)`, `var(--border)`, etc.) — matches the UI theme
576
+ - **Handle errors gracefully** — show a message if the service API is down
577
+ - **Poll for updates** — panels aren't automatically refreshed
578
+
579
+ Available CSS variables from the UI theme:
580
+ - `--bg`, `--bg-panel`, `--bg-card` — backgrounds
581
+ - `--border` — borders
582
+ - `--text`, `--text-dim`, `--text-bright` — text colors
583
+ - `--accent`, `--blue`, `--purple`, `--yellow`, `--red`, `--orange` — accent colors
584
+
585
+ **How it works**: The UI service calls `GET /services` on load, then tries `GET /<service>/_panel` for each loaded module. Services that return HTML get a tab in the dashboard. Tabs appear and disappear automatically as services are loaded/unloaded.
586
+
587
+ **SSE in panels**: For live-updating panels (like a feed), use `fetch()` with a streaming reader instead of `EventSource` — this lets you go through the API proxy which injects auth:
588
+
589
+ ```js
590
+ fetch(API + '/feed/stream').then(res => {
591
+ const reader = res.body.getReader();
592
+ const dec = new TextDecoder();
593
+ let buf = '';
594
+ (async function read() {
595
+ while (true) {
596
+ const { done, value } = await reader.read();
597
+ if (done) break;
598
+ buf += dec.decode(value, { stream: true });
599
+ const lines = buf.split('\\n');
600
+ buf = lines.pop() || '';
601
+ for (const line of lines) {
602
+ if (line.startsWith('data: ')) {
603
+ const event = JSON.parse(line.slice(6));
604
+ // render the event
605
+ }
606
+ }
607
+ }
608
+ })().catch(() => setTimeout(startSSE, 5000));
609
+ });
610
+ ```
611
+
612
+ ## Testing
613
+
614
+ Tests live alongside the service they test. Use `createTestHarness()` to spin up a server with just your module — no port binding, no external dependencies.
615
+
616
+ ```ts
617
+ // examples/services/your-service/your-service.test.ts
618
+
619
+ import { describe, test, expect, afterAll } from "bun:test";
620
+ import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
621
+ import yourService from "./index.js";
622
+
623
+ let t: TestHarness;
624
+
625
+ const setup = (async () => {
626
+ t = await createTestHarness({ services: [yourService] });
627
+ })();
628
+
629
+ afterAll(() => t?.cleanup());
630
+
631
+ describe("your-service", () => {
632
+ test("creates an item", async () => {
633
+ await setup;
634
+ const { status, data } = await t.json("/your-service/items", {
635
+ method: "POST",
636
+ auth: true,
637
+ body: { name: "test" },
638
+ });
639
+ expect(status).toBe(201);
640
+ expect(data.name).toBe("test");
641
+ });
642
+
643
+ test("lists items", async () => {
644
+ await setup;
645
+ const { data } = await t.json<{ items: any[] }>("/your-service/items", {
646
+ auth: true,
647
+ });
648
+ expect(data.items.length).toBeGreaterThanOrEqual(1);
649
+ });
650
+
651
+ test("requires auth", async () => {
652
+ await setup;
653
+ const { status } = await t.json("/your-service/items");
654
+ expect(status).toBe(401);
655
+ });
656
+ });
657
+ ```
658
+
659
+ Run with:
660
+
661
+ ```bash
662
+ bun test examples/services/your-service/your-service.test.ts
663
+ ```
664
+
665
+ **`createTestHarness()` options**:
666
+ - `services` — array of service modules to load (module objects or dynamic imports)
667
+ - `authToken` — override the test auth token (default: `"test-token"`)
668
+
669
+ **`TestHarness` API**:
670
+ - `t.fetch(path, opts?)` — make a request. `{ auth: true }` adds the bearer token
671
+ - `t.json<T>(path, opts?)` — fetch + parse JSON, returns `{ status, data }`
672
+ - `t.cleanup()` — remove temp dirs, restore env vars
673
+
674
+ **Tips**:
675
+ - Tests share a single harness instance for speed — use `await setup` at the top of each test
676
+ - Call `t.cleanup()` in `afterAll` to avoid leaking temp directories
677
+ - If your service depends on another module, include both: `services: [dep, yourService]`
678
+ - The harness sets `VERS_AUTH_TOKEN` automatically — `{ auth: true }` uses it
679
+
680
+ ## Checklist
681
+
682
+ Before considering the service done:
683
+
684
+ - [ ] `index.ts` default-exports a `ServiceModule`
685
+ - [ ] `name` is unique across all services
686
+ - [ ] Store handles missing `data/` directory (creates it)
687
+ - [ ] Routes return proper HTTP status codes (201, 400, 404)
688
+ - [ ] `routeDocs` added for all routes
689
+ - [ ] Tools are prefixed with the service name (`servicename_verb`)
690
+ - [ ] Tool descriptions explain *when* to use them
691
+ - [ ] Every tool checks `client.getBaseUrl()` before making API calls
692
+ - [ ] Behaviors are wrapped in try/catch
693
+ - [ ] Behaviors clean up timers on `session_shutdown`
694
+ - [ ] Hot-loads via `POST /services/reload/your-service`
695
+ - [ ] Routes work via curl
696
+ - [ ] Shows up in `GET /docs/your-service`
697
+ - [ ] UI panel added (`GET /_panel`) if the service has data worth showing
698
+ - [ ] Tests written alongside the service (`your-service.test.ts`)