@belte/belte 0.19.1 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @belte/belte
2
2
 
3
+ ## 0.19.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`e772716`](https://github.com/briancray/belte/commit/e772716a190f826f0041b8358271604ad5a230a5) - single prompt renderer and lazy-built agent surface ([`224d348`](https://github.com/briancray/belte/commit/224d34852a63122bb53ba85a5f37af729cc987a7))
8
+
9
+ - [`e772716`](https://github.com/briancray/belte/commit/e772716a190f826f0041b8358271604ad5a230a5) - surface abnormal engine stops and bound the tool loops ([`d2c3215`](https://github.com/briancray/belte/commit/d2c3215bb50ba41b2407eb8878e426a164927d9d))
10
+
11
+ - [`e772716`](https://github.com/briancray/belte/commit/e772716a190f826f0041b8358271604ad5a230a5) - populate the page proxy on error and 404 renders ([`e680ae6`](https://github.com/briancray/belte/commit/e680ae6b12c773f1516fe0ab128be9a208d855f0))
12
+
3
13
  ## 0.19.1
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -11,251 +11,280 @@ bundler swaps the runtime per target.
11
11
  // src/server/rpc/getPost.ts — the filename is the export, the URL, and the command name
12
12
  import { GET } from '@belte/belte/server/GET'
13
13
  import { json } from '@belte/belte/server/json'
14
- import { z } from 'zod'
15
14
 
16
- export const getPost = GET(async ({ id }) => json(await db.post(id)), {
17
- inputSchema: z.object({ id: z.string() }),
18
- })
15
+ export const getPost = GET<Post, typeof schema>(
16
+ async ({ id }) => json(await db.post(id)),
17
+ { inputSchema: schema },
18
+ )
19
19
  ```
20
20
 
21
21
  That one file is now all of this:
22
22
 
23
23
  ```text
24
- src/server/rpc/getPost.ts
25
- export const getPost = GET(fn, { inputSchema })
26
-
27
- ├─ browser await cache(getPost)({ id }) decoded body, SSR-hydrated
28
- ├─ http GET /rpc/getPost?id=… the wire endpoint
29
- ├─ openapi GET /rpc/getPost in /openapi.json operationId: getPost
30
- ├─ mcp tool "getPost" read-only ⇒ auto-exposed
31
- └─ cli app getPost --id … schema ⇒ typed flags
24
+ src/server/rpc/getPost.ts
25
+
26
+ ┌────────────┬──────────┼───────────┬──────────────────┐
27
+ browser http cli mcp openapi
28
+ cache(getPost) GET myapp getPost GET /rpc/getPost
29
+ ({ id }) /rpc/ getPost tool operation in
30
+ getPost? --id 1 (read-only) /openapi.json
31
+ id=1
32
32
  ```
33
33
 
34
- A bare `GET(fn)` always reaches browser, http, and openapi. The MCP tool and CLI
35
- subcommand turn on once the declaration carries a Standard Schema — the schema is
36
- what makes a machine-facing surface safe to advertise.
37
-
38
- Don't take the diagram's word for it — belte prints the exact map at boot
39
- (`DEBUG=belte`):
34
+ Don't take the diagram's word for it with `DEBUG=belte`, belte prints the exact
35
+ map at boot:
40
36
 
41
37
  ```sh
42
38
  pages:
43
- page layout error
44
- / / ·
45
- /posts/[id] / ·
39
+ page layout error
40
+ / · ·
41
+ /posts/[id] · ·
46
42
  sockets:
47
- socket schema browser mcp cli publish
48
- chat ✓ ✓ ✓ ✓ ✓
43
+ socket schema browser mcp cli publish
44
+ chat ✓ ✓ ✓ ✓ ✓
49
45
  rpcs:
50
46
  http schema browser mcp cli
51
47
  GET /rpc/getPost ✓ ✓ ✓ ✓
52
- POST /rpc/createPost ✓ ✓ ·
53
- GET /rpc/listFeed · ✓ · ·
48
+ POST /rpc/createPost ✓ ✓
54
49
  ```
55
50
 
56
- Scan a column to spot a missing surface, a row to see one declaration's reach. A
57
- schemaless verb (`listFeed`) reddens its `schema` cell its machine surfaces are
58
- gated until a schema lands. Every surface a function reaches is auditable in one
59
- place; no surface is ever exposed by accident.
51
+ The `schema` column is the gate: a verb or socket with no schema reddens its `·`
52
+ there, because that is what holds its `mcp`/`cli` columns off. Every surface a
53
+ function reaches is auditable in one place no surface is ever exposed by accident.
60
54
 
61
55
  ## Why it's built this way
62
56
 
63
- - **Zero runtime dependencies.** `package.json` declares no `dependencies` — only
64
- optional `peerDependencies` (`svelte`, and `tailwindcss` / `bun-plugin-tailwind`
65
- for styling). Everything else is Web platform and `Bun.*` (`Bun.serve`,
66
- `Bun.CookieMap`, `Bun.zstdCompress`, `Bun.YAML`, `Bun.file`).
67
- - **No magic strings.** The rpc/socket swap is a real tokenizer
68
- (`findExportCallSite`) that walks source character-by-character, skipping
69
- strings, templates, comments, regexes, and TypeScript generics — so a `GET`
70
- inside a docstring or a nested `GET<Map<K, V>>(` is never mistaken for the call.
71
- - **Safe by default for machines.** A read-only verb (`GET`/`HEAD`) with a schema
57
+ - **Zero runtime dependencies.** belte ships no `dependencies` — only optional
58
+ peers (`svelte`, and `bun-plugin-tailwind` / `tailwindcss` for styling). The
59
+ runtime is Bun (`Bun.serve`, `Bun.CookieMap`, `Bun.YAML`, `Bun.zstdDecompress`,
60
+ `Bun.file`) and Web standards (`Request`/`Response`, `ReadableStream`,
61
+ `AsyncIterable`, `structuredClone`).
62
+ - **No magic strings.** The bundler finds each `export const x = GET(fn)` with a
63
+ character-level scanner that skips strings, templates, comments, regexes, and
64
+ TypeScript generics (`findExportCallSite.ts`) a `GET` inside a docstring or a
65
+ `GET<Map<K, V>>(` is never mistaken for the call site.
66
+ - **Safe by default for machines.** A schema-bearing read verb (`GET`/`HEAD`)
72
67
  auto-exposes to MCP; a mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never does
73
- unless you pass `clients: { mcp: true }` explicitly — a model can't delete data
74
- just because the handler carries a schema.
68
+ just because it carries a schema — it requires an explicit `clients: { mcp: true }`
69
+ (`defineVerb.ts`, `resolveClientFlags.ts`, `isReadOnlyMethod.ts`).
75
70
 
76
71
  ## Scope — read this before you adopt
77
72
 
78
- - **Bun-only, by design.** `engines.bun >= 1.3.0`. There is no Node fallback; the
79
- runtime is built directly on `Bun.serve` and `Bun.*` APIs.
73
+ - **Bun-only, by design.** belte targets `bun >= 1.3.0` and builds on Bun APIs with
74
+ no Node fallback path.
80
75
  - **Svelte-only web surface.** Pages, layouts, and error pages are Svelte 5
81
- components; SSR runs through `svelte/server`. There is no other view layer.
82
- - **Pre-1.0.** The core (rpc, pages, cache, sockets) is the mature center. The
83
- machine-facing satellites MCP, the CLI binary, and the desktop bundle — are
84
- newer and move faster. APIs may still shift.
76
+ components; there is no other view layer.
77
+ - **Pre-1.0.** The core (rpc, sockets, cache, SSR+SPA) is the mature surface; the
78
+ satellites (`mcp`, `cli`, `bundle`/desktop, `agent`) are newer. Expect change.
79
+ - **No umbrella import.** There is no `.` barrel — every public name has its own
80
+ path (`@belte/belte/server/GET`, `@belte/belte/shared/cache`, …), so importing one
81
+ name never drags in side-effecting siblings.
82
+
83
+ ---
85
84
 
86
85
  ## The mental model
87
86
 
88
87
  Three ideas carry the whole framework.
89
88
 
90
- 1. **One runtime dev equals build.** `belte dev` and a compiled binary run the
91
- same server code over the same registry. There is no separate dev path to
92
- diverge from production.
93
- 2. **Declare once.** A file under `src/server/rpc/` holds exactly one
94
- `export const <name> = VERB(fn)`. The filename is the export name, the URL
95
- (`/rpc/<path>`), the MCP tool name, and the CLI subcommand. The HTTP verb you
96
- import picks the method.
97
- 3. **The namespace marks the side.** The import path tells you where a name runs.
89
+ 1. **One runtime.** Dev and build run the same code through the same plugins; the
90
+ only thing that changes per target is which runtime the bundler swaps in behind a
91
+ declared name.
92
+ 2. **Declare once.** A file under `src/server/rpc/` exports exactly one verb; its
93
+ filename is the export name, its path is the URL, and its schema decides which
94
+ surfaces it reaches. Same for `src/server/sockets/`.
95
+ 3. **The namespace marks the side.** The first path segment tells you where a name
96
+ runs.
98
97
 
99
- | Namespace | Runs | Public names |
98
+ | namespace | runs on | examples |
100
99
  | --- | --- | --- |
101
- | `belte/server/*` | server only | `GET` `POST` `PUT` `PATCH` `DELETE` `HEAD`, `socket`, `json` `jsonl` `sse` `error` `redirect`, `request` `cookies` `server` `env` `appDataDir`, `AppModule` |
102
- | `belte/browser/*` | client only | `page` `navigate` `subscribe` |
103
- | `belte/shared/*` | isomorphic | `cache` `HttpError` `withJsonSchema` `bundled` `log` |
104
- | `belte/bundle/*` | desktop launcher | `BundleWindow` `BundleMenu` `BundleMenuItem` `onMenu` |
105
- | `belte/test/*` | tests | `createTestClient` `clearVerbRegistry` |
106
-
107
- There is no umbrella `index.ts` and no `.` export. Every public name has its own
108
- module path (`belte/server/json`, `belte/shared/cache`, …), so importing one name
109
- never drags a side-effecting sibling into the bundle. `shared/*` names are the
110
- same callable with the same behaviour on both sides; the bundler swaps only the
111
- underlying runtime.
100
+ | `server/*` | server only | `server/GET`, `server/socket`, `server/json`, `server/request`, `server/cookies`, `server/env`, `server/agent` |
101
+ | `browser/*` | client only | `browser/page`, `browser/navigate`, `browser/subscribe` |
102
+ | `shared/*` | isomorphic — same callable, same behaviour both sides | `shared/cache`, `shared/HttpError`, `shared/withJsonSchema`, `shared/bundled` |
103
+ | `bundle/*` | desktop bundle | `bundle/BundleWindow`, `bundle/onMenu`, `bundle/BundleMenu` |
104
+
105
+ No `index.ts` barrels anywhere. Each import is its own module path.
112
106
 
113
107
  ## One function, every surface
114
108
 
115
- A single schema-bearing verb, consumed from all five surfaces.
109
+ A single schema-bearing verb, consumed five ways. Declare it once:
116
110
 
117
111
  ```ts
118
- // src/server/rpc/createOrder.ts
119
- import { POST } from '@belte/belte/server/POST'
112
+ // src/server/rpc/getPost.ts
113
+ import { GET } from '@belte/belte/server/GET'
120
114
  import { json } from '@belte/belte/server/json'
121
115
  import { z } from 'zod'
122
116
 
123
- export const createOrder = POST(async ({ sku, qty }) => json(await orders.create(sku, qty)), {
124
- inputSchema: z.object({ sku: z.string(), qty: z.number() }),
125
- clients: { mcp: true }, // a mutating verb must opt into MCP explicitly
126
- })
117
+ const schema = z.object({ id: z.string() })
118
+
119
+ export const getPost = GET<Post, typeof schema>(
120
+ async ({ id }) => json(await db.post(id)),
121
+ { inputSchema: schema },
122
+ )
127
123
  ```
128
124
 
129
- ```svelte
130
- <!-- browser: SSR + hydrate -->
131
- <script lang="ts">
132
- import { cache } from '@belte/belte/shared/cache'
133
- import { createOrder } from '$server/rpc/createOrder'
134
- const result = $derived(await cache(createOrder)({ sku: 'A1', qty: 2 }))
135
- </script>
125
+ Now consume it.
126
+
127
+ ```ts
128
+ // browser / SSR same callable, bundler-swapped runtime
129
+ const post = await cache(getPost)({ id })
136
130
  ```
137
131
 
138
132
  ```sh
139
- # http
140
- curl -X POST localhost:3000/rpc/createOrder -d '{"sku":"A1","qty":2}'
133
+ # http — args on the query string for a GET
134
+ curl 'http://localhost:3000/rpc/getPost?id=42'
135
+ ```
141
136
 
142
- # cli (schema → flags)
143
- app createOrder --sku A1 --qty 2
137
+ ```sh
138
+ # cli the thin client turns it into a subcommand with schema-derived flags
139
+ myapp getPost --id 42
140
+ ```
144
141
 
145
- # mcp — tool "createOrder", dispatched at POST /__belte/mcp
142
+ ```json
143
+ // mcp — POST /__belte/mcp, tools/call
144
+ { "method": "tools/call", "params": { "name": "getPost", "arguments": { "id": "42" } } }
145
+ ```
146
146
 
147
- # openapi — POST /rpc/createOrder, operationId createOrder, in /openapi.json
147
+ ```sh
148
+ # openapi — the operation is in the generated document
149
+ curl http://localhost:3000/openapi.json
148
150
  ```
149
151
 
152
+ `getPost` is read-only and carries a schema, so all five light up automatically. A
153
+ mutating verb would expose `http`, `openapi`, and `browser` by the same rules, but
154
+ hold `mcp` off until you opt in.
155
+
156
+ ---
157
+
150
158
  ## Server
151
159
 
152
160
  ### Server / rpc
153
161
 
154
162
  #### Declaring
155
163
 
156
- Each file under `src/server/rpc/` exports one verb-bound function. Import a verb
157
- helper from its own path: `belte/server/GET`, `.../POST`, `.../PUT`, `.../PATCH`,
158
- `.../DELETE`, `.../HEAD`.
164
+ A verb helper rewrites `export const x = VERB(fn, opts?)` into a server handler (or
165
+ a browser fetch stub). One export per file.
159
166
 
160
167
  ```ts
161
- type Verb = <Return = unknown, InputSchema = StandardSchemaV1>(
162
- handler: (args: InferOutput<InputSchema>) => Response | Promise<Response>,
168
+ type VerbHelper = <Return, InputSchema, FilesSchema>(
169
+ fn: (args: InferOutput<InputSchema>) => Response | Promise<Response>,
163
170
  opts?: {
164
- inputSchema?: InputSchema // validates args; 422 on failure
165
- outputSchema?: StandardSchemaV1 // describes the 200 body (OpenAPI + MCP)
166
- filesSchema?: StandardSchemaV1 // validates multipart File parts
167
- clients?: Partial<{ browser; mcp; cli }>
171
+ inputSchema?: InputSchema
172
+ outputSchema?: StandardSchemaV1
173
+ filesSchema?: FilesSchema
174
+ clients?: Partial<{ browser: boolean; mcp: boolean; cli: boolean }>
168
175
  },
169
176
  ) => RemoteFunction<InferInput<InputSchema>, Return>
170
177
  ```
171
178
 
172
- | Option | Effect | Default |
179
+ | option | type | effect |
173
180
  | --- | --- | --- |
174
- | `inputSchema` | Validates args (any Standard Schema lib). `Args` infers from it; replies 422 with `{ issues }` on failure. | none |
175
- | `outputSchema` | Standard Schema for the success body feeds the OpenAPI 200 response and the MCP tool `outputSchema`. | none |
176
- | `filesSchema` | Validates the `File` parts of a multipart upload (kept out of the JSON-Schema projection). | none |
177
- | `clients` | Which surfaces expose the verb: `{ browser, mcp, cli }`. | `browser: true`; `mcp`/`cli` auto-on with a schema (MCP only for read-only verbs) |
181
+ | `inputSchema` | Standard Schema | validates args (422 on failure); projected to OpenAPI / MCP / CLI. Its presence flips on `cli`, and `mcp` for read-only verbs |
182
+ | `outputSchema` | Standard Schema | describes the 200 body in the OpenAPI doc and the MCP tool `outputSchema` |
183
+ | `filesSchema` | Standard Schema | validates multipart `File` parts; kept off the JSON-Schema projection (a `File` has no honest conversion) |
184
+ | `clients` | partial flags | explicit per-surface override; always wins over the computed defaults |
178
185
 
179
- `Args` is inferred from `inputSchema` or from the handler's parameter type; `Return`
180
- is inferred from the handler's return via the `TypedResponse<T>` brand on the
181
- response helpers, so `GET(() => json({ ... }))` types end-to-end with no annotation.
186
+ Helpers: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. Any Standard Schema
187
+ library (zod, valibot, arktype) works without an adapter.
182
188
 
183
- **Response helpers** — one per file under `belte/server/*`. All default to
184
- `Cache-Control: no-store` (intermediary caches shouldn't memoise rpc replies).
189
+ ```ts
190
+ // a mutation must opt into MCP explicitly
191
+ export const createPost = POST<Post, typeof schema>(
192
+ async (input) => json(await db.create(input), { status: 201 }),
193
+ { inputSchema: schema, clients: { mcp: true } },
194
+ )
195
+ ```
185
196
 
186
- | Helper | Returns | Notes |
197
+ **Response helpers** each returns a `TypedResponse<T>` whose phantom `T` lets the
198
+ caller-facing return type infer from the handler body.
199
+
200
+ | helper | path | builds |
187
201
  | --- | --- | --- |
188
- | `json(data, init?)` | `application/json` | Like `Response.json` plus the no-store default. |
189
- | `error(status, message?, init?)` | `text/plain` | `message` defaults to the status reason phrase. Body reaches `HttpError.response.text()` verbatim. |
190
- | `redirect(url, status?, init?)` | 3xx | Accepts relative URLs; defaults to 302. Statuses `301/302/303/307/308`. |
191
- | `jsonl(iterable, init?)` | `application/jsonl` | One JSON value per line from an `AsyncIterable`. Errors emit a final `{"$error":"…"}` line. |
192
- | `sse(iterable, init?)` | `text/event-stream` | One `data:` event per frame, 15s keepalive comments. Errors emit an `event: error` frame. |
202
+ | `json(data, init?)` | `server/json` | `application/json`, `Cache-Control: no-store` by default |
203
+ | `jsonl(iterable, init?)` | `server/jsonl` | JSON Lines stream (`application/jsonl`), one value per line |
204
+ | `sse(iterable, init?)` | `server/sse` | Server-Sent Events stream with a 15s keepalive comment |
205
+ | `error(status, message?, init?)` | `server/error` | `text/plain` error; message defaults to the standard reason phrase |
206
+ | `redirect(url, status?, init?)` | `server/redirect` | 301/302/303/307/308; accepts relative URLs (default 302) |
207
+
208
+ `jsonl` and `sse` carry generator errors as a final frame (`{"$error": "…"}` /
209
+ `event: error`) with only the message on the wire — the full error is logged
210
+ server-side. Cancellation flows from the consumer into the generator's `for await`
211
+ via `iter.return()`.
193
212
 
194
213
  **Request-scoped helpers** — resolve only while an SSR render or rpc handler is in
195
- flight (they throw at module top level or in `app.ts` `init`):
214
+ flight (they throw outside a request scope):
215
+
216
+ - `request()` (`server/request`) — the inbound `Request`.
217
+ - `server()` (`server/server`) — the active `Bun.serve` instance (a no-op stand-in
218
+ under in-process CLI / MCP / test dispatch).
219
+ - `cookies()` (`server/cookies`) — Bun's `CookieMap`: a live `Map<string, string>`
220
+ plus `.set(name, value, options)` and `.delete(name)`, flushed to `Set-Cookie` on
221
+ return.
196
222
 
197
- - `request()` the inbound `Request` (`belte/server/request`).
198
- - `cookies()` the live `Bun.CookieMap`; `.set`/`.delete` flush as `Set-Cookie`
199
- when the handler returns (`belte/server/cookies`).
200
- - `server()` the active `Bun.Server` (`belte/server/server`).
223
+ > SSR and MCP call verbs **in-process**, and that path forwards only an allowlist of
224
+ > inbound headers `cookie`, `authorization`, and the `x-forwarded-*` hints. A
225
+ > handler that reads any other header (e.g. `accept-language`, `x-tenant-id`) during
226
+ > SSR or an MCP call sees nothing unless you extend the list via the `forwardHeaders`
227
+ > export in `src/app.ts`.
201
228
 
202
- In-process calls (SSR, MCP, CLI) forward only an allowlist of inbound headers onto
203
- the synthesized rpc `Request`: `cookie`, `authorization`, `x-forwarded-for`,
204
- `x-forwarded-proto`, `x-forwarded-host`. Every other header is dropped. Extend the
205
- allowlist with the `forwardHeaders` export in `src/app.ts` (e.g. `accept-language`,
206
- `x-tenant-id`).
229
+ **Multipart uploads** a body verb with `filesSchema` receives the validated text
230
+ fields merged with the `File` parts; call it with a `FormData`:
207
231
 
208
- **Multipart uploads** — pass `filesSchema` alongside `inputSchema`. The handler
209
- receives the validated text fields merged with the validated `File` parts; the
210
- call site sends a `FormData`. Files stay out of `inputSchema`, so its JSON-Schema
211
- projection (OpenAPI/MCP/CLI) never has to model a binary.
232
+ ```ts
233
+ export const upload = POST(
234
+ async ({ title, file }) => json(await store(title, file)),
235
+ { inputSchema: z.object({ title: z.string() }), filesSchema: z.object({ file: z.instanceof(File) }) },
236
+ )
237
+ ```
212
238
 
213
- **`withJsonSchema`** attaches a `toJSONSchema()` projection to a schema whose
214
- library doesn't expose one (Zod 4 / Effect / Arktype carry their own):
239
+ **Schemas without `toJSONSchema()`** wrap once at the declaration so the OpenAPI
240
+ doc, MCP tools, and CLI flags can read it:
215
241
 
216
242
  ```ts
217
243
  import { withJsonSchema } from '@belte/belte/shared/withJsonSchema'
218
- export const fn = POST(handler, { inputSchema: withJsonSchema(vSchema, (s) => toJsonSchema(s)) })
244
+ const schema = withJsonSchema(valibotSchema, (s) => toJsonSchema(s))
219
245
  ```
220
246
 
221
247
  #### Consuming
222
248
 
223
- A verb's value is a `RemoteFunction` with the same call signature on both sides.
249
+ A `RemoteFunction` is one callable with two siblings:
224
250
 
225
- | Form | Resolves to | On non-2xx |
251
+ | form | resolves to | use |
226
252
  | --- | --- | --- |
227
- | `fn(args)` | the Content-Type-decoded body (`Promise<Return>`) | throws `HttpError` |
228
- | `fn.raw(args)` | the underlying `Response` | no throw inspect status/headers/body |
229
- | `fn.stream(args?)` | a `Subscribable<Return>` view of the body (SSE/JSONL frames, or the decoded body once) | surfaced via `subscribe.error` |
253
+ | `fn(args)` | decoded body (`Promise<Return>`); throws `HttpError` on non-2xx | the default call |
254
+ | `fn.raw(args)` | the underlying `Response` | status / headers / manual streaming |
255
+ | `fn.stream(args)` | a `Subscribable<Return>` | frame-by-frame consumption via `subscribe()` |
230
256
 
231
257
  ```ts
232
- const post = await getPost({ id }) // decoded body, throws HttpError on 4xx/5xx
233
- const res = await getPost.raw({ id }) // Response; res.status, res.headers
234
- const live = orderFeed.stream({ since }) // Subscribable — pass to subscribe()
258
+ import { HttpError } from '@belte/belte/shared/HttpError'
259
+
260
+ try {
261
+ const post = await getPost({ id })
262
+ } catch (err) {
263
+ if (err instanceof HttpError && err.status === 404) {
264
+ // err.response is the raw Response
265
+ }
266
+ }
235
267
  ```
236
268
 
237
- **`HttpError`** (`belte/shared/HttpError`) carries `status`, `statusText`, and the
238
- raw `response` so a call site can render error UI without opting into `.raw`.
239
-
240
- **OpenAPI** — every verb is described at `/openapi.json` (OpenAPI 3.1) regardless
241
- of which machine clients it advertises. `GET`/`DELETE`/`HEAD` args become query
242
- parameters; `POST`/`PUT`/`PATCH` args become a JSON request body (or
243
- `multipart/form-data` when `filesSchema` is set). `operationId` is the
244
- folder-prefixed command name.
269
+ The HTTP surface is always on, independent of the other clients. The OpenAPI 3.1
270
+ document for every verb is served at `/openapi.json`.
245
271
 
246
272
  ### Server / sockets
247
273
 
248
- A bidirectional named broadcast primitive. One file per socket under
249
- `src/server/sockets/`; import the helper from `belte/server/socket`.
274
+ A WebSocket-backed pub/sub topic. Every socket multiplexes onto one
275
+ framework-owned connection per client at `/__belte/sockets` user code never
276
+ touches the raw ws lifecycle.
277
+
278
+ #### Declaring
250
279
 
251
280
  ```ts
252
- type socket = <T>(opts?: {
253
- history?: number // messages replayed to a new subscriber (default 0)
254
- ttl?: number // ms; history entries older than this are evicted lazily
255
- clientPublish?: boolean // allow clients to publish over the wire (default false)
256
- schema?: StandardSchemaV1 // validates publish payloads; infers T; unlocks mcp/cli
257
- clients?: Partial<{ browser; mcp; cli }>
258
- }) => Socket<T>
281
+ type SocketOptions = {
282
+ history?: number // messages replayed to a new subscriber
283
+ ttl?: number // ms; history entries past it are evicted lazily on read
284
+ clientPublish?: boolean // allow clients to publish (off by default)
285
+ schema?: StandardSchemaV1 // validates publishes; unlocks mcp/cli
286
+ clients?: Partial<{ browser: boolean; mcp: boolean; cli: boolean }>
287
+ }
259
288
  ```
260
289
 
261
290
  ```ts
@@ -264,296 +293,349 @@ import { socket } from '@belte/belte/server/socket'
264
293
  import { z } from 'zod'
265
294
 
266
295
  export const chat = socket({
296
+ schema: z.object({ user: z.string(), text: z.string() }),
267
297
  history: 50,
268
298
  clientPublish: true,
269
- schema: z.object({ user: z.string(), text: z.string() }),
270
299
  })
271
300
  ```
272
301
 
273
- **Publishing** is isomorphic: `chat.publish(msg)` notifies in-process iterators and
274
- fans out to remote subscribers via Bun's native `server.publish`. With a `schema`,
275
- publish validates synchronously and throws on a bad payload.
302
+ With a schema, `T` infers from it and publishes validate on the server.
303
+ Schemaless browser-only; schema present all surfaces.
304
+
305
+ #### Publishing
306
+
307
+ ```ts
308
+ chat.publish({ user, text }) // server-side: notifies in-process iterators + broadcasts to ws clients
309
+ ```
310
+
311
+ `publish` is isomorphic — called from the client (via the socket proxy) it sends a
312
+ `pub` frame the server validates and forwards.
313
+
314
+ #### Consuming
315
+
316
+ A `Socket<T>` is an `AsyncIterable`: a bare `for await` replays the full history
317
+ buffer then tails live; `.tail(count)` replays the last `count` items (default `0`).
318
+
319
+ ```ts
320
+ for await (const message of chat) {
321
+ // history first, then live
322
+ }
323
+
324
+ const recent = chat.tail(10)
325
+ ```
326
+
327
+ In a Svelte component, layer `subscribe()` on top for reactivity (below).
328
+
329
+ ### Server / agent
276
330
 
277
- **Consuming** a `Socket<T>` is an `AsyncIterable<T>`:
331
+ `agent(engine, messages)` runs a model engine against the app's own MCP surface
332
+ (its gated tools/prompts/resources) and returns the engine's frame stream. The
333
+ handler picks the transport — same as any streaming verb.
278
334
 
279
335
  ```ts
280
- for await (const msg of chat) { /* replays history, then tails live */ }
281
- for await (const msg of chat.tail(10)) { /* last 10, then live */ }
336
+ // src/server/rpc/chat.ts
337
+ import { agent } from '@belte/belte/server/agent'
338
+ import { jsonl } from '@belte/belte/server/jsonl'
339
+ import { engine } from '@belte/anthropic'
340
+
341
+ const chatEngine = engine({ model: 'claude-opus-4-8', apiKey: config.ANTHROPIC_API_KEY })
342
+
343
+ export const chat = POST(({ messages }) => jsonl(agent(chatEngine, messages)), { inputSchema })
282
344
  ```
283
345
 
284
- `.tail(count)` replays the last `count` items (default `0`, clamped to the configured
285
- `history`) before tailing. In a Svelte component, layer `subscribe()` on top instead
286
- of iterating by hand.
346
+ The engine (a `@belte/<provider>` package) only sees the surface in and yields
347
+ frames out, so swapping providers never touches the verb or the UI. Permission is
348
+ decided server-side: the surface is already gated by each verb's `clients.mcp` plus
349
+ its own handler auth.
350
+
351
+ ---
287
352
 
288
353
  ## Clients
289
354
 
290
355
  ### Shared
291
356
 
292
- **`cache(fn, options?)`** (`belte/shared/cache`) returns an invoker; calling it
293
- checks a store and shares the in-flight promise on a hit, or runs `fn` once on a
294
- miss. `fn` is a verb helper, `fn.raw`, or a plain producer.
357
+ `cache(fn, options?)` (`shared/cache`) returns an invoker; calling it dedupes
358
+ against a store a shared promise on hit, one invocation on miss. `fn` is a verb
359
+ helper, its `.raw`, or a plain producer returning a `Promise`.
295
360
 
296
361
  ```ts
297
- type cache = (fn, options?: {
298
- ttl?: number // ms past resolve: omitted = forever, 0 = dedupe only
299
- scope?: string | string[] // tags for grouped cache.invalidate({ scope })
300
- global?: boolean // process-level store instead of request-scoped (server)
301
- invalidate?: { throttle?: number } | { debounce?: number } // coalesce refetches
302
- }) => (args?) => Promise<Return>
362
+ type CacheOptions = {
363
+ ttl?: number // ms past resolve; omitted = forever, 0 = dedupe-only
364
+ scope?: string | string[] // tags for grouped cache.invalidate({ scope })
365
+ global?: boolean // process-level store instead of per-request
366
+ invalidate?: { throttle?: number } | { debounce?: number } // coalesce invalidations (stale-while-revalidate)
367
+ }
303
368
  ```
304
369
 
305
370
  ```ts
306
- // server (SSR) or client
307
- const post = await cache(getPost)({ id }) // decoded body
308
- const res = await cache(getPost.raw)({ id }) // raw Response, same cache key
309
- const rates = await cache(fetchRates)() // plain producer (hoist for dedupe)
371
+ // server (request-scoped store by default — per-user data never leaks across requests)
372
+ const post = await cache(getPost)({ id })
373
+
374
+ // browser (one tab store)
375
+ const post = $derived(await cache(getPost)({ id }))
310
376
  ```
311
377
 
312
378
  `cache.invalidate(selector?)`, `cache.pending(selector?)`, and
313
- `cache.refreshing(selector?)` share one selector grammar: omitted = all, a function
314
- = that function's calls, `{ scope }` = a tagged group. Keys are auto-derived from
315
- `method + url + args` (or `producer-ref + args`); arg keys distinguish `Date`,
316
- `Map`, `Set`, and `bigint` from look-alike values via `canonicalJson` (a `Date`
317
- never aliases its ISO string; a `Map` never aliases a plain object).
379
+ `cache.refreshing(selector?)` share one selector grammar: no arg = everything, a
380
+ function = that function's calls, `{ scope }` = a tagged group.
318
381
 
319
- **SSR mode is chosen by how you read** per Svelte's `{#await}` rule:
382
+ **SSR mode is decided by how you read**, per Svelte's `{#await}` rule:
320
383
 
321
384
  ```svelte
322
- <script>const post = await cache(getPost)({ id })</script> <!-- blocks render → inlined in initial HTML -->
385
+ <!-- top-level await blocks render → value baked into the initial HTML -->
386
+ <script>const post = await cache(getPost)({ id })</script>
323
387
 
324
- {#await cache(getPost)({ id }) then post} <!-- pending → shell flushes, value streams in -->
325
- {post.title}
326
- {/await}
388
+ <!-- {#await} → shell flushes now, value streams in on the same response -->
389
+ {#await cache(getPost)({ id }) then post}…{/await}
327
390
  ```
328
391
 
329
- A top-level `await` flips the whole component instance into await-everything mode;
330
- to mix blocking and streaming reads, isolate each blocking read in its own child
331
- component. Reactivity is implicit the invoker registers the surrounding
332
- `$derived`/`$effect` via `createSubscriber`, so `cache.invalidate` re-runs it.
333
-
334
- **`bundled()`** (`belte/shared/bundled`) returns whether the code is running inside
335
- the desktop bundle — `true` in the bundle's webview or its embedded server process,
336
- `false` in a plain browser tab or a standalone server binary. Same name, same
337
- meaning on both sides; each side detects it differently (client: the webview's init
338
- script; server: the launcher's `BELTE_PARENT_PID`). Branch UI or behaviour on it
339
- without threading a flag through your code.
392
+ There is no `ssr` option the consumption form is the switch. (A top-level await
393
+ flips Svelte's whole component instance into await-everything mode, so isolate
394
+ blocking and streaming reads in separate components.)
340
395
 
341
- ### Browser
396
+ Cache keys are derived with `canonicalJson.ts`, which tags types so they never
397
+ collide: a `Date` never equals the string of its ISO form, a `Map` never equals a
398
+ plain object, and `Set`/`bigint`/`-0` each key distinctly.
342
399
 
343
- **Pages** are Svelte 5 components. Routing is file-based under `src/browser/pages/`:
344
- every leaf lives in its own folder as `page.svelte`, `layout.svelte`, or
345
- `error.svelte`. Dynamic segments use `[id]` / `[...rest]` folder names.
400
+ `HttpError` (`shared/HttpError`) carries `status`, `statusText`, and the raw
401
+ `response` for error UI without opting into `.raw`.
346
402
 
347
- - **Layouts** are nearest-only: the deepest `layout.svelte` ancestor wraps a page.
348
- - **Error pages** (`error.svelte`) render server-side for a 404 (unknown route) or
349
- a throw during a page render, nearest-only, with `{ status, message, stack }`
350
- props. The document ships static (no hydration).
403
+ ### Browser
351
404
 
352
- **`navigate(href, options?)`** (`belte/browser/navigate`) is the SPA navigation
353
- entry point.
405
+ - **Pages** are folder-based Svelte 5 components: `src/browser/pages/**/page.svelte`,
406
+ the URL is the directory path. `[name]` is a dynamic param, `[...rest]` a
407
+ catch-all; params arrive as `$props`.
408
+ - **Layouts** are `layout.svelte` files; the nearest ancestor wraps a page
409
+ (nearest-only, not nested chains).
410
+ - **Error pages** are `error.svelte` files (nearest-only); they render on the server
411
+ for an unknown route (404) or a throw during render, receiving `{ status, message }`.
354
412
 
355
413
  ```ts
356
- type navigate = (href: string, options?: { replace?: boolean; scroll?: boolean }) => Promise<void>
414
+ // shared/cache reactivity is implicit createSubscriber drives the lifecycle
415
+ const post = $derived(await cache(getPost)({ id }))
357
416
  ```
358
417
 
359
- A same-pathname change (search/hash only) skips the network round-trip; a
360
- cross-origin or non-SPA target hard-navigates cleanly.
418
+ **`subscribe(subscribable)`** (`browser/subscribe`) reactively reads a streaming
419
+ source — a `Socket<T>` or `fn.stream(args)`. The first `$derived` read opens the
420
+ underlying iterator; the last to stop reading closes it; readers of the same key
421
+ share one subscription.
361
422
 
362
- **`page`** (`belte/browser/page`) is reactive route state — a discriminated union
363
- on `route`, so narrowing `page.route` types `page.params`.
423
+ ```svelte
424
+ <script>
425
+ import { subscribe } from '@belte/belte/browser/subscribe'
426
+ const latest = $derived(subscribe(chat)) // socket
427
+ const tick = $derived(subscribe(tickFeed.stream({ to: 5 }))) // rpc stream
428
+ const err = $derived(subscribe.error(chat)) // surfaced, never thrown
429
+ const status = $derived(subscribe.status(chat)) // 'pending' | 'open' | 'done' | 'error'
430
+ </script>
431
+ ```
364
432
 
365
- | Field | Type | Notes |
366
- | --- | --- | --- |
367
- | `page.route` | the matched route key | discriminant |
368
- | `page.params` | param shape for that route | typed from the generated routes |
369
- | `page.url` | live `URL` | reassigned on every nav so `$derived` re-runs |
433
+ `subscribe` is a no-op during SSR — seed initial HTML with `cache()` against an
434
+ HTTP verb, then layer `subscribe()` for live updates after hydration.
370
435
 
371
- **`subscribe(subscribable)`** (`belte/browser/subscribe`) reactively reads a
372
- streaming source — a `Socket<T>` or `fn.stream(args)`:
436
+ **`navigate(href, options?)`** (`browser/navigate`) does SPA navigation:
373
437
 
374
- ```svelte
375
- <script lang="ts">
376
- import { subscribe } from '@belte/belte/browser/subscribe'
377
- import { chat } from '$server/sockets/chat'
378
- const latest = $derived(subscribe(chat)) // T | undefined
379
- const status = $derived(subscribe.status(chat)) // 'pending' | 'open' | 'done' | 'error'
380
- const err = $derived(subscribe.error(chat)) // Error | undefined
381
- </script>
438
+ ```ts
439
+ type NavigateOptions = { replace?: boolean; scroll?: boolean }
440
+ await navigate('/posts/42')
382
441
  ```
383
442
 
384
- It opens the iterator on the first `$derived` read and closes it when the last
385
- reader stops; many `$derived`s reading the same source share one subscription
386
- (deduped by name). It's a no-op during SSR — seed initial HTML via `cache()` against
387
- an HTTP rpc and layer `subscribe()` on top for live updates after hydration.
443
+ A same-pathname change skips the network round-trip and just reassigns `page.url`;
444
+ a non-SPA target hard-navigates cleanly.
445
+
446
+ **`page`** (`browser/page`) is reactive `$state`. Narrowing on `page.route` narrows
447
+ `page.params` to the matching shape.
448
+
449
+ | field | type | meaning |
450
+ | --- | --- | --- |
451
+ | `page.route` | route key | the matched route (e.g. `/posts/[id]`) |
452
+ | `page.params` | params for the route | path params, typed per route |
453
+ | `page.url` | `URL` | live location; reassigned on every navigation |
388
454
 
389
455
  ### Mcp
390
456
 
391
- The MCP server is fully framework-generated at `/__belte/mcp` (JSON-RPC, protocol
457
+ The MCP server is generated at `/__belte/mcp` (JSON-RPC over HTTP, protocol
392
458
  `2025-06-18`) — there is no module to author.
393
459
 
394
- - **Tools** come from every verb with `clients.mcp` (read-only + schema → auto;
395
- mutating explicit `clients.mcp`) and from every mcp-exposed socket: a
396
- `<base>-tail` read tool plus a `<base>-publish` tool when `clientPublish` is set.
397
- The HTTP verb feeds each tool's annotations (`readOnlyHint`/`destructiveHint`/
398
- `idempotentHint`). Tool calls run through the same `verb.fetch` seam as the HTTP
399
- router, inheriting forwarded auth headers.
400
- - **Resources** are files under `src/mcp/resources/`, served under the
401
- `belte://resources/<path>` URI namespace (text inline, binary as base64).
402
- - **Prompts** are `.md` files under `src/mcp/prompts/`. YAML frontmatter carries
403
- `description` and an `arguments` list; the body interpolates `{{name}}`
404
- placeholders at `prompts/get`.
460
+ - **Tools** come from every verb with `clients.mcp: true` (read-only + schema
461
+ auto-on; mutations opt in) and every mcp-exposed socket (a `<base>-tail` read tool,
462
+ plus `<base>-publish` when `clientPublish` is set). The HTTP verb feeds each tool's
463
+ `readOnlyHint` / `destructiveHint` / `idempotentHint` annotations. Auth inherits
464
+ from the inbound request.
465
+ - **Resources** are files under `src/mcp/resources/`, served at
466
+ `belte://resources/<path>` (text inline, binary as base64). No module to author.
467
+ - **Prompts** are `src/mcp/prompts/**.md` files: optional YAML frontmatter
468
+ (`description`, `arguments`) plus a body interpolated via `{{name}}` placeholders.
405
469
 
406
470
  ```md
407
471
  ---
408
- description: Summarize a thread
472
+ description: Summarise a thread
409
473
  arguments:
410
474
  - name: topic
411
- description: what to focus on
412
475
  required: true
413
476
  ---
414
- Summarize the discussion, focusing on {{topic}}.
477
+ Summarise the discussion about {{topic}}.
415
478
  ```
416
479
 
417
480
  ### Cli
418
481
 
419
- `belte cli` builds a thin remote-client binary (no handler code) that ships the
420
- compiled server beside it. It talks to a running server over HTTP, or boots a local
421
- one with `<name> /start`.
482
+ `belte cli` builds a thin remote client it carries no handler code, talks to a
483
+ running server over HTTP, and ships the compiled server beside it so it can spawn a
484
+ local instance.
485
+
486
+ - Connection state comes from `BELTE_APP_URL` / `BELTE_APP_TOKEN` (shell env >
487
+ data-dir `.env` > binary-dir `.env`). A downloaded binary resumes against its
488
+ baked default.
489
+ - The first positional decides the action: `/`-prefixed verbs manage the connection,
490
+ a bare word runs an rpc.
422
491
 
423
- | First arg | Action |
492
+ | command | does |
424
493
  | --- | --- |
425
- | `--help` / `-h`, `/help [cmd]` | top-level or per-command help |
426
- | (none) on a TTY | interactive session, resuming the saved connection |
494
+ | `<cmd> [--flags]` | one-shot rpc against the resumed target |
427
495
  | `/connect <url>` | connect to a remote server, open a session |
428
496
  | `/start` | boot a local instance, open a session |
429
497
  | `/disconnect` | forget the saved connection |
430
- | `<cmd> [--flags]` | one-shot rpc against the resumed target |
498
+ | `/help [cmd]` | help, per-command with an arg |
499
+ | *(none)* on a TTY | interactive session resuming the saved connection |
431
500
 
432
- Connection target comes from `BELTE_APP_URL` / `BELTE_APP_TOKEN` (precedence: shell
433
- > data-dir `.env` > binary-dir `.env`). The token sets a `Bearer` auth header, so an
434
- authenticated server's CLI (and its authenticated binary downloads via
435
- `/__belte/cli`) work the same.
501
+ Schema-bearing rpcs become subcommands; the JSON Schema types the flags:
436
502
 
437
- Each rpc becomes a subcommand; the input schema derives the flags:
438
-
439
- | Schema type | Flag form |
503
+ | property type | flag form |
440
504
  | --- | --- |
441
505
  | `boolean` | `--name` / `--no-name` |
442
506
  | `number` / `integer` | `--name <n>` (coerced) |
443
507
  | `array` | repeated `--name <v>` |
444
- | anything else | `--name <value>` |
445
- | complex / nested | `--json '<args>'`, or pipe a JSON object on stdin |
508
+ | anything else | `--name <value>` (string) |
509
+ | complex shapes | `--json '<args>'`, or pipe a JSON object on stdin |
446
510
 
447
- Optional `src/cli/banner.txt` prints above top-level help; `src/cli/footer.txt`
448
- below it.
511
+ A running server hands out the client: `GET /__belte/cli` returns a POSIX install
512
+ script (detects OS+arch, downloads the right tarball); `GET /__belte/cli/<platform>`
513
+ streams a gzipped tarball of the platform binary, its sibling server, and a `.env`
514
+ carrying `BELTE_APP_URL` (and `BELTE_APP_TOKEN` if the request was authenticated).
515
+ `src/cli/banner.txt` and `src/cli/footer.txt` wrap the help output.
449
516
 
450
517
  ### Bundle
451
518
 
452
519
  `belte bundle` assembles a movable, self-contained desktop app for the host
453
- platform the server binary, the launcher, and the webview lib together (a `.app`
454
- on macOS, a flat directory elsewhere). On macOS the `.app` is **ad-hoc
455
- code-signed** (`codesign --sign -`, no certificate) so it launches on other Macs; a
456
- quarantined copy may still need `xattr -cr <app>` once. Full distribution still
457
- needs a Developer ID signature and notarization.
458
-
459
- The app boots into a connect screen that either starts the embedded server or
460
- connects to a remote one. Customize via files under `src/bundle/`:
461
-
462
- - **`window.ts`** default-exports a `BundleWindow` (`belte/bundle/BundleWindow`):
463
- `title`, `width`, `height`, custom `menu` (`BundleMenu` / `BundleMenuItem`), and a
464
- `config` schema override for the first-run setup form (defaults to the
465
- `src/server/config.ts` env schema).
466
- - **`disconnected.svelte`** overrides the connect screen.
467
- - **`onMenu`** (`belte/bundle/onMenu`) subscribes to custom menu clicks, returning
468
- an unsubscribe for `$effect`:
520
+ platform (a `.app` on macOS, a flat directory elsewhere) the server binary, the
521
+ launcher, and the native webview lib together. It boots into a connect screen that
522
+ can **start the embedded server** or **connect to a remote one**.
523
+
524
+ > Bundles are **unsigned** distributing to other users still needs platform
525
+ > signing/notarization, and macOS Gatekeeper will warn until then.
526
+
527
+ - **`src/bundle/window.ts`** default-exports a `BundleWindow`:
528
+
529
+ ```ts
530
+ type BundleWindow = {
531
+ title?: string
532
+ width?: number
533
+ height?: number
534
+ menu?: BundleMenu[] // custom top-level menus between Edit and Window
535
+ config?: StandardSchemaV1 // overrides the first-run setup form (defaults to the env schema)
536
+ }
537
+ ```
538
+
539
+ - The standard App/Edit/Window menus plus a File menu (Start / Connect /
540
+ Disconnect) are always installed. Custom menu items are either an `emit` (a
541
+ `belte:menu` CustomEvent into the page) or a `navigate` (repoints the window).
542
+ - **`onMenu`** (`bundle/onMenu`) subscribes to those emits inside a Svelte `$effect`:
469
543
 
470
544
  ```ts
471
545
  $effect(() => onMenu('reload', () => location.reload()))
472
546
  ```
473
547
 
474
- - **`icon.png`** is the app icon.
548
+ - **`src/bundle/disconnected.svelte`** overrides the default connect screen.
549
+ - **`src/bundle/icon.png`** is the app icon.
550
+ - **`bundled()`** (`shared/bundled`) is `true` when running inside the desktop
551
+ webview (or, server-side, the embedded server process).
552
+
553
+ ---
475
554
 
476
555
  ## Some details
477
556
 
478
- **Config / env** — optional `src/server/config.ts` calls `env(schema)`
479
- (`belte/server/env`) to validate `Bun.env` against a Standard Schema at boot; a
480
- missing/malformed var fails the boot with every issue listed. belte eager-imports
481
- the file (no import from your code); import `config` from `$server/config`
482
- server-side. `appDataDir()` (`belte/server/appDataDir`) returns the bundled app's
483
- per-user data directory.
557
+ ### Config / env
484
558
 
485
- **App hooks** — optional exports from `src/app.ts` (`belte/server/AppModule` types
486
- them):
559
+ `env(schema)` (`server/env`) validates `Bun.env` against a Standard Schema at boot
560
+ — a missing or malformed variable fails the boot with every issue listed, instead
561
+ of surfacing as `undefined` in a handler. The conventional home is
562
+ `src/server/config.ts`, eager-imported at boot:
487
563
 
488
- | Export | Runs |
489
- | --- | --- |
490
- | `forwardHeaders` | extra inbound header names to forward onto in-process rpc Requests |
491
- | `init({ server })` | once after `Bun.serve` is up; return a cleanup for SIGINT/SIGTERM |
492
- | `handle(request, next)` | middleware wrapping the request pipeline |
493
- | `handleError(error, request)` | custom 500 fallback |
564
+ ```ts
565
+ // src/server/config.ts
566
+ import { env } from '@belte/belte/server/env'
567
+ import { z } from 'zod'
568
+ export const config = env(z.object({ DATABASE_URL: z.string(), STRIPE_KEY: z.string() }))
569
+ ```
570
+
571
+ The same schema drives the desktop bundle's first-run setup form. `appDataDir()`
572
+ (`server/appDataDir`) returns the running bundle's per-user data directory.
494
573
 
495
- **Project layout**
574
+ ### App hooks
575
+
576
+ All optional, exported from `src/app.ts`:
577
+
578
+ | hook | signature | role |
579
+ | --- | --- | --- |
580
+ | `forwardHeaders` | `string[]` | extra inbound header names to forward onto in-process rpc Requests |
581
+ | `init` | `({ server }) => void \| (() => void)` | boot setup; an optional returned cleanup runs on SIGINT/SIGTERM |
582
+ | `handle` | `(req, next) => Response` | single middleware; mutate the response or branch on the URL |
583
+ | `handleError` | `(error, req) => Response` | catches thrown handler errors |
584
+
585
+ ### Project layout
496
586
 
497
587
  ```text
498
588
  src/
499
- app.ts optional hooks
589
+ app.ts # optional hooks
500
590
  server/
501
- config.ts optional env(schema), eager-imported at boot
502
- rpc/<name>.ts one verb-bound function per file → /rpc/<path>
503
- sockets/<name>.ts one socket per file
504
- lib/ userland (declare your own aliases)
591
+ config.ts # env(schema)
592
+ rpc/<name>.ts # one verb each → /rpc/<name>
593
+ sockets/<name>.ts # one socket each
505
594
  browser/
506
- app.html optional shell override
507
- pages/**/page.svelte routes (layout.svelte, error.svelte nearest-only)
508
- public/ served at the site root
595
+ pages/**/page.svelte # routes
596
+ pages/**/layout.svelte # nearest-only layouts
597
+ pages/**/error.svelte # nearest-only error pages
598
+ public/ # static files served at the site root
509
599
  mcp/
510
- prompts/*.md MCP prompts
511
- resources/** MCP resources
600
+ resources/ # belte://resources/<path>
601
+ prompts/**.md # MCP prompts
512
602
  bundle/
513
- window.ts BundleWindow config
514
- disconnected.svelte connect-screen override
603
+ window.ts # BundleWindow
604
+ disconnected.svelte # connect screen override
515
605
  icon.png
516
606
  cli/
517
- banner.txt footer.txt CLI help chrome
607
+ banner.txt
608
+ footer.txt
518
609
  ```
519
610
 
520
- Aliases `$server`, `$browser`, `$shared`, `$mcp`, `$cli` resolve to the top-level
521
- project directories; `lib/` is userland.
522
-
523
- **CLI commands**
611
+ ### CLI commands
524
612
 
525
- | Command | Does |
613
+ | command | does |
526
614
  | --- | --- |
527
615
  | `bunx belte scaffold <name>` | scaffold a new project |
528
- | `belte dev` | build the client + run the server with browser live-reload |
616
+ | `belte dev` | build + run with hot reload |
529
617
  | `belte build` | build the client into `dist/_app/` |
530
618
  | `belte start` | run the production server against `dist/` |
531
619
  | `belte run <file> [args]` | run a script under the belte preload (same runtime as the server) |
532
620
  | `belte compile [--target] [--out]` | build a standalone server executable |
533
- | `belte cli [--target] [--out] [--platforms a,b,c]` | build the thin CLI binary (ships the server beside it) |
534
- | `belte bundle` | build a movable, self-contained desktop app (macOS `.app` ad-hoc signed) |
621
+ | `belte cli [--target] [--out] [--platforms]` | build the thin CLI binary |
622
+ | `belte bundle` | build a movable desktop app for this platform |
535
623
 
536
- **Bundling targets**
624
+ ### Bundling targets
537
625
 
538
- | Target | Output | Surface |
539
- | --- | --- | --- |
540
- | `belte build` | `dist/_app/` client assets | web (with `belte start`) |
541
- | `belte compile` | one server executable | self-hosted HTTP |
542
- | `belte cli` | thin client binary + sibling server | CLI / scripting |
543
- | `belte bundle` | movable app (`.app` on macOS) | desktop |
626
+ `--target` / `--platforms` accept Bun's target triples:
544
627
 
545
- **`public/` files** under `src/browser/public/` are served at the site root,
546
- bypassing the request scope and middleware.
628
+ | target |
629
+ | --- |
630
+ | `bun-darwin-arm64` |
631
+ | `bun-darwin-x64` |
632
+ | `bun-linux-arm64` |
633
+ | `bun-linux-x64` |
634
+ | `bun-windows-x64` |
547
635
 
548
- **Logging** — `log` (`belte/shared/log`) wraps `console.*` with a `[belte]` prefix
549
- and per-method/status coloring. `DEBUG` follows the `debug` convention; `DEBUG=belte`
550
- turns on request logging and prints the boot surface map.
636
+ ### Logging
551
637
 
552
- **Environment variables**
553
-
554
- | Var | Effect |
555
- | --- | --- |
556
- | `PORT` | bind this exact port; unset scans upward from 3000 |
557
- | `BELTE_IDLE_TIMEOUT` | Bun per-connection idle timeout (seconds; default 10) |
558
- | `DEBUG` | `belte` enables request logs + the surface map |
559
- | `BELTE_APP_URL` / `BELTE_APP_TOKEN` | CLI binary's default server + bearer token |
638
+ The shared logger prefixes `[belte]` and colours request lines by method/status.
639
+ `DEBUG=<scope>` enables scoped debug output; **`DEBUG=belte` prints the boot
640
+ surface map** shown at the top of this document — the auditable list of every page,
641
+ socket, and rpc with the surfaces it reaches.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@belte/belte",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -1,8 +1,7 @@
1
- import { promptRegistry } from '../server/prompts/promptRegistry.ts'
2
1
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
3
2
  import { NO_STORE } from '../shared/CACHE_CONTROL_VALUES.ts'
4
3
  import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
5
- import { buildPrompts, buildTools, callTool } from './mcpSurface.ts'
4
+ import { buildPrompts, buildTools, callTool, renderPrompt } from './mcpSurface.ts'
6
5
  import type { JsonRpcRequest } from './types/JsonRpcRequest.ts'
7
6
  import type { JsonRpcResponse } from './types/JsonRpcResponse.ts'
8
7
  import type { McpServerOptions } from './types/McpServerOptions.ts'
@@ -33,14 +32,13 @@ function getPrompt(
33
32
  name: string,
34
33
  args: Record<string, unknown> | undefined,
35
34
  ): Record<string, unknown> {
36
- const entry = promptRegistry.get(name)
37
- if (!entry) {
38
- throw new Error(`unknown prompt: ${name}`)
39
- }
40
- const rendered = entry.prompt.render((args ?? {}) as Record<string, string>)
35
+ const { description, messages } = renderPrompt(name, args)
41
36
  return {
42
- ...(entry.prompt.description ? { description: entry.prompt.description } : {}),
43
- messages: [{ role: 'user', content: { type: 'text', text: rendered } }],
37
+ ...(description ? { description } : {}),
38
+ messages: messages.map((message) => ({
39
+ role: message.role,
40
+ content: { type: 'text', text: message.text },
41
+ })),
44
42
  }
45
43
  }
46
44
 
@@ -230,16 +230,31 @@ export async function callTool(
230
230
  }
231
231
 
232
232
  /*
233
- Renders a prompt to the message(s) that seed a conversation. A markdown
234
- prompt is a single user turn whose text is the interpolated template.
235
- Throws on an unknown prompt name.
233
+ Renders a prompt: looks it up, interpolates the caller's args, and returns its
234
+ optional description plus the message(s) that seed a conversation. A markdown
235
+ prompt is a single user turn whose text is the interpolated template. Throws on
236
+ an unknown prompt name. The one place prompt rendering lives — dispatchMcpRequest
237
+ wraps this in the prompts/get wire shape, the agent loop reads the messages plain.
236
238
  */
237
- export function getPromptMessages(name: string, args?: Record<string, unknown>): PromptMessage[] {
239
+ export function renderPrompt(
240
+ name: string,
241
+ args?: Record<string, unknown>,
242
+ ): { description?: string; messages: PromptMessage[] } {
238
243
  const entry = promptRegistry.get(name)
239
244
  if (!entry) {
240
245
  throw new Error(`unknown prompt: ${name}`)
241
246
  }
242
- return [{ role: 'user', text: entry.prompt.render((args ?? {}) as Record<string, string>) }]
247
+ return {
248
+ ...(entry.prompt.description ? { description: entry.prompt.description } : {}),
249
+ messages: [
250
+ { role: 'user', text: entry.prompt.render((args ?? {}) as Record<string, string>) },
251
+ ],
252
+ }
253
+ }
254
+
255
+ // The conversation-seeding messages for a prompt, without the wire-shape wrapping.
256
+ export function getPromptMessages(name: string, args?: Record<string, unknown>): PromptMessage[] {
257
+ return renderPrompt(name, args).messages
243
258
  }
244
259
 
245
260
  /*
@@ -248,10 +263,20 @@ Projects the app's MCP surface for an in-process consumer bound to `request`
248
263
  the model acts with the caller's identity. Used by `agent()`.
249
264
  */
250
265
  export function mcpSurface(request: Request): McpSurface {
266
+ // Built on first read and memoized: an engine that advertises tools but never
267
+ // reads prompts (or reaches tools over HTTP, reading neither) skips the unused build.
268
+ let tools: ToolDescriptor[] | undefined
269
+ let prompts: PromptDescriptor[] | undefined
251
270
  return {
252
- tools: buildTools(),
271
+ get tools() {
272
+ tools ??= buildTools()
273
+ return tools
274
+ },
275
+ get prompts() {
276
+ prompts ??= buildPrompts()
277
+ return prompts
278
+ },
253
279
  call: (name, args) => callTool(name, args, request),
254
- prompts: buildPrompts(),
255
280
  getPrompt: getPromptMessages,
256
281
  async listResources() {
257
282
  const server = getMcpResourceServer()
@@ -41,7 +41,16 @@ export type AgentFrame =
41
41
  | { type: 'text'; delta: string }
42
42
  | { type: 'tool_use'; id: string; name: string; input: unknown }
43
43
  | { type: 'tool_result'; id: string; name: string; ok: boolean }
44
- | { type: 'done'; stop: 'end' | 'tool_use' | 'max_tokens' | 'refusal' }
44
+ /*
45
+ `stop` is the reason the engine's loop ended. `error` covers an abnormal
46
+ stop the model didn't choose — a provider error/limit (e.g. Claude Code's
47
+ max-turns) or the engine's own tool-loop cap — so a client can tell a cut-off
48
+ answer from a clean `end`.
49
+ */
50
+ | {
51
+ type: 'done'
52
+ stop: 'end' | 'tool_use' | 'max_tokens' | 'refusal' | 'error'
53
+ }
45
54
 
46
55
  // The app's tool/prompt/resource surface handed to an engine (already gated).
47
56
  export type AgentSurface = McpSurface
@@ -262,17 +262,17 @@ export async function createServer({
262
262
  ])
263
263
  const ErrorView = errorMod.default as Component
264
264
  const Layout = layoutMod?.default as Component | undefined
265
+ // status is a number (and stack optional); the page-params shape is
266
+ // string-keyed generically, so the error props ride through as-is.
267
+ const errorParams = { status, message, stack } as unknown as Record<string, string>
268
+ /* Publish to the store too, so the `page` proxy resolves these during the error render
269
+ (renderError bypasses renderPage, which is where normal renders set them). */
270
+ store.route = pathname
271
+ store.params = errorParams
265
272
  const rendered = await render(App, {
266
273
  props: {
267
274
  state: {
268
- page: {
269
- route: pathname,
270
- // status is a number (and stack optional); the page-params
271
- // shape is string-keyed generically, so the error props
272
- // ride through to the component as-is.
273
- params: { status, message, stack } as unknown as Record<string, string>,
274
- url: store.url,
275
- },
275
+ page: { route: pathname, params: errorParams, url: store.url },
276
276
  render: { Layout, Page: ErrorView },
277
277
  },
278
278
  },