@personaai/runtime 0.22.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 +613 -453
- package/dist/index.cjs +923 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -3
- package/dist/index.d.ts +145 -3
- package/dist/index.js +928 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
`/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
[
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
| `
|
|
100
|
-
| `GET` | `/
|
|
101
|
-
| `
|
|
102
|
-
| `POST` | `/
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `GET` | `/
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `GET` | `/
|
|
119
|
-
|
|
120
|
-
`
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
| `POST` | `/
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `GET` | `/
|
|
152
|
-
| `GET`/`
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `GET
|
|
157
|
-
| `
|
|
158
|
-
| `POST` | `/
|
|
159
|
-
| `GET` | `/
|
|
160
|
-
| `POST` | `/
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `GET`/`POST` | `/
|
|
165
|
-
| `GET`/`PATCH`/`DELETE` | `/
|
|
166
|
-
| `
|
|
167
|
-
| `GET
|
|
168
|
-
| `GET` | `/
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `GET` | `/
|
|
177
|
-
| `GET` | `/
|
|
178
|
-
| `GET` | `/
|
|
179
|
-
| `GET` | `/
|
|
180
|
-
| `
|
|
181
|
-
| `POST` | `/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
`
|
|
203
|
-
`
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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.
|