@personaai/runtime 0.21.0 → 0.23.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 CHANGED
@@ -1,453 +1,613 @@
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/adapters/express`, `@personaai/adapters/nextjs/server`, ...) 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.16.0.** New opt-in outbound messaging: `POST /messages` (plain SMS/WhatsApp send) and
9
- `POST /messages/agent` (the Agent composes the reply), both behind `capabilities.messages` — off by
10
- default because a send targets any E.164 number the caller names and spends this Project's Twilio
11
- balance. Also includes `/memory` and `/memory/file`'s `scope: "workspace"` for an Agent's own
12
- `/workspace/` files, full Workflows engine support
13
- (`POST /workflows/:id/stream`, `GET /workflows`, `POST /workflows/runs/:runId/cancel`,
14
- `GET /workflows/runs/:runId/resume`), and `beforeWorkflowNode`/`afterWorkflowNode` lifecycle hooks. Unified adapter [`@personaai/adapters`](https://persona.hasanraiyan.me/guides/express/quickstart) provides `express`/`nextjs`/`nestjs` bindings. For non-covered hosts, see
15
- [Quickstart](#quickstart) for how to run it directly against raw Node `http`, and
16
- [Not yet implemented](#not-yet-implemented) for what's missing before it's a complete Level 2
17
- runtime.
18
-
19
- **Server-side only.** The credential this runtime holds is a server-side secret — never bundle
20
- this into a browser app.
21
-
22
- ## Install
23
-
24
- ```
25
- npm install @personaai/runtime
26
- ```
27
-
28
- ## The user resolver contract
29
-
30
- Persona never authenticates users. The runtime receives a request and asks *you* who it's from:
31
-
32
- ```ts
33
- import { createRuntime } from '@personaai/runtime';
34
-
35
- const runtime = createRuntime({
36
- baseUrl: process.env.PERSONA_BASE_URL!,
37
- credential: process.env.PERSONA_CREDENTIAL!,
38
- resolveUser: async (request) => {
39
- // Your auth, your rules — Clerk, a JWT, a session cookie, whatever you
40
- // already use. Return the resolved external user id, or null/throw if
41
- // the request isn't authenticated (the runtime responds 401 either way).
42
- return getUserIdFromSession(request.headers['cookie']);
43
- },
44
- });
45
- ```
46
-
47
- `resolveUser` is the single point of contact between your auth world and Persona's runtime
48
- world — see `RunContext`/the design notes below for why this boundary is absolute.
49
-
50
- ## Quickstart (raw Node `http`)
51
-
52
- Using Express, Next.js, or NestJS? Skip this section and use the published [`@personaai/adapters` adapters](https://persona.hasanraiyan.me/guides/express/quickstart) — `import { toExpressRouter } from '@personaai/adapters/express'`, `import { createPersonaHandler } from '@personaai/adapters/nextjs/server'`, or `import { PersonaModule } from '@personaai/adapters/nestjs'`.
53
- For every other host there's no `@personaai/node` adapter package yet, so this release ships a
54
- small, tested bridge at [`examples/node-handler.ts`](./examples/node-handler.ts) in this repo
55
- for running the runtime directly against Node's `http` module, just enough to demo/smoke-test
56
- the runtime end to end until `@personaai/node` ships. It parses multipart file uploads too, via
57
- Node's native `Request`/`FormData` (undici) — no extra dependency.
58
-
59
- **This file is not published to npm and has no `exports` entry** (`package.json`'s `files` only
60
- ships `dist/`, and `exports` only maps `.`) — `import ... from '@personaai/runtime/examples/...'`
61
- fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` after a real `npm install`. Copy the file into your own
62
- project instead (it's plain TypeScript with no runtime-internal dependencies):
63
-
64
- ```ts
65
- import { createServer } from 'node:http';
66
- import { createRuntime } from '@personaai/runtime';
67
- import { toNodeHandler } from './node-handler.js'; // copied from this repo's examples/ — not importable from the published package, see note below
68
-
69
- const runtime = createRuntime({
70
- baseUrl: process.env.PERSONA_BASE_URL!,
71
- credential: process.env.PERSONA_CREDENTIAL!,
72
- resolveUser: (request) => request.headers['x-demo-user-id'] ?? null,
73
- });
74
-
75
- createServer(toNodeHandler(runtime)).listen(3210);
76
- ```
77
-
78
- ## Routes
79
-
80
- All routes are relative to whatever `mountPath` you configure (default: none, i.e. the runtime
81
- expects `request.path` already stripped). Every route except `/health` requires an authenticated
82
- user; `resolveUser` returning `null` or throwing responds `401`.
83
-
84
- ### Always on — end-user-scoped, no capability flag needed
85
-
86
- | Method | Path | Proxies to |
87
- | --- | --- | --- |
88
- | `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). |
89
- | `GET` | `/chat/:runId/resume` | Reattaches to the run started by the matching `POST /chat`. See [Reconnect and resume](#reconnect-and-resume). |
90
- | `GET` | `/threads` | `client.threads.list({page, limit})` — each item's `agentId` is populated as `{_id, name, avatar, slug, sandboxEnabled}` |
91
- | `POST` | `/threads` | `client.threads.create({agentId})` |
92
- | `POST` | `/threads/bulk-delete` | `client.threads.bulkDelete(ids)` |
93
- | `GET` | `/threads/:id` | `client.threads.get(id)` — `agentId` populated the same way as `list()`; check `sandboxEnabled` here to decide whether to show a Terminal-style UI for this Thread's Agent |
94
- | `PATCH` | `/threads/:id` | `client.threads.update(id, {title?, isArchived?})` |
95
- | `DELETE` | `/threads/:id` | `client.threads.delete(id)` → `204` |
96
- | `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. |
97
- | `POST` | `/threads/:id/reset` | `client.threads.reset(id)` — clears message history/files/todos/subagent traces in place, keeping the same Thread id/title. |
98
- | `POST` | `/voice/sessions` | `client.voice.createSession(agentId)` → `{ticket, wsUrl, expiresAt, session}`. `agentId` required in the body. This route only mints the ticket — your frontend takes `wsUrl` (ticket already embedded) and opens `new WebSocket(wsUrl)` **directly to Persona**, not back through this runtime. Real-time audio, powered by Gemini Live. `201` |
99
- | `GET` | `/agents` | `client.agents.list({page, limit, search, category, scope})` — read-only discovery, e.g. "let the user pick an agent." |
100
- | `GET` | `/files` | `client.files.list({page, limit})` |
101
- | `POST` | `/files` | `client.files.upload({filename, content, contentType?, agentId?, threadId?})` — multipart, `file` part required. `201` |
102
- | `POST` | `/files/bulk-delete` | `client.files.bulkDelete(ids)` |
103
- | `GET` | `/files/:id` | `client.files.download(id)` — raw bytes, streamed through as `kind: 'binary'` |
104
- | `DELETE` | `/files/:id` | `client.files.delete(id)` → `204` |
105
- | `GET` | `/memory` | `client.memory.list()` → `{userFiles, agentMemories, agentWorkspaces}` |
106
- | `GET` | `/memory/file` | `client.memory.getFile({path, scope?, agentId?})` — `path` query param required. `scope` is `"user"` (default), `"agent"`, or `"workspace"` (an Agent's own `/workspace/` files); `"agent"`/`"workspace"` require `agentId`. |
107
- | `PUT` | `/memory/file` | `client.memory.writeFile({path, content, scope?, agentId?})` — creates or overwrites |
108
- | `DELETE` | `/memory/file` | `client.memory.deleteFile({path, scope?, agentId?})` → `204` |
109
- | `GET` | `/mcps/:id/oauth/owner/authorize` | `client.mcps.oauth.getOwnerAuthorizeUrl(id)` → `{url}` to redirect the Project owner to |
110
- | `GET` | `/mcps/:id/oauth/user/authorize` | `client.mcps.oauth.getUserAuthorizeUrl(id, returnTo?)` → `{url}` to redirect the end user to |
111
- | `GET` | `/mcps/:id/oauth/user/status` | `client.mcps.oauth.getUserConnectionStatus(id)` |
112
- | `DELETE` | `/mcps/:id/oauth/user/connection` | `client.mcps.oauth.disconnectUserConnection(id)` → `204` |
113
- | `DELETE` | `/mcps/:id/oauth/owner/connection` | `client.mcps.oauth.disconnectOwnerConnection(id)` → `204` |
114
- | `GET` | `/workflows` | `client.workflows.list({page, limit, search, scope, visibility, isEnabled})` — read-only discovery of available workflows. |
115
- | `POST` | `/workflows/:id/stream` | `client.workflows.stream(id, {input, dryRun, version})` — multi-step workflow execution with node-level AG-UI event streaming. Response carries `x-persona-run-id`. |
116
- | `GET` | `/workflows/runs/:runId/resume` | Reattaches to in-flight or recent workflow execution with frame replay (`?since=<seq>`). |
117
- | `POST` | `/workflows/runs/:runId/cancel` | `client.workflows.cancel(runId)` → aborts active execution. |
118
- | `GET` | `/health` | `client.whoami()` → `{status, version, capabilities}`. Does **not** require `resolveUser` — it's a liveness/capability probe, not a user-scoped call. |
119
-
120
- `scope` for memory routes is `'user'` (default) or `'agent'` (`agentId` then required).
121
-
122
- For `POST /files`, a framework adapter must parse the incoming multipart body and populate
123
- `RuntimeRequest.file` (`{filename, content: Uint8Array, contentType?}`) plus put any other form
124
- fields (`agentId`, `threadId`) on `RuntimeRequest.body` — the runtime itself never touches raw
125
- bytes or a specific multipart parser. See `examples/node-handler.ts`'s `readMultipartBody` for
126
- the reference approach (it also handles `POST /knowledge/:id/documents`'s multi-file `files`
127
- field, below).
128
-
129
- ### Opt-in — Project-level admin surface, `capabilities.*` gated
130
-
131
- See [Capabilities](#capabilities--admin-surface) for what these are, why they default off, and
132
- how to enable them safely.
133
-
134
- | Method | Path | Capability | Proxies to |
135
- | --- | --- | --- | --- |
136
- | `POST` | `/agents` | `agentsWrite` | `client.agents.create(input)` |
137
- | `GET`/`PATCH`/`DELETE` | `/agents/:id` | `agentsWrite` | `client.agents.get/update/delete(id)` |
138
- | `POST` | `/agents/bulk-delete` | `agentsWrite` | `client.agents.bulkDelete(ids)` |
139
- | `GET`/`POST` | `/mcps` | `mcps` | `client.mcps.list/create` |
140
- | `GET`/`PATCH`/`DELETE` | `/mcps/:id` | `mcps` | `client.mcps.get/update/delete(id)` |
141
- | `POST` | `/mcps/bulk-delete` | `mcps` | `client.mcps.bulkDelete(ids)` |
142
- | `GET` | `/mcps/:id/usage` | `mcps` | `client.mcps.getUsage(id)` |
143
- | `POST` | `/mcps/:id/test` | `mcps` | `client.mcps.testConnection(id)` |
144
- | `GET` | `/mcps/:id/resource?uri=` | `mcps` | `client.mcps.readResource(id, uri)` |
145
- | `POST` | `/mcps/:id/call-tool` | `mcps` | `client.mcps.callTool(id, name, arguments)` |
146
- | `GET`/`POST` | `/providers` | `providers` | `client.providers.list/create` — **holds API keys** |
147
- | `GET`/`PATCH`/`DELETE` | `/providers/:id` | `providers` | `client.providers.get/update/delete(id)` |
148
- | `POST` | `/providers/bulk-delete` | `providers` | `client.providers.bulkDelete(ids)` |
149
- | `POST` | `/providers/:id/test` | `providers` | `client.providers.testConnection(id)` |
150
- | `GET` | `/providers/:id/models` | `providers` | `client.providers.getModels(id)` |
151
- | `GET` | `/providers/:id/usage` | `providers` | `client.providers.getUsage(id)` |
152
- | `GET`/`POST` | `/skills` | `skills` | `client.skills.list/create` |
153
- | `GET`/`PATCH`/`DELETE` | `/skills/:id` | `skills` | `client.skills.get/update/delete(id)` |
154
- | `POST` | `/skills/bulk-delete` | `skills` | `client.skills.bulkDelete(ids)` |
155
- | `GET` | `/skills/:id/usage` | `skills` | `client.skills.getUsage(id)` |
156
- | `GET`/`POST` | `/knowledge` | `knowledge` | `client.knowledge.list/create` |
157
- | `GET`/`PATCH`/`DELETE` | `/knowledge/:id` | `knowledge` | `client.knowledge.get/update/delete(id)` |
158
- | `POST` | `/knowledge/bulk-delete` | `knowledge` | `client.knowledge.bulkDelete(ids)` |
159
- | `GET` | `/knowledge/:id/usage` | `knowledge` | `client.knowledge.getUsage(id)` |
160
- | `POST` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.uploadDocuments(id, files)` — multipart, one or more `files` parts required. `201` |
161
- | `GET` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.listDocuments(id)` |
162
- | `DELETE` | `/knowledge/:id/documents/:sourceName` | `knowledge` | `client.knowledge.deleteDocument(id, sourceName)` |
163
- | `POST` | `/knowledge/:id/search` | `knowledge` | `client.knowledge.search(id, query, {topK?})` |
164
- | `GET`/`POST` | `/stores` | `stores` | `client.stores.list/create` |
165
- | `GET`/`PATCH`/`DELETE` | `/stores/:id` | `stores` | `client.stores.get/update/delete(id)` |
166
- | `GET` | `/stores/:id/files` | `stores` | `client.stores.listFiles(id)` |
167
- | `GET`/`PUT`/`DELETE` | `/stores/:id/file` | `stores` | `client.stores.getFile/writeFile/deleteFile(id, {path, content?})` |
168
- | `GET` | `/audit-logs` | `auditLogs` | `client.auditLogs.list({page, limit, eventType})` |
169
- | `POST` | `/architect` | `architect` | `client.architect.stream({messages, threadId, 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. `threadId` resumes a named Architect conversation (has no effect without an asserted external user). |
170
- | `GET` | `/architect/:runId/resume` | `architect` | Reattaches to the matching `POST /architect` run. |
171
- | `POST` | `/workflows` | `workflowsWrite` | `client.workflows.create(input)` |
172
- | `GET`/`PATCH`/`DELETE` | `/workflows/:id` | `workflowsWrite` | `client.workflows.get/update/delete(id)` |
173
- | `PUT` | `/workflows/:id/draft` | `workflowsWrite` | `client.workflows.saveDraft(id, draft)` |
174
- | `POST` | `/workflows/:id/publish` | `workflowsWrite` | `client.workflows.publish(id)` |
175
- | `GET` | `/workflows/:id/versions` | `workflowsWrite` | `client.workflows.listVersions(id)` |
176
- | `GET` | `/workflows/:id/versions/:version` | `workflowsWrite` | `client.workflows.getVersion(id, version)` |
177
- | `GET` | `/workflows/:id/mermaid` | `workflowsWrite` | `client.workflows.getMermaid(id)` → `{mermaid}` |
178
- | `GET` | `/workflows/:id/runs` | `workflowsWrite` | `client.workflows.listRuns(id, params)` |
179
- | `GET` | `/workflows/runs/:runId` | `workflowsWrite` | `client.workflows.getRun(runId)` |
180
- | `POST` | `/messages` | `messages` | `client.messages.send({to, body?, channel?, mediaUrl?, contentSid?, contentVariables?})` → `{channel, to, from, messageSids, status, parts}`. `to` is any E.164 number; the channel is inferred from a `whatsapp:` prefix unless given. A body is required unless `contentSid` supplies an approved WhatsApp template. |
181
- | `POST` | `/messages/agent` | `messages` | `client.messages.sendFromAgent({agentId, to, prompt, channel?, displayName?})` — runs the Agent on the conversation shared by all channels for that number, then delivers its reply. `422` for guarded (`interruptOn`) Agents, same as voice. |
182
-
183
- A disabled capability's routes are simply absent from the route table — a request to one 404s
184
- (or, where an always-on route shares the same path with a different method, e.g. `POST /agents`
185
- while only `GET /agents` is always-on, or `POST /workflows` while `GET /workflows` is always-on, `405`) rather than 403, so a disabled capability leaks no
186
- information about what it would have done.
187
-
188
- ## Capabilities — admin surface
189
-
190
- The routes above are split into two trust tiers, and this is a deliberate design choice, not an
191
- oversight:
192
-
193
- - **Always on**: things an end user does in their own chat session — send messages, manage their
194
- own conversations/files/memory, connect their own MCP account, run workflows. Scoped entirely to whichever
195
- user `resolveUser` returns.
196
- - **Opt-in via `capabilities`**: Project-level configuration — LLM provider credentials, skill
197
- authoring, knowledge base and vector store management, security audit logs, an agent-building
198
- co-pilot, workflow canvas builder/publishing, full Agent/MCP-server CRUD, and outbound
199
- SMS/WhatsApp. **Every one of these defaults to `false`.** Upgrading
200
- this package never silently exposes new surface to whoever `resolveUser` accepts.
201
-
202
- `messages` is the one opt-in flag that isn't configuration: it's a per-request action, like
203
- `voice/sessions`. It's still off by default because a send reaches any number the caller names
204
- and spends this Project's Twilio balance — whoever `resolveUser` accepts would be able to text
205
- arbitrary people, which is a materially larger blast radius than an end-user-scoped chat run.
206
-
207
- ```ts
208
- createRuntime({
209
- // ...
210
- capabilities: {
211
- agentsWrite: false, // default
212
- mcps: false, // default
213
- providers: false, // default — holds API keys, think hard before enabling
214
- skills: false, // default
215
- knowledge: false, // default
216
- stores: false, // default
217
- auditLogs: false, // default
218
- architect: false, // default
219
- workflowsWrite: false, // default — authoring and version snapshots
220
- messages: false, // default — sends spend the Project's Twilio balance
221
- },
222
- });
223
- ```
224
-
225
- **Most hosts should never turn any of these on**, and should instead call `@personaai/sdk`
226
- directly from their own admin backend/CLI/setup script for Project configuration — that's what
227
- "belongs to the host application" means in practice.
228
-
229
- If you *do* want an admin surface reachable over HTTP (e.g. building your own internal admin
230
- tool on top of this runtime), the right pattern is **two separate `createRuntime()` calls
231
- mounted at two different paths**, each with its own `resolveUser`:
232
-
233
- ```ts
234
- const appRuntime = createRuntime({
235
- baseUrl, credential,
236
- resolveUser: resolveEndUser, // your normal app auth — any logged-in user
237
- });
238
-
239
- const adminRuntime = createRuntime({
240
- baseUrl, credential,
241
- resolveUser: resolveAdminUser, // a stricter check — only your team
242
- capabilities: { providers: true, skills: true, knowledge: true, stores: true, auditLogs: true, architect: true, mcps: true, agentsWrite: true },
243
- });
244
-
245
- // mount appRuntime at /api/persona, adminRuntime at /api/admin/persona,
246
- // each behind whatever auth middleware your framework adapter wires up
247
- ```
248
-
249
- This is coarse-grained by design: a capability is either fully on or fully off for whoever
250
- `resolveUser` accepts on that mount — there's no per-user or per-action permission model inside
251
- the runtime itself (e.g. "this user can update Agents but not delete them" isn't expressible).
252
- If you need that, enforce it in `resolveUser` (reject the request before it reaches the route) or
253
- in a hook, not by asking this runtime for finer granularity than "on this mount, for this
254
- resolved identity, is the capability on."
255
-
256
- ## Lifecycle hooks
257
-
258
- Plain async event listeners, not middleware — the runtime proceeds with sensible defaults when a
259
- hook is omitted, and a hook that wants to reject a run just throws (the throw is caught and
260
- routed through the same sanitized error response as any other failure). **All nine are wired.**
261
-
262
- ```ts
263
- createRuntime({
264
- // ...
265
- hooks: {
266
- beforeRun(ctx) {
267
- // ctx: { userId, kind: 'chat' | 'architect', agentId?, threadId?, messages }
268
- // Fires before POST /chat's or POST /architect's stream starts.
269
- // agentId is only set for kind: 'chat' — the Architect has none of its own.
270
- },
271
- afterRun(ctx, result) {
272
- // result: { text, eventCount, interrupted, erroredInBand }
273
- // erroredInBand is true when the stream's last event was RUN_ERROR —
274
- // that's a normal completed-run outcome, not a thrown exception, so
275
- // afterRun still fires (onError does not).
276
- },
277
- onError(ctx, error) {
278
- // ctx.phase: 'auth' | 'chat' | 'architect'
279
- // Fires on a thrown exception only: the initial request failing
280
- // (auth/validation/network) or the stream dying mid-read. Not on an
281
- // in-band RUN_ERROR event — see afterRun above.
282
- },
283
- beforeToolCall(ctx) {
284
- // ctx: { userId, agentId, threadId?, toolName, toolCallId }
285
- // Fires on each TOOL_CALL_START event inside a chat stream.
286
- },
287
- afterToolCall(ctx, result) {
288
- // Fires on the matching TOOL_CALL_RESULT event; `result` is the raw
289
- // (string or already-JSON) tool output.
290
- },
291
- onFileUpload(ctx) {
292
- // ctx: { userId, fileName, mimeType? } — fires after POST /files succeeds.
293
- },
294
- onThreadCreate(ctx) {
295
- // ctx: { userId, agentId, threadId } — fires on an explicit POST
296
- // /threads, AND when POST /chat's RUN_STARTED event reports a
297
- // threadId that wasn't supplied on the way in (Persona created one
298
- // implicitly for that turn).
299
- },
300
- onMemoryWrite(ctx) {
301
- // ctx: { userId, agentId?, path } — fires after PUT /memory/file succeeds.
302
- },
303
- onVoiceSessionCreate(ctx) {
304
- // ctx: { userId, agentId } — fires after POST /voice/sessions mints a
305
- // ticket. Note this only covers the mint; the actual call happens on
306
- // a direct browser-to-Persona WebSocket this runtime never sees.
307
- },
308
- },
309
- });
310
- ```
311
-
312
- ## Reconnect and resume
313
-
314
- If a client's connection to `/chat` drops mid-stream, it can pick up exactly where it left off:
315
-
316
- ```ts
317
- const res = await fetch('/chat', { method: 'POST', body: JSON.stringify({ agentId, messages }) });
318
- const runId = res.headers.get('x-persona-run-id')!;
319
- // ... connection drops after receiving N frames ...
320
- const resumed = await fetch(`/chat/${runId}/resume?since=${lastSeqSeen}`);
321
- // streams every frame after `lastSeqSeen`, then continues live until the run finishes
322
- ```
323
-
324
- This works because a chat run is never tied to the HTTP response that started it. `POST /chat`
325
- constructs an internal `RunDriver` that starts pumping `chat.stream()` the moment the run begins
326
- and keeps running independently of whether anyone is still listening — buffering every formatted
327
- SSE frame with a sequence number and broadcasting to live subscribers. `GET /chat/:runId/resume`
328
- just attaches a new subscriber to that same driver: it replays whatever's already buffered after
329
- `since`, then streams new frames live until the run finishes. Lifecycle hooks (`afterRun`,
330
- `onError`, etc.) fire exactly once per run regardless of how many times a client reconnects —
331
- they belong to the driver, not to any one HTTP response.
332
-
333
- Finished runs stay resumable for 5 minutes by default before an internal eviction sweep (running
334
- every 60s) removes them; the registry also caps out at 1000 tracked runs by default, evicting the
335
- oldest-finished ones first if a host's traffic pattern leaves many runs unclaimed. Both are
336
- configurable:
337
-
338
- ```ts
339
- createRuntime({
340
- // ...
341
- runGraceMs: 5 * 60 * 1000, // default
342
- maxTrackedRuns: 1000, // default
343
- });
344
- ```
345
-
346
- A resume request for an evicted, unknown, or someone-else's run returns `404 RUN_NOT_FOUND`
347
- (never `403` — a `404` doesn't confirm whether the id ever existed).
348
-
349
- **Honest limitation: this is single-process and in-memory only.** A `RunDriver` holds a live
350
- upstream connection and a JS closure over its subscribers — it cannot be represented in Redis or
351
- shared across separate runtime instances. This closes the reconnect gap for the common
352
- single-instance deployment (the client dropped and came back, same server process still running).
353
- If you run more than one instance behind a load balancer — multiple Kubernetes pods, a PM2
354
- cluster, etc. — a reconnect that lands on a *different* instance than the one running the
355
- original pump won't find the run (`404 RUN_NOT_FOUND`) even though it's still live elsewhere.
356
-
357
- **Today's mitigation (deployment-level, no code change):** configure your load balancer for
358
- session affinity / sticky sessions (route a given client to the same instance) so reconnects
359
- land back where the run actually lives. This doesn't survive that instance crashing or being
360
- redeployed, and doesn't apply to serverless (no persistent instance to stick to), but covers the
361
- common case for free.
362
-
363
- **Planned (not yet built):** a pluggable `RunBroker` interface — publish/subscribe/claim-ownership
364
- for run frames — so a host can back it with Redis (or anything else) and get resume working
365
- across instances, including a resume request landing on an instance that never touched the
366
- original pump. The in-memory behavior above would remain the zero-config default; a Redis (or
367
- similar) implementation would be opt-in, not a dependency this package forces on everyone.
368
- Deliberately deferred rather than built speculatively — track
369
- [issue #229](https://github.com/hasanraiyan/agent-marketplace/issues/229) or open a new one if you
370
- need this now.
371
-
372
- `createRuntime()` returns a `close()` method that stops the eviction timer; the timer is also
373
- `unref`'d so it won't itself keep a Node process alive, but call `close()` if you construct
374
- runtimes repeatedly in a long-lived process (e.g. per-test-suite setup) to avoid accumulating
375
- timers.
376
-
377
- ## Heartbeats and backpressure
378
-
379
- `POST /chat` and `GET /chat/:runId/resume` both send an SSE comment-line heartbeat
380
- (`: heartbeat\n\n`) during any gap between real AG-UI events — e.g. a long-running tool call with
381
- no token output — so intermediary proxies and load balancers with an idle-connection timeout
382
- don't kill the stream. Comment lines are invisible to any `data:`-only SSE parser (including
383
- `@personaai/sdk`'s own `parseAguiEventStream`), so a consumer never sees them as part of the
384
- event sequence.
385
-
386
- ```ts
387
- createRuntime({
388
- // ...
389
- heartbeatIntervalMs: 15000, // default; lower it for faster proxy timeouts, or raise it to reduce chatter
390
- });
391
- ```
392
-
393
- Heartbeats only cover gaps *after* the first event of a run — headers can't be sent until the
394
- runtime has already peeked that first event to decide whether the run started successfully
395
- (a 401/400/500 has to be a normal buffered response, not a stream), so there's no way to keep a
396
- connection alive with heartbeats before that point. In practice this matters little: the gap
397
- heartbeats exist for is a stalled *middle* of a run (a slow tool call), not the initial
398
- time-to-first-token.
399
-
400
- **Backpressure has a real, deliberate tradeoff as of reconnect support.** Before reconnect
401
- existed, a slow or disconnected consumer propagated backpressure all the way back to Persona's
402
- server — the runtime never pulled a frame it hadn't been asked for. That's no longer true: a
403
- `RunDriver`'s pump starts draining `chat.stream()` the moment the run begins and keeps going
404
- regardless of subscriber speed, because resumability requires buffering whatever a reconnecting
405
- client might ask to replay. You cannot have both "backpressure all the way to the source" and "a
406
- disconnected client can come back and get what it missed" — they're in direct tension, and this
407
- runtime chose resumability. What's still true and tested (`test/runDriver.test.ts`): the pump
408
- drains the upstream generator exactly once, strictly in order, with no duplicate or skipped
409
- `next()` calls, no matter how many subscribers attach or how slowly they read. Per-run buffers
410
- are bounded by that one run's event count (not indefinite) and released after the grace period
411
- described above. The Node bridge in `examples/` still layers transport-level backpressure via
412
- `res.write()`'s return value and the `drain` event — that protects against one slow subscriber
413
- blocking the Node process's memory, but it no longer protects against the *runtime itself*
414
- buffering an in-progress run that nobody is currently reading.
415
-
416
- ## Errors
417
-
418
- Every error response is `{"error": {"code": "...", "message": "...", "detail"?: ...}}`. Two
419
- modes (`mode: 'development' | 'production'`, default `'production'` unless
420
- `NODE_ENV === 'development'`):
421
-
422
- - Errors already curated into a developer-facing message — `RuntimeHttpError` (routing/validation
423
- errors this runtime raises itself) and `PersonaApiError`/`PersonaAuthError`/`PersonaValidationError`
424
- from `@personaai/sdk` — pass through as-is; `detail` (the upstream response envelope) is only
425
- attached in development mode.
426
- - Anything else (a bug in your own hook code, a raw network error, ...) is treated as untrusted:
427
- always `500`/`INTERNAL_ERROR`, with a fixed generic message in production and the real
428
- message/stack under `detail` in development. This is what actually prevents internal
429
- implementation details (LangGraph, Qdrant, ...) from ever reaching a caller of this runtime.
430
-
431
- ## Not yet implemented
432
-
433
- This is v0.16.0. Every SDK resource now has a route (see [Routes](#routes)); what's left is either a
434
- genuine unclosed gap or an intentional package boundary, not an oversight:
435
-
436
- - **Multi-instance reconnect/resume** — see
437
- [Reconnect and resume](#reconnect-and-resume) above. Single-process resume is implemented and
438
- tested; sharing a live run across separate runtime instances would need a message-broker
439
- architecture this package doesn't provide. This is the one real gap.
440
- - **Fine-grained (per-user, per-action) permissions within an enabled capability** — **by
441
- design**, not a gap: see [Capabilities](#capabilities--admin-surface). A capability is on or off
442
- per mount; anything finer belongs in `resolveUser` or a hook, not the runtime.
443
- - Framework adapters beyond the shipped ones (`@personaai/node`, `@personaai/fastify`,
444
- `@personaai/hono`) — **by design**, not a gap: this package is the foundation they're meant to
445
- wrap, not a replacement for them. `@personaai/adapters` (unified `express`/`nextjs`/`nestjs` via subpaths, Wave 3) has shipped — the legacy standalone `@personaai/nextjs`/`@personaai/express`/`@personaai/nestjs` packages were removed from the repo (2026-09-14); their last-published versions are frozen and deprecated on the registry.
446
-
447
- ## Roadmap
448
-
449
- - A pluggable `RunBroker` interface for multi-instance reconnect/resume (Redis or similar,
450
- opt-in) — see [Reconnect and resume](#reconnect-and-resume).
451
- - Framework adapters (Wave 2–3 of the ecosystem plan) are the natural next step once this runtime
452
- is battle-tested — each should be a thin translation layer, proving the framework-neutral
453
- 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/adapters/express`, `@personaai/adapters/nextjs/server`, ...) 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.23.0** (built on `@personaai/sdk` 0.26.0):
9
+
10
+ - **`POST /feedback`**: your end users rate a run 👍/👎 (with a comment and tags). Always on.
11
+ - **Which version runs**: `selectVersion` / `defaultEnvironment` pick the published version for
12
+ `/chat` and `/voice/sessions` (canaries, beta users, production pinning). See
13
+ [Which version runs](#which-version-runs).
14
+ - **Receive Persona webhooks** at `POST /webhooks/persona` (signature checked, duplicates dropped).
15
+ See [Receiving Persona webhooks](#receiving-persona-webhooks).
16
+ - **`traceparent` forwarding** on `/chat`, so runs join your OpenTelemetry trace.
17
+ - **Clearer errors**: a key missing a scope is a `500 RUNTIME_MISCONFIGURED` (your setup, not the
18
+ user's login), and `402 BUDGET_EXCEEDED` says which budget ran out.
19
+ - **New capabilities**: `secrets`, `restToolSources`, and the Project-wide admin surface
20
+ `deploy`, `traces`, `evals`, `observe` (all off by default). See
21
+ [Project-wide admin capabilities](#project-wide-admin-capabilities).
22
+ - `afterRun` gets Persona's own run id (`personaRunId`), and `/health` reports the key's scopes
23
+ and warns about capabilities the key can't serve.
24
+
25
+ Also: chat, threads, files, memory, voice, workflows, MCP OAuth, outbound messages, the Architect,
26
+ REST/RCP tool manifests and Twilio channels. [`@personaai/adapters`](https://persona.hasanraiyan.me/guides/express/quickstart)
27
+ provides the `express` / `nextjs` / `nestjs` bindings; for other hosts see
28
+ [Quickstart](#quickstart-raw-node-http).
29
+
30
+ **Server-side only.** The credential this runtime holds is a server-side secret — never bundle
31
+ this into a browser app.
32
+
33
+ ## Install
34
+
35
+ ```
36
+ npm install @personaai/runtime
37
+ ```
38
+
39
+ ## The user resolver contract
40
+
41
+ Persona never authenticates users. The runtime receives a request and asks *you* who it's from:
42
+
43
+ ```ts
44
+ import { createRuntime } from '@personaai/runtime';
45
+
46
+ const runtime = createRuntime({
47
+ baseUrl: process.env.PERSONA_BASE_URL!,
48
+ credential: process.env.PERSONA_CREDENTIAL!,
49
+ resolveUser: async (request) => {
50
+ // Your auth, your rules — Clerk, a JWT, a session cookie, whatever you
51
+ // already use. Return the resolved external user id, or null/throw if
52
+ // the request isn't authenticated (the runtime responds 401 either way).
53
+ return getUserIdFromSession(request.headers['cookie']);
54
+ },
55
+ });
56
+ ```
57
+
58
+ `resolveUser` is the single point of contact between your auth world and Persona's runtime
59
+ world — see `RunContext`/the design notes below for why this boundary is absolute.
60
+
61
+ ## Quickstart (raw Node `http`)
62
+
63
+ Using Express, Next.js, or NestJS? Skip this section and use the published [`@personaai/adapters` adapters](https://persona.hasanraiyan.me/guides/express/quickstart) — `import { toExpressRouter } from '@personaai/adapters/express'`, `import { createPersonaHandler } from '@personaai/adapters/nextjs/server'`, or `import { PersonaModule } from '@personaai/adapters/nestjs'`.
64
+ For every other host there's no `@personaai/node` adapter package yet, so this release ships a
65
+ small, tested bridge at [`examples/node-handler.ts`](./examples/node-handler.ts) in this repo
66
+ for running the runtime directly against Node's `http` module, just enough to demo/smoke-test
67
+ the runtime end to end until `@personaai/node` ships. It parses multipart file uploads too, via
68
+ Node's native `Request`/`FormData` (undici) — no extra dependency.
69
+
70
+ **This file is not published to npm and has no `exports` entry** (`package.json`'s `files` only
71
+ ships `dist/`, and `exports` only maps `.`) — `import ... from '@personaai/runtime/examples/...'`
72
+ fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` after a real `npm install`. Copy the file into your own
73
+ project instead (it's plain TypeScript with no runtime-internal dependencies):
74
+
75
+ ```ts
76
+ import { createServer } from 'node:http';
77
+ import { createRuntime } from '@personaai/runtime';
78
+ import { toNodeHandler } from './node-handler.js'; // copied from this repo's examples/ — not importable from the published package, see note below
79
+
80
+ const runtime = createRuntime({
81
+ baseUrl: process.env.PERSONA_BASE_URL!,
82
+ credential: process.env.PERSONA_CREDENTIAL!,
83
+ resolveUser: (request) => request.headers['x-demo-user-id'] ?? null,
84
+ });
85
+
86
+ createServer(toNodeHandler(runtime)).listen(3210);
87
+ ```
88
+
89
+ ## Routes
90
+
91
+ All routes are relative to whatever `mountPath` you configure (default: none, i.e. the runtime
92
+ expects `request.path` already stripped). Every route except `/health` requires an authenticated
93
+ user; `resolveUser` returning `null` or throwing responds `401`.
94
+
95
+ ### Always on — end-user-scoped, no capability flag needed
96
+
97
+ | Method | Path | Proxies to |
98
+ | --- | --- | --- |
99
+ | `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). |
100
+ | `GET` | `/chat/:runId/resume` | Reattaches to the run started by the matching `POST /chat`. See [Reconnect and resume](#reconnect-and-resume). |
101
+ | `GET` | `/threads` | `client.threads.list({page, limit})` — each item's `agentId` is populated as `{_id, name, avatar, slug, sandboxEnabled}` |
102
+ | `POST` | `/threads` | `client.threads.create({agentId})` |
103
+ | `POST` | `/threads/bulk-delete` | `client.threads.bulkDelete(ids)` |
104
+ | `GET` | `/threads/:id` | `client.threads.get(id)` — `agentId` populated the same way as `list()`; check `sandboxEnabled` here to decide whether to show a Terminal-style UI for this Thread's Agent |
105
+ | `PATCH` | `/threads/:id` | `client.threads.update(id, {title?, isArchived?})` |
106
+ | `DELETE` | `/threads/:id` | `client.threads.delete(id)` → `204` |
107
+ | `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. |
108
+ | `POST` | `/threads/:id/reset` | `client.threads.reset(id)` — clears message history/files/todos/subagent traces in place, keeping the same Thread id/title. |
109
+ | `POST` | `/voice/sessions` | `client.voice.createSession(agentId)` → `{ticket, wsUrl, expiresAt, session}`. `agentId` required in the body. This route only mints the ticket — your frontend takes `wsUrl` (ticket already embedded) and opens `new WebSocket(wsUrl)` **directly to Persona**, not back through this runtime. Real-time audio, powered by Gemini Live. `201` |
110
+ | `POST` | `/feedback` | `client.feedback.create({runId, score: 1 \| -1, comment?, tags?})` — the end user rates one of their own runs (`runId` is Persona's run id: `RUN_STARTED.runId` in the stream, or `personaRunId` in `afterRun`). Persona refuses ratings on anyone else's runs. Fires `onFeedback`. |
111
+ | `GET` | `/agents` | `client.agents.list({page, limit, search, category, scope})` — read-only discovery, e.g. "let the user pick an agent." |
112
+ | `GET` | `/files` | `client.files.list({page, limit})` |
113
+ | `POST` | `/files` | `client.files.upload({filename, content, contentType?, agentId?, threadId?})` — multipart, `file` part required. `201` |
114
+ | `POST` | `/files/bulk-delete` | `client.files.bulkDelete(ids)` |
115
+ | `GET` | `/files/:id` | `client.files.download(id)` — raw bytes, streamed through as `kind: 'binary'` |
116
+ | `DELETE` | `/files/:id` | `client.files.delete(id)` → `204` |
117
+ | `GET` | `/memory` | `client.memory.list()` → `{userFiles, agentMemories, agentWorkspaces}` |
118
+ | `GET` | `/memory/file` | `client.memory.getFile({path, scope?, agentId?})` — `path` query param required. `scope` is `"user"` (default), `"agent"`, or `"workspace"` (an Agent's own `/workspace/` files); `"agent"`/`"workspace"` require `agentId`. |
119
+ | `PUT` | `/memory/file` | `client.memory.writeFile({path, content, scope?, agentId?})` — creates or overwrites |
120
+ | `DELETE` | `/memory/file` | `client.memory.deleteFile({path, scope?, agentId?})` → `204` |
121
+ | `GET` | `/mcps/:id/oauth/owner/authorize` | `client.mcps.oauth.getOwnerAuthorizeUrl(id)` → `{url}` to redirect the Project owner to |
122
+ | `GET` | `/mcps/:id/oauth/user/authorize` | `client.mcps.oauth.getUserAuthorizeUrl(id, returnTo?)` → `{url}` to redirect the end user to |
123
+ | `GET` | `/mcps/:id/oauth/user/status` | `client.mcps.oauth.getUserConnectionStatus(id)` |
124
+ | `DELETE` | `/mcps/:id/oauth/user/connection` | `client.mcps.oauth.disconnectUserConnection(id)` → `204` |
125
+ | `DELETE` | `/mcps/:id/oauth/owner/connection` | `client.mcps.oauth.disconnectOwnerConnection(id)` → `204` |
126
+ | `GET` | `/workflows` | `client.workflows.list({page, limit, search, scope, visibility, isEnabled})` — read-only discovery of available workflows. |
127
+ | `POST` | `/workflows/:id/stream` | `client.workflows.stream(id, {input, dryRun, version})` — multi-step workflow execution with node-level AG-UI event streaming. Response carries `x-persona-run-id`. |
128
+ | `GET` | `/workflows/runs/:runId/resume` | Reattaches to in-flight or recent workflow execution with frame replay (`?since=<seq>`). |
129
+ | `POST` | `/workflows/runs/:runId/cancel` | `client.workflows.cancel(runId)` → aborts active execution. |
130
+ | `GET` | `/health` | `client.whoami()` → `{status, version, capabilities}`. Does **not** require `resolveUser` — it's a liveness/capability probe, not a user-scoped call. |
131
+
132
+ `scope` for memory routes is `'user'` (default) or `'agent'` (`agentId` then required).
133
+
134
+ For `POST /files`, a framework adapter must parse the incoming multipart body and populate
135
+ `RuntimeRequest.file` (`{filename, content: Uint8Array, contentType?}`) plus put any other form
136
+ fields (`agentId`, `threadId`) on `RuntimeRequest.body` — the runtime itself never touches raw
137
+ bytes or a specific multipart parser. See `examples/node-handler.ts`'s `readMultipartBody` for
138
+ the reference approach (it also handles `POST /knowledge/:id/documents`'s multi-file `files`
139
+ field, below).
140
+
141
+ ### Opt-in — Project-level admin surface, `capabilities.*` gated
142
+
143
+ See [Capabilities](#capabilities--admin-surface) for what these are, why they default off, and
144
+ how to enable them safely.
145
+
146
+ | Method | Path | Capability | Proxies to |
147
+ | --- | --- | --- | --- |
148
+ | `POST` | `/agents` | `agentsWrite` | `client.agents.create(input)` |
149
+ | `GET`/`PATCH`/`DELETE` | `/agents/:id` | `agentsWrite` | `client.agents.get/update/delete(id)` |
150
+ | `POST` | `/agents/bulk-delete` | `agentsWrite` | `client.agents.bulkDelete(ids)` |
151
+ | `GET`/`POST` | `/mcps` | `mcps` | `client.mcps.list/create` |
152
+ | `GET`/`PATCH`/`DELETE` | `/mcps/:id` | `mcps` | `client.mcps.get/update/delete(id)` |
153
+ | `POST` | `/mcps/bulk-delete` | `mcps` | `client.mcps.bulkDelete(ids)` |
154
+ | `GET` | `/mcps/:id/usage` | `mcps` | `client.mcps.getUsage(id)` |
155
+ | `POST` | `/mcps/:id/test` | `mcps` | `client.mcps.testConnection(id)` |
156
+ | `GET` | `/mcps/:id/resource?uri=` | `mcps` | `client.mcps.readResource(id, uri)` |
157
+ | `POST` | `/mcps/:id/call-tool` | `mcps` | `client.mcps.callTool(id, name, arguments)` |
158
+ | `GET`/`POST` | `/providers` | `providers` | `client.providers.list/create` — **holds API keys** |
159
+ | `GET`/`PATCH`/`DELETE` | `/providers/:id` | `providers` | `client.providers.get/update/delete(id)` |
160
+ | `POST` | `/providers/bulk-delete` | `providers` | `client.providers.bulkDelete(ids)` |
161
+ | `POST` | `/providers/:id/test` | `providers` | `client.providers.testConnection(id)` |
162
+ | `GET` | `/providers/:id/models` | `providers` | `client.providers.getModels(id)` |
163
+ | `GET` | `/providers/:id/usage` | `providers` | `client.providers.getUsage(id)` |
164
+ | `GET`/`POST` | `/skills` | `skills` | `client.skills.list/create` |
165
+ | `GET`/`PATCH`/`DELETE` | `/skills/:id` | `skills` | `client.skills.get/update/delete(id)` |
166
+ | `POST` | `/skills/bulk-delete` | `skills` | `client.skills.bulkDelete(ids)` |
167
+ | `GET` | `/skills/:id/usage` | `skills` | `client.skills.getUsage(id)` |
168
+ | `GET`/`POST` | `/knowledge` | `knowledge` | `client.knowledge.list/create` |
169
+ | `GET`/`PATCH`/`DELETE` | `/knowledge/:id` | `knowledge` | `client.knowledge.get/update/delete(id)` |
170
+ | `POST` | `/knowledge/bulk-delete` | `knowledge` | `client.knowledge.bulkDelete(ids)` |
171
+ | `GET` | `/knowledge/:id/usage` | `knowledge` | `client.knowledge.getUsage(id)` |
172
+ | `POST` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.uploadDocuments(id, files)` — multipart, one or more `files` parts required. `201` |
173
+ | `GET` | `/knowledge/:id/documents` | `knowledge` | `client.knowledge.listDocuments(id)` |
174
+ | `DELETE` | `/knowledge/:id/documents/:sourceName` | `knowledge` | `client.knowledge.deleteDocument(id, sourceName)` |
175
+ | `POST` | `/knowledge/:id/search` | `knowledge` | `client.knowledge.search(id, query, {topK?})` |
176
+ | `GET`/`POST` | `/stores` | `stores` | `client.stores.list/create` |
177
+ | `GET`/`PATCH`/`DELETE` | `/stores/:id` | `stores` | `client.stores.get/update/delete(id)` |
178
+ | `GET` | `/stores/:id/files` | `stores` | `client.stores.listFiles(id)` |
179
+ | `GET`/`PUT`/`DELETE` | `/stores/:id/file` | `stores` | `client.stores.getFile/writeFile/deleteFile(id, {path, content?})` |
180
+ | `GET` | `/audit-logs` | `auditLogs` | `client.auditLogs.list({page, limit, eventType})` |
181
+ | `POST` | `/architect` | `architect` | `client.architect.stream({messages, threadId, 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. `threadId` resumes a named Architect conversation (has no effect without an asserted external user). |
182
+ | `GET` | `/architect/:runId/resume` | `architect` | Reattaches to the matching `POST /architect` run. |
183
+ | `POST` | `/workflows` | `workflowsWrite` | `client.workflows.create(input)` |
184
+ | `GET`/`PATCH`/`DELETE` | `/workflows/:id` | `workflowsWrite` | `client.workflows.get/update/delete(id)` |
185
+ | `PUT` | `/workflows/:id/draft` | `workflowsWrite` | `client.workflows.saveDraft(id, draft)` |
186
+ | `POST` | `/workflows/:id/publish` | `workflowsWrite` | `client.workflows.publish(id)` |
187
+ | `GET` | `/workflows/:id/versions` | `workflowsWrite` | `client.workflows.listVersions(id)` |
188
+ | `GET` | `/workflows/:id/versions/:version` | `workflowsWrite` | `client.workflows.getVersion(id, version)` |
189
+ | `GET` | `/workflows/:id/mermaid` | `workflowsWrite` | `client.workflows.getMermaid(id)` → `{mermaid}` |
190
+ | `GET` | `/workflows/:id/runs` | `workflowsWrite` | `client.workflows.listRuns(id, params)` |
191
+ | `GET` | `/workflows/runs/:runId` | `workflowsWrite` | `client.workflows.getRun(runId)` |
192
+ | `POST` | `/messages` | `messages` | `client.messages.send({to, body?, channel?, mediaUrl?, contentSid?, contentVariables?})` → `{channel, to, from, messageSids, status, parts}`. `to` is any E.164 number; the channel is inferred from a `whatsapp:` prefix unless given. A body is required unless `contentSid` supplies an approved WhatsApp template. |
193
+ | `POST` | `/messages/agent` | `messages` | `client.messages.sendFromAgent({agentId, to, prompt, channel?, displayName?})` — runs the Agent on the conversation shared by all channels for that number, then delivers its reply. `422` for guarded (`interruptOn`) Agents, same as voice. |
194
+
195
+ | `GET`/`POST` | `/secrets` | `secrets` | `client.secrets.list/create` — values are write-only, never returned |
196
+ | `GET`/`PATCH`/`DELETE` | `/secrets/:id` | `secrets` | `client.secrets.get/update/delete(id)` (update renames or rotates) |
197
+ | `GET` | `/secrets/:id/usage` | `secrets` | `client.secrets.getUsage(id)` |
198
+ | `POST` | `/secrets/bulk-delete` | `secrets` | `client.secrets.bulkDelete(ids)` |
199
+ | `GET`/`POST` | `/rest-tool-sources` | `restToolSources` | `client.restToolSources.list/create` |
200
+ | `GET`/`PATCH`/`DELETE` | `/rest-tool-sources/:id` | `restToolSources` | `client.restToolSources.get/update/delete(id)` |
201
+ | `GET` | `/rest-tool-sources/:id/usage` | `restToolSources` | `client.restToolSources.getUsage(id)` |
202
+ | `POST` | `/rest-tool-sources/:id/test` | `restToolSources` | `client.restToolSources.testConnection(id)` |
203
+ | `POST` | `/rest-tool-sources/bulk-delete` | `restToolSources` | `client.restToolSources.bulkDelete(ids)` |
204
+
205
+ ### Project-wide admin capabilities
206
+
207
+ These four act on **the whole Project**, not on the resolved user: Persona refuses them for a key
208
+ that names an end user, so the runtime calls them **without** `externalUserId`. Turn them on only
209
+ on an admin runtime whose `resolveUser` admits your own team (see
210
+ [Capabilities](#capabilities--admin-surface)), with a key that has the scope in the last column.
211
+ Persona's audit log names the API key; the `onAdminAction` hook tells you which of your users it was.
212
+
213
+ | Method | Path | Capability | Proxies to | Key scope |
214
+ | --- | --- | --- | --- | --- |
215
+ | `GET`/`POST` | `/agents/:id/versions` | `deploy` | `client.agents.versions.list/publish` (`{changelog?}`) | `deploy:write` |
216
+ | `GET` | `/agents/:id/versions/stats` | `deploy` | `versions.stats(id, {days, source})` — runs, error rate, P50/P90, cost, 👍/👎 per version | `deploy:write` |
217
+ | `GET` | `/agents/:id/versions/:version` | `deploy` | `versions.get(id, version)` | `deploy:write` |
218
+ | `GET` | `/agents/:id/versions/:version/diff?against=` | `deploy` | `versions.diff(id, version, against ?? 'draft')` | `deploy:write` |
219
+ | `GET` | `/agents/:id/deployments` | `deploy` | `versions.deployments(id)` | `deploy:write` |
220
+ | `PUT` | `/agents/:id/deployments/policy` | `deploy` | `versions.setServePolicy(id, serveDeployedVersion)` | `deploy:write` |
221
+ | `POST` | `/agents/:id/deployments/:environment` | `deploy` | `versions.deploy(id, {environment, version?})` | `deploy:write` |
222
+ | `POST` | `/agents/:id/deployments/:environment/rollback` | `deploy` | `versions.rollback(id, environment)` | `deploy:write` |
223
+ | `GET` | `/traces` | `traces` | `client.traces.list({agentId, runId, status, feedback, environment, from, to, …})` | `traces:read` |
224
+ | `GET` | `/traces/:id` | `traces` | `traces.get(id, {spans: spans !== 'false'})` | `traces:read` |
225
+ | `GET` | `/traces/:id/feedback` | `traces` | `traces.feedback(id)` | `traces:read` |
226
+ | `GET`/`POST` | `/traces/:id/replay` | `traces` | `traces.replayPlan(id)` / `traces.replay(id, options)` (`fromStep` forks) | `traces:read` (+ `agent:invoke` to replay) |
227
+ | `GET` | `/traces/:id/replays` | `traces` | `traces.replays(id)` | `traces:read` |
228
+ | `GET`/`POST` | `/datasets` | `evals` | `client.datasets.list/create` | `eval:run` |
229
+ | `GET`/`PATCH`/`DELETE` | `/datasets/:id` | `evals` | `datasets.get/update/delete(id)` | `eval:run` |
230
+ | `GET`/`POST` | `/datasets/:id/items` | `evals` | `datasets.items.list(id)` / `datasets.import(id, items \| text)` — POST `{items}` or `{text}` (CSV / JSON / JSON lines) | `eval:run` |
231
+ | `POST` | `/datasets/:id/items/from-trace` | `evals` | `datasets.items.addFromTrace(id, traceId, {expected?})` | `eval:run` + `traces:read` |
232
+ | `PATCH`/`DELETE` | `/datasets/:id/items/:itemId` | `evals` | `datasets.items.update/delete` | `eval:run` |
233
+ | `GET`/`POST` | `/eval-runs` | `evals` | `client.evals.list/create` | `eval:run` |
234
+ | `GET` | `/eval-runs/compare?a=&b=` | `evals` | `evals.compare(a, b)` | `eval:run` |
235
+ | `GET`/`DELETE` | `/eval-runs/:id` | `evals` | `evals.get/delete(id)` | `eval:run` |
236
+ | `GET` | `/eval-runs/:id/results` | `evals` | `evals.results(id, {failed})` | `eval:run` |
237
+ | `POST` | `/eval-runs/:id/cancel` | `evals` | `evals.cancel(id)` | `eval:run` |
238
+ | `GET` | `/observe/budget` | `observe` | `client.observe.budget()` | `traces:read` |
239
+ | `GET` | `/observe/usage` | `observe` | `client.observe.usage({days, agentId, source})` | `traces:read` |
240
+
241
+ `evals.waitForRun()` / `assertPasses()` are for CI scripts, not this runtime: poll
242
+ `GET /eval-runs/:id` from your admin UI instead.
243
+
244
+ A disabled capability's routes are simply absent from the route table — a request to one 404s
245
+ (or, where an always-on route shares the same path with a different method, e.g. `POST /agents`
246
+ while only `GET /agents` is always-on, or `POST /workflows` while `GET /workflows` is always-on, `405`) rather than 403, so a disabled capability leaks no
247
+ information about what it would have done.
248
+
249
+ ## Capabilities — admin surface
250
+
251
+ The routes above are split into two trust tiers, and this is a deliberate design choice, not an
252
+ oversight:
253
+
254
+ - **Always on**: things an end user does in their own chat session — send messages, manage their
255
+ own conversations/files/memory, connect their own MCP account, run workflows. Scoped entirely to whichever
256
+ user `resolveUser` returns.
257
+ - **Opt-in via `capabilities`**: Project-level configuration — LLM provider credentials, skill
258
+ authoring, knowledge base and vector store management, security audit logs, an agent-building
259
+ co-pilot, workflow canvas builder/publishing, full Agent/MCP-server CRUD, and outbound
260
+ SMS/WhatsApp. **Every one of these defaults to `false`.** Upgrading
261
+ this package never silently exposes new surface to whoever `resolveUser` accepts.
262
+
263
+ `messages` is the one opt-in flag that isn't configuration: it's a per-request action, like
264
+ `voice/sessions`. It's still off by default because a send reaches any number the caller names
265
+ and spends this Project's Twilio balance — whoever `resolveUser` accepts would be able to text
266
+ arbitrary people, which is a materially larger blast radius than an end-user-scoped chat run.
267
+
268
+ ```ts
269
+ createRuntime({
270
+ // ...
271
+ capabilities: {
272
+ agentsWrite: false, // default
273
+ mcps: false, // default
274
+ providers: false, // default — holds API keys, think hard before enabling
275
+ skills: false, // default
276
+ knowledge: false, // default
277
+ stores: false, // default
278
+ auditLogs: false, // default
279
+ architect: false, // default
280
+ workflowsWrite: false, // default — authoring and version snapshots
281
+ messages: false, // default — sends spend the Project's Twilio balance
282
+ secrets: false, // default
283
+ restToolSources: false, // default
284
+ // Project-wide admin surface — act for the whole Project, not the user:
285
+ deploy: false, // default — key scope deploy:write
286
+ traces: false, // default — key scope traces:read (+ agent:invoke to replay)
287
+ evals: false, // default — key scope eval:run
288
+ observe: false, // default — key scope traces:read
289
+ },
290
+ });
291
+ ```
292
+
293
+ **Most hosts should never turn any of these on**, and should instead call `@personaai/sdk`
294
+ directly from their own admin backend/CLI/setup script for Project configuration — that's what
295
+ "belongs to the host application" means in practice.
296
+
297
+ If you *do* want an admin surface reachable over HTTP (e.g. building your own internal admin
298
+ tool on top of this runtime), the right pattern is **two separate `createRuntime()` calls
299
+ mounted at two different paths**, each with its own `resolveUser`:
300
+
301
+ ```ts
302
+ const appRuntime = createRuntime({
303
+ baseUrl, credential,
304
+ resolveUser: resolveEndUser, // your normal app auth — any logged-in user
305
+ });
306
+
307
+ const adminRuntime = createRuntime({
308
+ baseUrl, credential,
309
+ resolveUser: resolveAdminUser, // a stricter check — only your team
310
+ capabilities: { providers: true, skills: true, knowledge: true, stores: true, auditLogs: true, architect: true, mcps: true, agentsWrite: true },
311
+ });
312
+
313
+ // mount appRuntime at /api/persona, adminRuntime at /api/admin/persona,
314
+ // each behind whatever auth middleware your framework adapter wires up
315
+ ```
316
+
317
+ This is coarse-grained by design: a capability is either fully on or fully off for whoever
318
+ `resolveUser` accepts on that mount — there's no per-user or per-action permission model inside
319
+ the runtime itself (e.g. "this user can update Agents but not delete them" isn't expressible).
320
+ If you need that, enforce it in `resolveUser` (reject the request before it reaches the route) or
321
+ in a hook, not by asking this runtime for finer granularity than "on this mount, for this
322
+ resolved identity, is the capability on."
323
+
324
+ ## Which API key?
325
+
326
+ Give each runtime the narrowest key that works (Platform → Project → API keys):
327
+
328
+ - **Your app's runtime** (end users): an `agent:invoke` key. It covers chat, threads, files,
329
+ memory, voice, feedback, workflows, MCP OAuth and messages.
330
+ - **An admin runtime** with configuration capabilities (`providers`, `skills`, `secrets`, …):
331
+ a full-access `*` key, and a `resolveUser` that only admits your team.
332
+ - **Admin capabilities** `deploy` / `traces` / `evals` / `observe`: `deploy:write`,
333
+ `traces:read` (+ `agent:invoke` for replays), `eval:run`, `traces:read`, or `*`.
334
+
335
+ `GET /health` returns the key's `scopes` and a `warnings` list when an enabled capability needs a
336
+ scope the key lacks (also logged). A request that hits a missing scope anyway fails with
337
+ `500 RUNTIME_MISCONFIGURED`. `CAPABILITY_SCOPES` (exported) maps each capability to its scopes.
338
+
339
+ ## Which version runs
340
+
341
+ By default a run uses the Agent's own default: its editable **draft**, unless the Agent's
342
+ "serve only deployed versions" policy is on. For a production app, choose explicitly:
343
+
344
+ ```ts
345
+ createRuntime({
346
+ // ...
347
+ defaultEnvironment: 'production', // every /chat and /voice/sessions run
348
+ // or per request (wins over defaultEnvironment):
349
+ selectVersion: ({ userId, agentId, kind }) =>
350
+ betaUsers.has(userId) ? { environment: 'development' } : undefined,
351
+ // or pin a version: () => ({ agentVersion: 12 })
352
+ });
353
+ ```
354
+
355
+ The browser can't choose: `environment` / `agentVersion` in the request body are ignored unless
356
+ you set `allowClientVersionSelection: true` (e.g. for an internal preview build). Otherwise any
357
+ end user could run an unreleased version. `beforeRun` / `afterRun` see the chosen
358
+ `environment` / `agentVersion` on their context. Workflows and `/messages/agent` aren't covered
359
+ (`@personaai/sdk` has no version option for them yet).
360
+
361
+ `/chat` also forwards a valid incoming `traceparent` header (`forwardTraceparent`, default
362
+ `true`), so the run's spans join your OpenTelemetry trace.
363
+
364
+ ## Receiving Persona webhooks
365
+
366
+ Persona can POST events to your server (Platform → Observe → Webhooks). The runtime can receive
367
+ them for you:
368
+
369
+ ```ts
370
+ createRuntime({
371
+ // ...
372
+ webhooks: {
373
+ secret: process.env.PERSONA_WEBHOOK_SECRET!, // whsec_… (shown once)
374
+ on: {
375
+ 'trace.failed': async (event) => notifyOnCall(event.data),
376
+ 'budget.exceeded': async (event) => pauseCampaigns(event.data),
377
+ '*': async (event, delivery) => log(delivery.id, event.type),
378
+ },
379
+ // dedupe: { has: (id) => redis.exists(id), add: (id) => redis.set(id, 1, 'EX', 86400) },
380
+ },
381
+ });
382
+ // Register https://your-app.example/<mountPath>/webhooks/persona in the Platform.
383
+ ```
384
+
385
+ `POST /webhooks/persona` is public (no `resolveUser`). It checks `X-Persona-Signature`
386
+ (HMAC-SHA256 over the raw body, 5-minute tolerance, WebCrypto, so it works on edge runtimes),
387
+ drops duplicates by `X-Persona-Delivery`, and answers `200` when your handlers resolve or `500`
388
+ when one throws, so Persona retries with backoff.
389
+
390
+ **It needs the raw body.** `@personaai/adapters` passes `RuntimeRequest.rawBody` automatically
391
+ when it reads the body itself. If your app runs `express.json()` before the adapter, keep the
392
+ bytes: `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`; in NestJS use
393
+ `NestFactory.create(AppModule, { rawBody: true })`. Without it the route answers
394
+ `500 RUNTIME_MISCONFIGURED`.
395
+
396
+ ## Lifecycle hooks
397
+
398
+ Plain async event listeners, not middleware — the runtime proceeds with sensible defaults when a
399
+ hook is omitted, and a hook that wants to reject a run just throws (the throw is caught and
400
+ routed through the same sanitized error response as any other failure). **All thirteen are wired**
401
+ (`beforeWorkflowNode` / `afterWorkflowNode` fire on workflow streams; see the `RuntimeHooks` type).
402
+
403
+ ```ts
404
+ createRuntime({
405
+ // ...
406
+ hooks: {
407
+ beforeRun(ctx) {
408
+ // ctx: { userId, kind: 'chat' | 'architect', agentId?, threadId?, messages }
409
+ // Fires before POST /chat's or POST /architect's stream starts.
410
+ // agentId is only set for kind: 'chat' — the Architect has none of its own.
411
+ },
412
+ afterRun(ctx, result) {
413
+ // result: { text, eventCount, interrupted, erroredInBand, personaRunId?, threadId? }
414
+ // personaRunId is Persona's own run id (RUN_STARTED.runId): what POST /feedback and
415
+ // client.traces.getByRunId() take. It is NOT the x-persona-run-id resume id.
416
+ // ctx.environment / ctx.agentVersion: the version this run asked for.
417
+ // erroredInBand is true when the stream's last event was RUN_ERROR —
418
+ // that's a normal completed-run outcome, not a thrown exception, so
419
+ // afterRun still fires (onError does not).
420
+ },
421
+ onError(ctx, error) {
422
+ // ctx.phase: 'auth' | 'chat' | 'architect'
423
+ // Fires on a thrown exception only: the initial request failing
424
+ // (auth/validation/network) or the stream dying mid-read. Not on an
425
+ // in-band RUN_ERROR event — see afterRun above.
426
+ },
427
+ beforeToolCall(ctx) {
428
+ // ctx: { userId, agentId, threadId?, toolName, toolCallId }
429
+ // Fires on each TOOL_CALL_START event inside a chat stream.
430
+ },
431
+ afterToolCall(ctx, result) {
432
+ // Fires on the matching TOOL_CALL_RESULT event; `result` is the raw
433
+ // (string or already-JSON) tool output.
434
+ },
435
+ onFileUpload(ctx) {
436
+ // ctx: { userId, fileName, mimeType? } — fires after POST /files succeeds.
437
+ },
438
+ onThreadCreate(ctx) {
439
+ // ctx: { userId, agentId, threadId } — fires on an explicit POST
440
+ // /threads, AND when POST /chat's RUN_STARTED event reports a
441
+ // threadId that wasn't supplied on the way in (Persona created one
442
+ // implicitly for that turn).
443
+ },
444
+ onMemoryWrite(ctx) {
445
+ // ctx: { userId, agentId?, path } — fires after PUT /memory/file succeeds.
446
+ },
447
+ onVoiceSessionCreate(ctx) {
448
+ // ctx: { userId, agentId } — fires after POST /voice/sessions mints a
449
+ // ticket. Note this only covers the mint; the actual call happens on
450
+ // a direct browser-to-Persona WebSocket this runtime never sees.
451
+ },
452
+ onFeedback(ctx) {
453
+ // ctx: { userId, runId, score: 1 | -1, comment?, tags? } — after POST /feedback.
454
+ },
455
+ onAdminAction(ctx) {
456
+ // ctx: { userId, capability: 'deploy' | 'traces' | 'evals', action, target }
457
+ // After a publish / deploy / rollback / serve-policy change, a replay, or an eval
458
+ // run / dataset change. Persona's audit log only knows the API key: record your user here.
459
+ },
460
+ },
461
+ });
462
+ ```
463
+
464
+ ## Reconnect and resume
465
+
466
+ If a client's connection to `/chat` drops mid-stream, it can pick up exactly where it left off:
467
+
468
+ ```ts
469
+ const res = await fetch('/chat', { method: 'POST', body: JSON.stringify({ agentId, messages }) });
470
+ const runId = res.headers.get('x-persona-run-id')!;
471
+ // ... connection drops after receiving N frames ...
472
+ const resumed = await fetch(`/chat/${runId}/resume?since=${lastSeqSeen}`);
473
+ // streams every frame after `lastSeqSeen`, then continues live until the run finishes
474
+ ```
475
+
476
+ This works because a chat run is never tied to the HTTP response that started it. `POST /chat`
477
+ constructs an internal `RunDriver` that starts pumping `chat.stream()` the moment the run begins
478
+ and keeps running independently of whether anyone is still listening — buffering every formatted
479
+ SSE frame with a sequence number and broadcasting to live subscribers. `GET /chat/:runId/resume`
480
+ just attaches a new subscriber to that same driver: it replays whatever's already buffered after
481
+ `since`, then streams new frames live until the run finishes. Lifecycle hooks (`afterRun`,
482
+ `onError`, etc.) fire exactly once per run regardless of how many times a client reconnects —
483
+ they belong to the driver, not to any one HTTP response.
484
+
485
+ Finished runs stay resumable for 5 minutes by default before an internal eviction sweep (running
486
+ every 60s) removes them; the registry also caps out at 1000 tracked runs by default, evicting the
487
+ oldest-finished ones first if a host's traffic pattern leaves many runs unclaimed. Both are
488
+ configurable:
489
+
490
+ ```ts
491
+ createRuntime({
492
+ // ...
493
+ runGraceMs: 5 * 60 * 1000, // default
494
+ maxTrackedRuns: 1000, // default
495
+ });
496
+ ```
497
+
498
+ A resume request for an evicted, unknown, or someone-else's run returns `404 RUN_NOT_FOUND`
499
+ (never `403` — a `404` doesn't confirm whether the id ever existed).
500
+
501
+ **Honest limitation: this is single-process and in-memory only.** A `RunDriver` holds a live
502
+ upstream connection and a JS closure over its subscribers — it cannot be represented in Redis or
503
+ shared across separate runtime instances. This closes the reconnect gap for the common
504
+ single-instance deployment (the client dropped and came back, same server process still running).
505
+ If you run more than one instance behind a load balancer — multiple Kubernetes pods, a PM2
506
+ cluster, etc. — a reconnect that lands on a *different* instance than the one running the
507
+ original pump won't find the run (`404 RUN_NOT_FOUND`) even though it's still live elsewhere.
508
+
509
+ **Today's mitigation (deployment-level, no code change):** configure your load balancer for
510
+ session affinity / sticky sessions (route a given client to the same instance) so reconnects
511
+ land back where the run actually lives. This doesn't survive that instance crashing or being
512
+ redeployed, and doesn't apply to serverless (no persistent instance to stick to), but covers the
513
+ common case for free.
514
+
515
+ **Planned (not yet built):** a pluggable `RunBroker` interface — publish/subscribe/claim-ownership
516
+ for run frames — so a host can back it with Redis (or anything else) and get resume working
517
+ across instances, including a resume request landing on an instance that never touched the
518
+ original pump. The in-memory behavior above would remain the zero-config default; a Redis (or
519
+ similar) implementation would be opt-in, not a dependency this package forces on everyone.
520
+ Deliberately deferred rather than built speculatively — track
521
+ [issue #229](https://github.com/hasanraiyan/agent-marketplace/issues/229) or open a new one if you
522
+ need this now.
523
+
524
+ `createRuntime()` returns a `close()` method that stops the eviction timer; the timer is also
525
+ `unref`'d so it won't itself keep a Node process alive, but call `close()` if you construct
526
+ runtimes repeatedly in a long-lived process (e.g. per-test-suite setup) to avoid accumulating
527
+ timers.
528
+
529
+ ## Heartbeats and backpressure
530
+
531
+ `POST /chat` and `GET /chat/:runId/resume` both send an SSE comment-line heartbeat
532
+ (`: heartbeat\n\n`) during any gap between real AG-UI events — e.g. a long-running tool call with
533
+ no token output — so intermediary proxies and load balancers with an idle-connection timeout
534
+ don't kill the stream. Comment lines are invisible to any `data:`-only SSE parser (including
535
+ `@personaai/sdk`'s own `parseAguiEventStream`), so a consumer never sees them as part of the
536
+ event sequence.
537
+
538
+ ```ts
539
+ createRuntime({
540
+ // ...
541
+ heartbeatIntervalMs: 15000, // default; lower it for faster proxy timeouts, or raise it to reduce chatter
542
+ });
543
+ ```
544
+
545
+ Heartbeats only cover gaps *after* the first event of a run — headers can't be sent until the
546
+ runtime has already peeked that first event to decide whether the run started successfully
547
+ (a 401/400/500 has to be a normal buffered response, not a stream), so there's no way to keep a
548
+ connection alive with heartbeats before that point. In practice this matters little: the gap
549
+ heartbeats exist for is a stalled *middle* of a run (a slow tool call), not the initial
550
+ time-to-first-token.
551
+
552
+ **Backpressure has a real, deliberate tradeoff as of reconnect support.** Before reconnect
553
+ existed, a slow or disconnected consumer propagated backpressure all the way back to Persona's
554
+ server — the runtime never pulled a frame it hadn't been asked for. That's no longer true: a
555
+ `RunDriver`'s pump starts draining `chat.stream()` the moment the run begins and keeps going
556
+ regardless of subscriber speed, because resumability requires buffering whatever a reconnecting
557
+ client might ask to replay. You cannot have both "backpressure all the way to the source" and "a
558
+ disconnected client can come back and get what it missed" — they're in direct tension, and this
559
+ runtime chose resumability. What's still true and tested (`test/runDriver.test.ts`): the pump
560
+ drains the upstream generator exactly once, strictly in order, with no duplicate or skipped
561
+ `next()` calls, no matter how many subscribers attach or how slowly they read. Per-run buffers
562
+ are bounded by that one run's event count (not indefinite) and released after the grace period
563
+ described above. The Node bridge in `examples/` still layers transport-level backpressure via
564
+ `res.write()`'s return value and the `drain` event — that protects against one slow subscriber
565
+ blocking the Node process's memory, but it no longer protects against the *runtime itself*
566
+ buffering an in-progress run that nobody is currently reading.
567
+
568
+ ## Errors
569
+
570
+ Every error response is `{"error": {"code": "...", "message": "...", "details"?: {...}, "detail"?: ...}}`
571
+ (`details`: safe machine-readable extras; `detail`: development-mode debugging only). Two
572
+ modes (`mode: 'development' | 'production'`, default `'production'` unless
573
+ `NODE_ENV === 'development'`):
574
+
575
+ - Errors already curated into a developer-facing message — `RuntimeHttpError` (routing/validation
576
+ errors this runtime raises itself) and `PersonaApiError`/`PersonaAuthError`/`PersonaValidationError`
577
+ from `@personaai/sdk` — pass through as-is; `detail` (the upstream response envelope) is only
578
+ attached in development mode.
579
+ - Two Persona errors are reshaped:
580
+ - **Missing key scope** (`ForbiddenScopeError`) → `500 RUNTIME_MISCONFIGURED`. It's the server's
581
+ setup, not the user's login, so it isn't passed on as a 403; development mode names the scope.
582
+ - **Budget used up** (`BudgetExceededError`) → `402 BUDGET_EXCEEDED` with
583
+ `details: { scope: 'project' | 'agent' }`, so the UI can say which limit was reached.
584
+ - An eval-gate block on `POST /agents/:id/versions` (`EvalGateError`) → `409` with `details`
585
+ (`runId`, `passRate`, `minPassRate`, `datasetId`).
586
+ - Anything else (a bug in your own hook code, a raw network error, ...) is treated as untrusted:
587
+ always `500`/`INTERNAL_ERROR`, with a fixed generic message in production and the real
588
+ message/stack under `detail` in development. This is what actually prevents internal
589
+ implementation details (LangGraph, Qdrant, ...) from ever reaching a caller of this runtime.
590
+
591
+ ## Not yet implemented
592
+
593
+ This is v0.16.0. Every SDK resource now has a route (see [Routes](#routes)); what's left is either a
594
+ genuine unclosed gap or an intentional package boundary, not an oversight:
595
+
596
+ - **Multi-instance reconnect/resume** — see
597
+ [Reconnect and resume](#reconnect-and-resume) above. Single-process resume is implemented and
598
+ tested; sharing a live run across separate runtime instances would need a message-broker
599
+ architecture this package doesn't provide. This is the one real gap.
600
+ - **Fine-grained (per-user, per-action) permissions within an enabled capability** — **by
601
+ design**, not a gap: see [Capabilities](#capabilities--admin-surface). A capability is on or off
602
+ per mount; anything finer belongs in `resolveUser` or a hook, not the runtime.
603
+ - Framework adapters beyond the shipped ones (`@personaai/node`, `@personaai/fastify`,
604
+ `@personaai/hono`) — **by design**, not a gap: this package is the foundation they're meant to
605
+ wrap, not a replacement for them. `@personaai/adapters` (unified `express`/`nextjs`/`nestjs` via subpaths, Wave 3) has shipped — the legacy standalone `@personaai/nextjs`/`@personaai/express`/`@personaai/nestjs` packages were removed from the repo (2026-09-14); their last-published versions are frozen and deprecated on the registry.
606
+
607
+ ## Roadmap
608
+
609
+ - A pluggable `RunBroker` interface for multi-instance reconnect/resume (Redis or similar,
610
+ opt-in) — see [Reconnect and resume](#reconnect-and-resume).
611
+ - Framework adapters (Wave 2–3 of the ecosystem plan) are the natural next step once this runtime
612
+ is battle-tested — each should be a thin translation layer, proving the framework-neutral
613
+ contract here is actually sufficient.