@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.
- package/.github/workflows/test.yml +47 -0
- package/README.md +257 -0
- package/bun.lock +587 -0
- package/examples/services/board/board.test.ts +215 -0
- package/examples/services/board/index.ts +155 -0
- package/examples/services/board/routes.ts +335 -0
- package/examples/services/board/store.ts +329 -0
- package/examples/services/board/tools.ts +214 -0
- package/examples/services/commits/commits.test.ts +74 -0
- package/examples/services/commits/index.ts +14 -0
- package/examples/services/commits/routes.ts +43 -0
- package/examples/services/commits/store.ts +114 -0
- package/examples/services/feed/behaviors.ts +23 -0
- package/examples/services/feed/feed.test.ts +101 -0
- package/examples/services/feed/index.ts +117 -0
- package/examples/services/feed/routes.ts +224 -0
- package/examples/services/feed/store.ts +194 -0
- package/examples/services/feed/tools.ts +83 -0
- package/examples/services/journal/index.ts +15 -0
- package/examples/services/journal/journal.test.ts +57 -0
- package/examples/services/journal/routes.ts +45 -0
- package/examples/services/journal/store.ts +119 -0
- package/examples/services/journal/tools.ts +32 -0
- package/examples/services/log/index.ts +15 -0
- package/examples/services/log/log.test.ts +70 -0
- package/examples/services/log/routes.ts +44 -0
- package/examples/services/log/store.ts +105 -0
- package/examples/services/log/tools.ts +57 -0
- package/examples/services/registry/behaviors.ts +128 -0
- package/examples/services/registry/index.ts +37 -0
- package/examples/services/registry/registry.test.ts +135 -0
- package/examples/services/registry/routes.ts +76 -0
- package/examples/services/registry/store.ts +224 -0
- package/examples/services/registry/tools.ts +116 -0
- package/examples/services/reports/index.ts +14 -0
- package/examples/services/reports/reports.test.ts +75 -0
- package/examples/services/reports/routes.ts +42 -0
- package/examples/services/reports/store.ts +110 -0
- package/examples/services/ui/auth.ts +61 -0
- package/examples/services/ui/index.ts +16 -0
- package/examples/services/ui/routes.ts +160 -0
- package/examples/services/ui/static/app.js +369 -0
- package/examples/services/ui/static/index.html +42 -0
- package/examples/services/ui/static/style.css +157 -0
- package/examples/services/usage/behaviors.ts +166 -0
- package/examples/services/usage/index.ts +19 -0
- package/examples/services/usage/routes.ts +53 -0
- package/examples/services/usage/store.ts +341 -0
- package/examples/services/usage/tools.ts +75 -0
- package/examples/services/usage/usage.test.ts +91 -0
- package/package.json +29 -0
- package/services/agent/index.ts +465 -0
- package/services/board/index.ts +155 -0
- package/services/board/routes.ts +335 -0
- package/services/board/store.ts +329 -0
- package/services/board/tools.ts +214 -0
- package/services/docs/index.ts +391 -0
- package/services/feed/behaviors.ts +23 -0
- package/services/feed/index.ts +117 -0
- package/services/feed/routes.ts +224 -0
- package/services/feed/store.ts +194 -0
- package/services/feed/tools.ts +83 -0
- package/services/installer/index.ts +574 -0
- package/services/services/index.ts +165 -0
- package/services/ui/auth.ts +61 -0
- package/services/ui/index.ts +16 -0
- package/services/ui/routes.ts +160 -0
- package/services/ui/static/app.js +369 -0
- package/services/ui/static/index.html +42 -0
- package/services/ui/static/style.css +157 -0
- package/skills/create-service/SKILL.md +698 -0
- package/src/core/auth.ts +28 -0
- package/src/core/client.ts +99 -0
- package/src/core/discover.ts +152 -0
- package/src/core/events.ts +44 -0
- package/src/core/extension.ts +66 -0
- package/src/core/server.ts +262 -0
- package/src/core/testing.ts +155 -0
- package/src/core/types.ts +194 -0
- package/src/extension.ts +16 -0
- package/src/main.ts +11 -0
- package/tests/server.test.ts +1338 -0
- 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`)
|