@personaai/runtime 0.5.0

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/README.md ADDED
@@ -0,0 +1,411 @@
1
+ # @personaai/runtime
2
+
3
+ Framework-agnostic runtime engine for [Persona](https://persona.hasanraiyan.me). This is the
4
+ shared engine every framework adapter (`@personaai/express`, `@personaai/nextjs`, ...) is meant
5
+ to be a thin translation layer over — see
6
+ [the SDK Ecosystem plan](https://github.com/hasanraiyan/agent-marketplace/blob/feat/ai/product-research/11-sdk-new/package-ecosystem.md).
7
+
8
+ **v0.5.** Not installed directly by most developers yet — there is no published framework
9
+ adapter for it in this release. See [Quickstart](#quickstart) for how to run it directly against
10
+ raw Node `http` in the meantime, and [Not yet implemented](#not-yet-implemented) for what's
11
+ missing before it's a complete Level 2 runtime.
12
+
13
+ **Server-side only.** The credential this runtime holds is a server-side secret — never bundle
14
+ this into a browser app.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ npm install @personaai/runtime
20
+ ```
21
+
22
+ ## The user resolver contract
23
+
24
+ Persona never authenticates users. The runtime receives a request and asks *you* who it's from:
25
+
26
+ ```ts
27
+ import { createRuntime } from '@personaai/runtime';
28
+
29
+ const runtime = createRuntime({
30
+ baseUrl: process.env.PERSONA_BASE_URL!,
31
+ credential: process.env.PERSONA_CREDENTIAL!,
32
+ resolveUser: async (request) => {
33
+ // Your auth, your rules — Clerk, a JWT, a session cookie, whatever you
34
+ // already use. Return the resolved external user id, or null/throw if
35
+ // the request isn't authenticated (the runtime responds 401 either way).
36
+ return getUserIdFromSession(request.headers['cookie']);
37
+ },
38
+ });
39
+ ```
40
+
41
+ `resolveUser` is the single point of contact between your auth world and Persona's runtime
42
+ world — see `RunContext`/the design notes below for why this boundary is absolute.
43
+
44
+ ## Quickstart (raw Node `http`, no framework adapter yet)
45
+
46
+ There's no published `@personaai/node` adapter package yet, so this release ships a small,
47
+ tested bridge in `examples/` for running the runtime directly against Node's `http` module —
48
+ **not itself a published entry point**, just enough to demo/smoke-test the runtime end to end
49
+ until `@personaai/node` ships. It parses multipart file uploads too, via Node's native
50
+ `Request`/`FormData` (undici) — no extra dependency:
51
+
52
+ ```ts
53
+ import { createServer } from 'node:http';
54
+ import { createRuntime } from '@personaai/runtime';
55
+ import { toNodeHandler } from '@personaai/runtime/examples/node-handler.js'; // not a stable public API
56
+
57
+ const runtime = createRuntime({
58
+ baseUrl: process.env.PERSONA_BASE_URL!,
59
+ credential: process.env.PERSONA_CREDENTIAL!,
60
+ resolveUser: (request) => request.headers['x-demo-user-id'] ?? null,
61
+ });
62
+
63
+ createServer(toNodeHandler(runtime)).listen(3210);
64
+ ```
65
+
66
+ ## Routes
67
+
68
+ All routes are relative to whatever `mountPath` you configure (default: none, i.e. the runtime
69
+ expects `request.path` already stripped). Every route except `/health` requires an authenticated
70
+ user; `resolveUser` returning `null` or throwing responds `401`.
71
+
72
+ ### Always on — end-user-scoped, no capability flag needed
73
+
74
+ | Method | Path | Proxies to |
75
+ | --- | --- | --- |
76
+ | `POST` | `/chat` | `client.chat.stream(agentId, {messages, threadId, resume, contextOverride})`, streamed out as SSE. `agentId`/`messages` required in the body. Response carries an `x-persona-run-id` header — see [Reconnect and resume](#reconnect-and-resume). |
77
+ | `GET` | `/chat/:runId/resume` | Reattaches to the run started by the matching `POST /chat`. See [Reconnect and resume](#reconnect-and-resume). |
78
+ | `GET` | `/threads` | `client.threads.list({page, limit})` |
79
+ | `POST` | `/threads` | `client.threads.create({agentId})` |
80
+ | `POST` | `/threads/bulk-delete` | `client.threads.bulkDelete(ids)` |
81
+ | `GET` | `/threads/:id` | `client.threads.get(id)` |
82
+ | `PATCH` | `/threads/:id` | `client.threads.update(id, {title?, isArchived?})` |
83
+ | `DELETE` | `/threads/:id` | `client.threads.delete(id)` → `204` |
84
+ | `GET` | `/threads/:id/messages` | `client.threads.getMessages(id)` — full history + graph state, the same data `chat.stream()` resumes from; load a past conversation on page reopen. |
85
+ | `GET` | `/agents` | `client.agents.list({page, limit, search, category, scope})` — read-only discovery, e.g. "let the user pick an agent." |
86
+ | `GET` | `/files` | `client.files.list({page, limit})` |
87
+ | `POST` | `/files` | `client.files.upload({filename, content, contentType?, agentId?, threadId?})` — multipart, `file` part required. `201` |
88
+ | `POST` | `/files/bulk-delete` | `client.files.bulkDelete(ids)` |
89
+ | `GET` | `/files/:id` | `client.files.download(id)` — raw bytes, streamed through as `kind: 'binary'` |
90
+ | `DELETE` | `/files/:id` | `client.files.delete(id)` → `204` |
91
+ | `GET` | `/memory` | `client.memory.list()` |
92
+ | `GET` | `/memory/file` | `client.memory.getFile({path, scope?, agentId?})` — `path` query param required |
93
+ | `PUT` | `/memory/file` | `client.memory.writeFile({path, content, scope?, agentId?})` — creates or overwrites |
94
+ | `DELETE` | `/memory/file` | `client.memory.deleteFile({path, scope?, agentId?})` → `204` |
95
+ | `GET` | `/mcps/:id/oauth/owner/authorize` | `client.mcps.oauth.getOwnerAuthorizeUrl(id)` → `{url}` to redirect the Project owner to |
96
+ | `GET` | `/mcps/:id/oauth/user/authorize` | `client.mcps.oauth.getUserAuthorizeUrl(id, returnTo?)` → `{url}` to redirect the end user to |
97
+ | `GET` | `/mcps/:id/oauth/user/status` | `client.mcps.oauth.getUserConnectionStatus(id)` |
98
+ | `DELETE` | `/mcps/:id/oauth/user/connection` | `client.mcps.oauth.disconnectUserConnection(id)` → `204` |
99
+ | `DELETE` | `/mcps/:id/oauth/owner/connection` | `client.mcps.oauth.disconnectOwnerConnection(id)` → `204` |
100
+ | `GET` | `/health` | `client.whoami()` → `{status, version, capabilities}`. Does **not** require `resolveUser` — it's a liveness/capability probe, not a user-scoped call. |
101
+
102
+ `scope` for memory routes is `'user'` (default) or `'agent'` (`agentId` then required).
103
+
104
+ For `POST /files`, a framework adapter must parse the incoming multipart body and populate
105
+ `RuntimeRequest.file` (`{filename, content: Uint8Array, contentType?}`) plus put any other form
106
+ fields (`agentId`, `threadId`) on `RuntimeRequest.body` — the runtime itself never touches raw
107
+ bytes or a specific multipart parser. See `examples/node-handler.ts`'s `readMultipartBody` for
108
+ the reference approach (it also handles `POST /knowledge/:id/documents`'s multi-file `files`
109
+ field, below).
110
+
111
+ ### Opt-in — Project-level admin surface, `capabilities.*` gated
112
+
113
+ See [Capabilities](#capabilities--admin-surface) for what these are, why they default off, and
114
+ how to enable them safely.
115
+
116
+ | Method | Path | Capability | Proxies to |
117
+ | --- | --- | --- | --- |
118
+ | `POST` | `/agents` | `agentsWrite` | `client.agents.create(input)` |
119
+ | `GET`/`PATCH`/`DELETE` | `/agents/:id` | `agentsWrite` | `client.agents.get/update/delete(id)` |
120
+ | `POST` | `/agents/bulk-delete` | `agentsWrite` | `client.agents.bulkDelete(ids)` |
121
+ | `GET`/`POST` | `/mcps` | `mcps` | `client.mcps.list/create` |
122
+ | `GET`/`PATCH`/`DELETE` | `/mcps/:id` | `mcps` | `client.mcps.get/update/delete(id)` |
123
+ | `POST` | `/mcps/bulk-delete` | `mcps` | `client.mcps.bulkDelete(ids)` |
124
+ | `GET` | `/mcps/:id/usage` | `mcps` | `client.mcps.getUsage(id)` |
125
+ | `POST` | `/mcps/:id/test` | `mcps` | `client.mcps.testConnection(id)` |
126
+ | `GET` | `/mcps/:id/resource?uri=` | `mcps` | `client.mcps.readResource(id, uri)` |
127
+ | `POST` | `/mcps/:id/call-tool` | `mcps` | `client.mcps.callTool(id, name, arguments)` |
128
+ | `GET`/`POST` | `/providers` | `providers` | `client.providers.list/create` — **holds API keys** |
129
+ | `GET`/`PATCH`/`DELETE` | `/providers/:id` | `providers` | `client.providers.get/update/delete(id)` |
130
+ | `POST` | `/providers/bulk-delete` | `providers` | `client.providers.bulkDelete(ids)` |
131
+ | `POST` | `/providers/:id/test` | `providers` | `client.providers.testConnection(id)` |
132
+ | `GET` | `/providers/:id/models` | `providers` | `client.providers.getModels(id)` |
133
+ | `GET` | `/providers/:id/usage` | `providers` | `client.providers.getUsage(id)` |
134
+ | `GET`/`POST` | `/skills` | `skills` | `client.skills.list/create` |
135
+ | `GET`/`PATCH`/`DELETE` | `/skills/:id` | `skills` | `client.skills.get/update/delete(id)` |
136
+ | `POST` | `/skills/bulk-delete` | `skills` | `client.skills.bulkDelete(ids)` |
137
+ | `GET` | `/skills/:id/usage` | `skills` | `client.skills.getUsage(id)` |
138
+ | `GET`/`POST` | `/knowledge` | `knowledge` | `client.knowledge.list/create` |
139
+ | `GET`/`PATCH`/`DELETE` | `/knowledge/:id` | `knowledge` | `client.knowledge.get/update/delete(id)` |
140
+ | `POST` | `/knowledge/bulk-delete` | `knowledge` | `client.knowledge.bulkDelete(ids)` |
141
+ | `GET` | `/knowledge/:id/usage` | `knowledge` | `client.knowledge.getUsage(id)` |
142
+ | `POST` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.uploadDocuments(id, files)` — multipart, one or more `files` parts required. `201` |
143
+ | `GET` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.listDocuments(id)` |
144
+ | `DELETE` | `/knowledge/:id/documents/:sourceName` | `knowledge` | `client.knowledge.deleteDocument(id, sourceName)` |
145
+ | `POST` | `/knowledge/:id/search` | `knowledge` | `client.knowledge.search(id, query, {topK?})` |
146
+ | `GET`/`POST` | `/stores` | `stores` | `client.stores.list/create` |
147
+ | `GET`/`PATCH`/`DELETE` | `/stores/:id` | `stores` | `client.stores.get/update/delete(id)` |
148
+ | `GET` | `/stores/:id/files` | `stores` | `client.stores.listFiles(id)` |
149
+ | `GET`/`PUT`/`DELETE` | `/stores/:id/file` | `stores` | `client.stores.getFile/writeFile/deleteFile(id, {path, content?})` |
150
+ | `GET` | `/audit-logs` | `auditLogs` | `client.auditLogs.list({page, limit, eventType})` |
151
+ | `POST` | `/architect` | `architect` | `client.architect.stream({messages, resume})`, streamed out as SSE, same `x-persona-run-id`/reconnect mechanics as `/chat`. No `agentId` — the Architect builds/edits the caller's own Agents. |
152
+ | `GET` | `/architect/:runId/resume` | `architect` | Reattaches to the matching `POST /architect` run. |
153
+
154
+ A disabled capability's routes are simply absent from the route table — a request to one 404s
155
+ (or, where an always-on route shares the same path with a different method, e.g. `POST /agents`
156
+ while only `GET /agents` is always-on, `405`) rather than 403, so a disabled capability leaks no
157
+ information about what it would have done.
158
+
159
+ ## Capabilities — admin surface
160
+
161
+ The routes above are split into two trust tiers, and this is a deliberate design choice, not an
162
+ oversight:
163
+
164
+ - **Always on**: things an end user does in their own chat session — send messages, manage their
165
+ own conversations/files/memory, connect their own MCP account. Scoped entirely to whichever
166
+ user `resolveUser` returns.
167
+ - **Opt-in via `capabilities`**: Project-level configuration — LLM provider credentials, skill
168
+ authoring, knowledge base and vector store management, security audit logs, an agent-building
169
+ co-pilot, and full Agent/MCP-server CRUD. **Every one of these defaults to `false`.** Upgrading
170
+ this package never silently exposes new surface to whoever `resolveUser` accepts.
171
+
172
+ ```ts
173
+ createRuntime({
174
+ // ...
175
+ capabilities: {
176
+ agentsWrite: false, // default
177
+ mcps: false, // default
178
+ providers: false, // default — holds API keys, think hard before enabling
179
+ skills: false, // default
180
+ knowledge: false, // default
181
+ stores: false, // default
182
+ auditLogs: false, // default
183
+ architect: false, // default
184
+ },
185
+ });
186
+ ```
187
+
188
+ **Most hosts should never turn any of these on**, and should instead call `@personaai/sdk`
189
+ directly from their own admin backend/CLI/setup script for Project configuration — that's what
190
+ "belongs to the host application" means in practice.
191
+
192
+ If you *do* want an admin surface reachable over HTTP (e.g. building your own internal admin
193
+ tool on top of this runtime), the right pattern is **two separate `createRuntime()` calls
194
+ mounted at two different paths**, each with its own `resolveUser`:
195
+
196
+ ```ts
197
+ const appRuntime = createRuntime({
198
+ baseUrl, credential,
199
+ resolveUser: resolveEndUser, // your normal app auth — any logged-in user
200
+ });
201
+
202
+ const adminRuntime = createRuntime({
203
+ baseUrl, credential,
204
+ resolveUser: resolveAdminUser, // a stricter check — only your team
205
+ capabilities: { providers: true, skills: true, knowledge: true, stores: true, auditLogs: true, architect: true, mcps: true, agentsWrite: true },
206
+ });
207
+
208
+ // mount appRuntime at /api/persona, adminRuntime at /api/admin/persona,
209
+ // each behind whatever auth middleware your framework adapter wires up
210
+ ```
211
+
212
+ This is coarse-grained by design: a capability is either fully on or fully off for whoever
213
+ `resolveUser` accepts on that mount — there's no per-user or per-action permission model inside
214
+ the runtime itself (e.g. "this user can update Agents but not delete them" isn't expressible).
215
+ If you need that, enforce it in `resolveUser` (reject the request before it reaches the route) or
216
+ in a hook, not by asking this runtime for finer granularity than "on this mount, for this
217
+ resolved identity, is the capability on."
218
+
219
+ ## Lifecycle hooks
220
+
221
+ Plain async event listeners, not middleware — the runtime proceeds with sensible defaults when a
222
+ hook is omitted, and a hook that wants to reject a run just throws (the throw is caught and
223
+ routed through the same sanitized error response as any other failure). **All eight are wired.**
224
+
225
+ ```ts
226
+ createRuntime({
227
+ // ...
228
+ hooks: {
229
+ beforeRun(ctx) {
230
+ // ctx: { userId, kind: 'chat' | 'architect', agentId?, threadId?, messages }
231
+ // Fires before POST /chat's or POST /architect's stream starts.
232
+ // agentId is only set for kind: 'chat' — the Architect has none of its own.
233
+ },
234
+ afterRun(ctx, result) {
235
+ // result: { text, eventCount, interrupted, erroredInBand }
236
+ // erroredInBand is true when the stream's last event was RUN_ERROR —
237
+ // that's a normal completed-run outcome, not a thrown exception, so
238
+ // afterRun still fires (onError does not).
239
+ },
240
+ onError(ctx, error) {
241
+ // ctx.phase: 'auth' | 'chat' | 'architect'
242
+ // Fires on a thrown exception only: the initial request failing
243
+ // (auth/validation/network) or the stream dying mid-read. Not on an
244
+ // in-band RUN_ERROR event — see afterRun above.
245
+ },
246
+ beforeToolCall(ctx) {
247
+ // ctx: { userId, agentId, threadId?, toolName, toolCallId }
248
+ // Fires on each TOOL_CALL_START event inside a chat stream.
249
+ },
250
+ afterToolCall(ctx, result) {
251
+ // Fires on the matching TOOL_CALL_RESULT event; `result` is the raw
252
+ // (string or already-JSON) tool output.
253
+ },
254
+ onFileUpload(ctx) {
255
+ // ctx: { userId, fileName, mimeType? } — fires after POST /files succeeds.
256
+ },
257
+ onThreadCreate(ctx) {
258
+ // ctx: { userId, agentId, threadId } — fires on an explicit POST
259
+ // /threads, AND when POST /chat's RUN_STARTED event reports a
260
+ // threadId that wasn't supplied on the way in (Persona created one
261
+ // implicitly for that turn).
262
+ },
263
+ onMemoryWrite(ctx) {
264
+ // ctx: { userId, agentId?, path } — fires after PUT /memory/file succeeds.
265
+ },
266
+ },
267
+ });
268
+ ```
269
+
270
+ ## Reconnect and resume
271
+
272
+ If a client's connection to `/chat` drops mid-stream, it can pick up exactly where it left off:
273
+
274
+ ```ts
275
+ const res = await fetch('/chat', { method: 'POST', body: JSON.stringify({ agentId, messages }) });
276
+ const runId = res.headers.get('x-persona-run-id')!;
277
+ // ... connection drops after receiving N frames ...
278
+ const resumed = await fetch(`/chat/${runId}/resume?since=${lastSeqSeen}`);
279
+ // streams every frame after `lastSeqSeen`, then continues live until the run finishes
280
+ ```
281
+
282
+ This works because a chat run is never tied to the HTTP response that started it. `POST /chat`
283
+ constructs an internal `RunDriver` that starts pumping `chat.stream()` the moment the run begins
284
+ and keeps running independently of whether anyone is still listening — buffering every formatted
285
+ SSE frame with a sequence number and broadcasting to live subscribers. `GET /chat/:runId/resume`
286
+ just attaches a new subscriber to that same driver: it replays whatever's already buffered after
287
+ `since`, then streams new frames live until the run finishes. Lifecycle hooks (`afterRun`,
288
+ `onError`, etc.) fire exactly once per run regardless of how many times a client reconnects —
289
+ they belong to the driver, not to any one HTTP response.
290
+
291
+ Finished runs stay resumable for 5 minutes by default before an internal eviction sweep (running
292
+ every 60s) removes them; the registry also caps out at 1000 tracked runs by default, evicting the
293
+ oldest-finished ones first if a host's traffic pattern leaves many runs unclaimed. Both are
294
+ configurable:
295
+
296
+ ```ts
297
+ createRuntime({
298
+ // ...
299
+ runGraceMs: 5 * 60 * 1000, // default
300
+ maxTrackedRuns: 1000, // default
301
+ });
302
+ ```
303
+
304
+ A resume request for an evicted, unknown, or someone-else's run returns `404 RUN_NOT_FOUND`
305
+ (never `403` — a `404` doesn't confirm whether the id ever existed).
306
+
307
+ **Honest limitation: this is single-process and in-memory only.** A `RunDriver` holds a live
308
+ upstream connection and a JS closure over its subscribers — it cannot be represented in Redis or
309
+ shared across separate runtime instances. This closes the reconnect gap for the common
310
+ single-instance deployment (the client dropped and came back, same server process still running).
311
+ If you run more than one instance behind a load balancer — multiple Kubernetes pods, a PM2
312
+ cluster, etc. — a reconnect that lands on a *different* instance than the one running the
313
+ original pump won't find the run (`404 RUN_NOT_FOUND`) even though it's still live elsewhere.
314
+
315
+ **Today's mitigation (deployment-level, no code change):** configure your load balancer for
316
+ session affinity / sticky sessions (route a given client to the same instance) so reconnects
317
+ land back where the run actually lives. This doesn't survive that instance crashing or being
318
+ redeployed, and doesn't apply to serverless (no persistent instance to stick to), but covers the
319
+ common case for free.
320
+
321
+ **Planned (not yet built):** a pluggable `RunBroker` interface — publish/subscribe/claim-ownership
322
+ for run frames — so a host can back it with Redis (or anything else) and get resume working
323
+ across instances, including a resume request landing on an instance that never touched the
324
+ original pump. The in-memory behavior above would remain the zero-config default; a Redis (or
325
+ similar) implementation would be opt-in, not a dependency this package forces on everyone.
326
+ Deliberately deferred rather than built speculatively — track
327
+ [issue #229](https://github.com/hasanraiyan/agent-marketplace/issues/229) or open a new one if you
328
+ need this now.
329
+
330
+ `createRuntime()` returns a `close()` method that stops the eviction timer; the timer is also
331
+ `unref`'d so it won't itself keep a Node process alive, but call `close()` if you construct
332
+ runtimes repeatedly in a long-lived process (e.g. per-test-suite setup) to avoid accumulating
333
+ timers.
334
+
335
+ ## Heartbeats and backpressure
336
+
337
+ `POST /chat` and `GET /chat/:runId/resume` both send an SSE comment-line heartbeat
338
+ (`: heartbeat\n\n`) during any gap between real AG-UI events — e.g. a long-running tool call with
339
+ no token output — so intermediary proxies and load balancers with an idle-connection timeout
340
+ don't kill the stream. Comment lines are invisible to any `data:`-only SSE parser (including
341
+ `@personaai/sdk`'s own `parseAguiEventStream`), so a consumer never sees them as part of the
342
+ event sequence.
343
+
344
+ ```ts
345
+ createRuntime({
346
+ // ...
347
+ heartbeatIntervalMs: 15000, // default; lower it for faster proxy timeouts, or raise it to reduce chatter
348
+ });
349
+ ```
350
+
351
+ Heartbeats only cover gaps *after* the first event of a run — headers can't be sent until the
352
+ runtime has already peeked that first event to decide whether the run started successfully
353
+ (a 401/400/500 has to be a normal buffered response, not a stream), so there's no way to keep a
354
+ connection alive with heartbeats before that point. In practice this matters little: the gap
355
+ heartbeats exist for is a stalled *middle* of a run (a slow tool call), not the initial
356
+ time-to-first-token.
357
+
358
+ **Backpressure has a real, deliberate tradeoff as of reconnect support.** Before reconnect
359
+ existed, a slow or disconnected consumer propagated backpressure all the way back to Persona's
360
+ server — the runtime never pulled a frame it hadn't been asked for. That's no longer true: a
361
+ `RunDriver`'s pump starts draining `chat.stream()` the moment the run begins and keeps going
362
+ regardless of subscriber speed, because resumability requires buffering whatever a reconnecting
363
+ client might ask to replay. You cannot have both "backpressure all the way to the source" and "a
364
+ disconnected client can come back and get what it missed" — they're in direct tension, and this
365
+ runtime chose resumability. What's still true and tested (`test/runDriver.test.ts`): the pump
366
+ drains the upstream generator exactly once, strictly in order, with no duplicate or skipped
367
+ `next()` calls, no matter how many subscribers attach or how slowly they read. Per-run buffers
368
+ are bounded by that one run's event count (not indefinite) and released after the grace period
369
+ described above. The Node bridge in `examples/` still layers transport-level backpressure via
370
+ `res.write()`'s return value and the `drain` event — that protects against one slow subscriber
371
+ blocking the Node process's memory, but it no longer protects against the *runtime itself*
372
+ buffering an in-progress run that nobody is currently reading.
373
+
374
+ ## Errors
375
+
376
+ Every error response is `{"error": {"code": "...", "message": "...", "detail"?: ...}}`. Two
377
+ modes (`mode: 'development' | 'production'`, default `'production'` unless
378
+ `NODE_ENV === 'development'`):
379
+
380
+ - Errors already curated into a developer-facing message — `RuntimeHttpError` (routing/validation
381
+ errors this runtime raises itself) and `PersonaApiError`/`PersonaAuthError`/`PersonaValidationError`
382
+ from `@personaai/sdk` — pass through as-is; `detail` (the upstream response envelope) is only
383
+ attached in development mode.
384
+ - Anything else (a bug in your own hook code, a raw network error, ...) is treated as untrusted:
385
+ always `500`/`INTERNAL_ERROR`, with a fixed generic message in production and the real
386
+ message/stack under `detail` in development. This is what actually prevents internal
387
+ implementation details (LangGraph, Qdrant, ...) from ever reaching a caller of this runtime.
388
+
389
+ ## Not yet implemented
390
+
391
+ This is v0.5. Every SDK resource now has a route (see [Routes](#routes)); what's left is either a
392
+ genuine unclosed gap or an intentional package boundary, not an oversight:
393
+
394
+ - **Multi-instance reconnect/resume** — see
395
+ [Reconnect and resume](#reconnect-and-resume) above. Single-process resume is implemented and
396
+ tested; sharing a live run across separate runtime instances would need a message-broker
397
+ architecture this package doesn't provide. This is the one real gap.
398
+ - **Fine-grained (per-user, per-action) permissions within an enabled capability** — **by
399
+ design**, not a gap: see [Capabilities](#capabilities--admin-surface). A capability is on or off
400
+ per mount; anything finer belongs in `resolveUser` or a hook, not the runtime.
401
+ - Any published framework adapter (`@personaai/express`, `@personaai/nextjs`, `@personaai/node`,
402
+ `@personaai/fastify`, `@personaai/hono`, `@personaai/nestjs`) — **by design**, not a gap: this
403
+ package is the foundation they're meant to wrap, not a replacement for them.
404
+
405
+ ## Roadmap
406
+
407
+ - A pluggable `RunBroker` interface for multi-instance reconnect/resume (Redis or similar,
408
+ opt-in) — see [Reconnect and resume](#reconnect-and-resume).
409
+ - Framework adapters (Wave 2–3 of the ecosystem plan) are the natural next step once this runtime
410
+ is battle-tested — each should be a thin translation layer, proving the framework-neutral
411
+ contract here is actually sufficient.