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