libfx 0.0.13-dev.1415.g607b1632fff6 → 0.0.13-dev.1431.g1df91f0b4ebf

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,8 +1,9 @@
1
1
  # libfx
2
2
 
3
- `libfx` is the small fx agent kernel for JavaScript hosts. One agent is one
4
- in-memory conversation with `prompt`, `checkpoint`, and `close` operations,
5
- plus mid-turn steering on each running turn.
3
+ `libfx` is the fx agent kernel for JavaScript hosts. You create an agent once
4
+ and open sessions on it. libfx saves each session as it runs, so a crash, a
5
+ function timeout, or a redeploy continues the conversation instead of losing
6
+ it. The same code runs on your machine and on Vercel.
6
7
 
7
8
  ```sh
8
9
  npm install libfx
@@ -13,45 +14,339 @@ Browsers use WebAssembly with JSPI. The default package has no runtime
13
14
  dependencies and performs no MCP connection, skill scan, process spawn, or
14
15
  filesystem read when imported.
15
16
 
17
+ libfx ships TypeScript declarations for every entry point. Under
18
+ `moduleResolution: "bundler"`, TypeScript reads the browser declarations for
19
+ `libfx` unless `customConditions` includes `"node"`.
20
+
16
21
  ## Agent
17
22
 
18
23
  ```js
19
24
  import { createFxAgent } from "libfx";
20
25
 
21
- const agent = await createFxAgent({
22
- apiKey: process.env.AI_GATEWAY_API_KEY,
26
+ const agent = createFxAgent({
23
27
  model: "google/gemini-2.5-flash-lite",
24
- onEvent(event) {
25
- if (event.type === "transport.response") console.log(event.elapsedMs);
26
- },
28
+ instructions: "Answer in one paragraph.",
27
29
  });
28
30
 
29
- const turn = agent.prompt("Explain this project.");
31
+ const turn = agent.session().prompt("Explain this project.");
30
32
 
31
33
  for await (const event of turn) {
32
34
  if (event.type === "text_delta") process.stdout.write(event.delta);
33
35
  }
34
36
 
35
- console.log(await turn.result); // { stopReason, usage }
36
- const checkpoint = await agent.checkpoint();
37
+ console.log(await turn.result); // { messageId, stopReason, usage }
37
38
  await agent.close();
38
39
  ```
39
40
 
40
- `apiKey` is required. `model` is optional and defaults to fx's built-in model.
41
- Agent configuration uses named options; `env` is reserved for
42
- `createFxTerminal()`. The canonical model configuration groups the model ID
43
- and model-specific options:
41
+ `createFxAgent()` returns at once and does no I/O until a session runs a turn.
42
+ `apiKey` is optional. Without it, libfx uses `AI_GATEWAY_API_KEY`, then on
43
+ Vercel the deployment's OIDC token. `model` defaults to fx's built-in model.
44
+ Tools, MCP clients, and skills are agent options, described in
45
+ [JavaScript tools and instructions](#javascript-tools-and-instructions) and the
46
+ sections after it.
47
+
48
+ Because nothing loads before a session runs, an option the backend rejects,
49
+ such as an effort level the model does not support, fails that session's
50
+ first turn rather than the `createFxAgent()` call. The turn's `result` has the
51
+ stop reason `error` and the reason in `error`, and `onEvent` receives
52
+ `session.error`.
53
+
54
+ An agent is the configuration its sessions run under. Every process that
55
+ creates an agent with the same options can run the same sessions, so create
56
+ one agent for your server and open a session per conversation.
57
+ `agent.close()` waits for the turns this process is running, then lets their
58
+ sessions go.
59
+
60
+ ### Sessions
61
+
62
+ `agent.session(id)` opens the session `id`, and `agent.session()` starts a new
63
+ one. Any other value, `null` included, throws. Opening a session reads
64
+ nothing; its first prompt does. A new session's `id` is set once that prompt is
65
+ accepted, so read it from `turn.accepted`:
44
66
 
45
67
  ```js
46
- const agent = await createFxAgent({
47
- apiKey,
68
+ const turn = agent.session().prompt("Plan the migration.");
69
+ const { sessionId } = await turn.accepted;
70
+
71
+ // Later, in any process:
72
+ const followUp = agent.session(sessionId).prompt("Start with the schema.");
73
+ ```
74
+
75
+ To know the id before the first prompt, for example so a retried first
76
+ request with the same `messageId` reaches the same session, get one from
77
+ `await agent.newSessionId()` and open it with `agent.session(id)`.
78
+
79
+ On `memory()`, a session id is 1 to 128 letters, digits, `.`, `_`, or `-`. A
80
+ World durability, `local()`, `vercel()`, or `world()`, keeps each session as a
81
+ World run, so the id is a run id: `wrun_` followed by 10 to 64 letters or
82
+ digits. Take it from `agent.newSessionId()` or `turn.accepted`. Vercel's World
83
+ only creates a run whose id was minted in the last 24 hours, so send a new
84
+ id's first prompt within a day. `agent.prompt()` and `agent.checkpoint()` use
85
+ a session the agent opens for itself, for code that holds one conversation.
86
+
87
+ One process runs a session's turns at a time, one turn after another. A
88
+ second process that receives a prompt for a busy session queues it behind the
89
+ running turn. Pass `context` to hand JSON to the session's tools:
90
+ `agent.session(id, { context: { userId } })` gives every tool call
91
+ `context` beside its `executionId` and `sessionId`. Each turn runs with the context of the
92
+ `session()` call whose prompt or steer started it, and a call without
93
+ `context` gives its turns none.
94
+
95
+ A conversation can also choose its own model and instructions.
96
+ `agent.session(id, { model, instructions })` runs the turns it starts with
97
+ that model, a model ID or a model object such as `{ id, effort, fast }`, in
98
+ place of the agent's whole model choice, and with those instructions in place
99
+ of the agent's. They follow the rule `context` follows: a turn keeps the
100
+ settings of the `session()` call that started it, also when another process
101
+ continues it, and a call without them runs with the agent's. Pass a
102
+ conversation's settings on each call; a change applies from its next turn.
103
+ When a session's next turn runs under another model than the one its last
104
+ checkpoint was saved under, the session continues and `onEvent` receives a
105
+ `checkpoint.mismatch` event.
106
+
107
+ ### Turns
108
+
109
+ `session.prompt(input)` queues `input` as the session's next turn and returns
110
+ a view of that turn. Prompt input is a string or an array of content blocks,
111
+ as described in [prompt input](#prompt-input), except that image data must be a
112
+ base64 string, because the prompt is stored as JSON before it runs. A turn
113
+ view has:
114
+
115
+ - `messageId`: the turn's id, which libfx chooses unless you pass one.
116
+ - `accepted`: resolves to `{ messageId, sessionId }` once the prompt is stored,
117
+ or at once for a prompt whose `signal` had already aborted, which is never
118
+ stored.
119
+ - `result`: resolves to `{ messageId, stopReason, usage }` when the turn ends.
120
+ - Async iteration over the turn's events, as
121
+ [model and tool events](#prompt-input) describes.
122
+ - `readable`: the same turn as an NDJSON `ReadableStream` that a route can
123
+ return as its response body. Each line is an event with a `cursor`.
124
+
125
+ Dropping a turn view cancels nothing: the turn runs to its end whether or not
126
+ anyone reads it. Call `session.cancel()` to stop it. A prompt's `signal`
127
+ cancels that prompt's turn when it aborts, whether the turn is running or still
128
+ waiting behind another, and never a later turn. A prompt whose `signal` had
129
+ already aborted is not stored: its `result` resolves with the stop reason
130
+ `cancelled`.
131
+
132
+ Give a prompt a `messageId` when the same request can reach your server more
133
+ than once, such as a client retry. A second `prompt()` with an id the session
134
+ already accepted runs nothing: while that turn runs, the view follows it, and
135
+ after it ends, `result` resolves to its outcome with `repeated: true`. When
136
+ that turn failed and its end never reached the stream, `result` resolves with
137
+ the failure from the session's log, without `repeated` if the failure could be
138
+ this request's own run. When the process that ran the turn stopped before
139
+ writing how it ended, `result` resolves with the stop reason `unknown` after
140
+ 30 seconds. An id follows the session id rule.
141
+
142
+ `session.stream(cursor)` returns every event the session has produced from
143
+ `cursor` on, as NDJSON, and stays open for the events after them. Any process
144
+ can serve it, so a browser that loses its connection reconnects with the last
145
+ `cursor` it saw and continues where it stopped. Beside the turn events, the
146
+ stream marks each turn with `turn_start` and `turn_end`, a continued turn with
147
+ `turn_resume`, and a turn that stopped for a function deadline with
148
+ `turn_yield`. `turn_start` and `turn_resume` carry the `sessionId`, so a client
149
+ that started a new session learns its id from the first line of
150
+ `turn.readable`.
151
+
152
+ Each line carries the `epoch` of the lease its writer held, and epochs only
153
+ rise over a session's life. A worker that was replaced while it ran can still land lines late, because the World accepts
154
+ them. A worker that takes a turn over writes its `turn_resume` line before it
155
+ goes on, and every reader hides a line whose epoch is lower than one before
156
+ it, so the replaced worker's later lines never show and every reader, on any
157
+ server and after any refresh, shows the same lines. A replaced worker's line
158
+ that lands in the moment between its successor's claim and that `turn_resume`
159
+ line still shows; only a World that refuses stale stream writes closes that
160
+ gap.
161
+
162
+ `session.steer(text)` adds guidance to the running turn at its next model
163
+ request, or to the next turn when none is running. `session.resume()`
164
+ continues a turn a stopped process left open and returns its view, whose
165
+ `result` has the stop reason `idle` when there was none. A prompt also
166
+ continues an open turn before it runs, so most hosts never call `resume()`.
167
+
168
+ ### Durability
169
+
170
+ The `durability` option chooses where sessions live. Without it, libfx picks
171
+ one from the environment:
172
+
173
+ | Environment | Durability | Sessions live in |
174
+ | --- | --- | --- |
175
+ | Vercel, where `VERCEL` is set | `vercel()` | Vercel's World, the store and queue behind Vercel Workflow |
176
+ | Node.js elsewhere | `local()` | files in `FX_SESSIONS_DIR`, or `$TMPDIR/libfx/sessions` |
177
+ | Browsers | `memory()` | this page's memory |
178
+
179
+ ```js
180
+ import { createFxAgent, memory } from "libfx";
181
+ import { local } from "libfx/durable-local";
182
+
183
+ createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
184
+ createFxAgent({ durability: memory() }); // nothing survives the process
185
+ ```
186
+
187
+ `libfx/durable-local` and `libfx/durable-vercel` carry their World inside
188
+ them, and libfx loads one only when a session first needs it, so your app
189
+ installs no other package and an app with no sessions loads neither.
190
+
191
+ To keep sessions in a World your app creates, such as Workflow's Postgres
192
+ World, pass it to `world()` from `libfx/durable-world`:
193
+
194
+ ```js
195
+ import { createFxAgent } from "libfx";
196
+ import { world } from "libfx/durable-world";
197
+ import { createWorld } from "@workflow/world-postgres";
198
+
199
+ // libfx's own queue topics and jobs, apart from the app's workflows.
200
+ const sessions = createWorld({ namespace: "libfx", jobPrefix: "libfx_" });
201
+ await sessions.start();
202
+
203
+ const agent = createFxAgent({ durability: world(sessions, { queueDurable: true }) });
204
+ ```
205
+
206
+ A World you pass in stays yours. libfx never starts or closes it, so start it
207
+ before the agent's first prompt and close it after `agent.close()`. When the
208
+ World delivers in your process, agents given the same World take its
209
+ deliveries in turn, as servers sharing a queue do, and a delivery that arrives
210
+ while no agent is listening fails, so the queue delivers it again. Pass a
211
+ function that returns a World instead, and libfx creates, starts, and closes
212
+ it with the agent. `local()` and `vercel()` are `world()` with the World each
213
+ one bundles.
214
+
215
+ `world()` takes what libfx can't learn from the World itself:
216
+
217
+ | Option | Default | Meaning |
218
+ | --- | --- | --- |
219
+ | `queueDurable` | `false` | The queue keeps a message until a delivery acknowledges it, across crashes. When `false`, `prompt()` stores the prompt in the session before it queues it. |
220
+ | `livenessKnown`, `alive(lease)`, `holderInfo()` | `false` | `alive(lease)` answers whether the holder of a lease still runs, from what `holderInfo()` put in it, so a crashed worker's session frees at once. Without it, a worker renews its lease while it runs, and the session of one that died frees `leaseMs` after its last renewal. |
221
+ | `leaseMs` | 15000 | Without liveness, how long a lease lasts after its worker took or last renewed it. The worker renews it every third of that, and never past its deadline. |
222
+ | `maxDurationMs`, `reserveMs` | no limit, 30000 | Each delivery stops `reserveMs` before `maxDurationMs`, or before the World's `getRuntimeDeadline()` when it has one. |
223
+ | `pollMs` | 1000 | How often a waiting worker reads the session again. |
224
+ | `gatewayKey()` | none | The AI Gateway credential when the agent has no `apiKey`. |
225
+ | `name` | `"world"` | The durability's name in errors. |
226
+
227
+ libfx uses the World's `events.create` and `events.list`, its streams, its
228
+ queue, and `createQueueHandler`. It reads an event's position in its run from
229
+ the event's id, which must be lowercase letters, an underscore, and that
230
+ position as 26 digits, as in Workflow's Worlds; a session whose World returns
231
+ another id fails. It sends every message on the topic
232
+ `__libfx_wkf_workflow_session` and handles the prefix
233
+ `__libfx_wkf_workflow_`, which is what a Workflow World's `namespace: "libfx"`
234
+ gives, so a World serving the app's own workflows on the default namespace
235
+ needs a second instance for libfx. A World with `registerHandler` delivers to
236
+ the agent in its process. Any other World delivers over HTTP, so mount
237
+ `agent.wakeHandler()` where it delivers, as [Next.js and
238
+ Vercel](#nextjs-and-vercel) shows.
239
+
240
+ libfx records each step of a turn as it happens. A prompt is stored before
241
+ the caller hears it was accepted, and a tool call with effects is stored
242
+ before it runs, so a crash never loses an accepted prompt and never repeats a
243
+ tool call silently. When a session has no turn in progress, the process that
244
+ takes it sends the next turn's first model request while it writes its claim
245
+ on the session, and it writes, shows, and runs nothing for that turn until
246
+ the claim lands. If another process's claim lands first, the late claim
247
+ counts for nothing and its model request is cancelled. Other records are
248
+ written while the model works, without holding up the turn. A session also
249
+ saves a checkpoint at the end of every turn and wherever a turn yields, so the
250
+ process that continues it reads the checkpoint and the few records after it,
251
+ not the whole history.
252
+
253
+ When the process running a turn stops, the next process to receive work for
254
+ the session continues the turn from its last record:
255
+
256
+ - On `local()`, a session held by a process that died is free at once.
257
+ - On `vercel()`, where libfx cannot tell whether a function still runs, a
258
+ worker renews a 15 second lease while it runs. When its function dies, its
259
+ session frees 15 seconds after the last renewal, and the next delivery for
260
+ it continues the turn. When
261
+ the deadline is near, libfx stops before the next model request and saves
262
+ the turn, and the next invocation continues it. A model request or tool
263
+ call still running shortly before the deadline is cut off: `onEvent`
264
+ receives `session.deadline`, and the next invocation continues the turn,
265
+ running a cut-off idempotent call again and telling the model about any
266
+ other. The event's `cutoffs` counts the times in a row the deadline cut
267
+ off the same step. After 3, the next invocation does not run that step
268
+ again: a call is answered as possibly run, so the model decides what to do
269
+ next, and a model request cancels the turn. So a process never runs on
270
+ after the deadline its hold ends at. A
271
+ process that freezes, or whose renewals stall for a lease, and wakes after
272
+ another took its session over stops at its next write, a renewal included,
273
+ and `onEvent` receives `session.fenced`. Until then it
274
+ can finish a model request or a call to an idempotent tool; it never starts
275
+ a tool with effects, because that waits for its record to be stored. A
276
+ process that started a new turn on an idle session and was taken over
277
+ while still claiming the session runs nothing for that turn and reads the
278
+ session's log again instead, so the prompt it brought is not lost.
279
+ - When a write fails for any other reason, or the engine running a turn stops,
280
+ `onEvent` receives `session.error`, and the same queue message runs the
281
+ session again with a new engine. A turn whose engine stops 3 times is
282
+ cancelled, and the prompts behind it run; one that never got started ends
283
+ with an error instead. When the engine stops twice more, even to cancel the
284
+ turn, the session can run no more turns: the turn and every prompt sent to
285
+ the session end with an error. A turn whose engine stops after storing its
286
+ end resolves with the stop reason `unknown`.
287
+ - When an engine cannot open, the turn waiting on it ends with an error, and
288
+ each later engine that cannot open ends the next waiting prompt the same
289
+ way. A started turn that ended this way stays open in the session, and
290
+ `session.resume()` answers `idle` for it. The next prompt's engine first
291
+ continues it, or cancels it when it was cancelled or its engine kept
292
+ stopping, and then runs that prompt.
293
+ - On `memory()`, sessions end with the process.
294
+
295
+ The model is told when its turn was interrupted, so it can check what
296
+ happened before going on. A turn that stopped at a model request for a
297
+ deadline continues without that notice; one whose call was cut off gets it.
298
+
299
+ A durability holds conversation history only. Every process supplies the
300
+ model, credentials, instructions, tools, MCP clients, and skills when it
301
+ creates the agent. Every checkpoint records the libfx version, tool set, and
302
+ model that saved it. When a saved session, or a checkpoint passed as
303
+ `checkpoint`, was saved under different ones than the agent now has, it still
304
+ loads, and `onEvent` receives a `checkpoint.mismatch` event naming what
305
+ changed.
306
+
307
+ `session.checkpoint()` returns the session's history as opaque bytes, read
308
+ without changing the session. Pass them as `checkpoint` to start a new agent's
309
+ own session from them, for example to move a conversation between
310
+ durabilities.
311
+
312
+ ### Tools with effects
313
+
314
+ When a process stops while a tool call runs, libfx cannot know whether the
315
+ call finished. Mark a tool `idempotent: true` when running it twice is safe,
316
+ such as a lookup:
317
+
318
+ ```js
319
+ const tools = [
320
+ { name: "get_order", description: "Looks up an order", idempotent: true, inputSchema, async execute(input, { executionId }) { /* ... */ } },
321
+ { name: "refund_order", description: "Refunds an order", inputSchema, async execute(input, { executionId }) { /* ... */ } },
322
+ ];
323
+ ```
324
+
325
+ An idempotent call that was running runs again when the turn continues. Any
326
+ other call never runs again on its own: the turn continues at once, and the
327
+ model receives that call's result as an error saying it may have partly run,
328
+ so it can check the call's effects before calling it again or ask the user.
329
+
330
+ `executionId` is the same for a call each time it runs, including after a
331
+ crash. Pass it to the service the tool calls, as an idempotency key, so a call
332
+ that runs again changes nothing twice. Calls to idempotent tools also start
333
+ without waiting for their record to be stored.
334
+
335
+ ## Model options
336
+
337
+ Model configuration groups the model ID and model-specific options:
338
+
339
+ ```js
340
+ const agent = createFxAgent({
48
341
  model: { id: "anthropic/claude-opus-5.5-fast", effort: "low", fast: true },
49
342
  });
50
343
  ```
51
344
 
52
- A string `model` remains supported as shorthand. Top-level `effort` and
53
- `fast` are deprecated but remain supported with a string model or no model;
54
- they cannot be mixed with a model object. New code should use the model object.
345
+ Agent configuration uses named options; `env` is reserved for
346
+ `createFxTerminal()`. A string `model` remains supported as shorthand.
347
+ Top-level `effort` and `fast` are deprecated but remain supported with a
348
+ string model or no model; they cannot be mixed with a model object. New code
349
+ should use the model object.
55
350
 
56
351
  `model.effort` sets the reasoning effort for models that advertise effort
57
352
  levels. It uses the same vocabulary as the fx CLI's `--effort` flag:
@@ -98,6 +393,42 @@ included.
98
393
  libfx makes at most one automatic retry after a retryable transport failure and
99
394
  only before model output or tool effects escape. Cancellation prevents a retry.
100
395
 
396
+ ## A single engine
397
+
398
+ `createFxEngine()` is the kernel under `createFxAgent()`: one conversation,
399
+ held in the memory of the process that runs it, with no queue and no session
400
+ store. Use it to run turns inside one request and keep nothing, or to keep a
401
+ conversation in storage of your own through [persistence](#persistence). It
402
+ takes the same model, tool, MCP, and skill options, `apiKey` is required, and
403
+ it resolves once its backend has loaded. In this section, `agent` is an engine:
404
+
405
+ ```js
406
+ import { createFxEngine } from "libfx";
407
+
408
+ const agent = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model });
409
+ ```
410
+
411
+ ### Code written for libfx 0.0.12
412
+
413
+ In libfx 0.0.12, `createFxAgent()` created this engine. It now creates the
414
+ durable agent, so rename those calls to `createFxEngine()`. Code that still
415
+ calls `createFxAgent()` keeps running, with these differences:
416
+
417
+ - `await createFxAgent(...)` resolves to the durable agent, because awaiting a
418
+ value that is not a promise returns the value.
419
+ - Breaking out of a turn's events no longer cancels the turn, which runs to
420
+ its end. Cancel it with the prompt's `signal` or `session.cancel()`.
421
+ - Turns have no `cancel()` or `steer()`, and the agent has no `followUp()` or
422
+ `resume()`. A session has `steer()`, `cancel()`, and `resume()`.
423
+ - `persistence` and `sessionId` throw, because the durability holds the
424
+ session.
425
+
426
+ On the native backend, host tools now run alongside each other unless they
427
+ are marked `writes: true`, in `createFxEngine()` as well. WebAssembly runs
428
+ them one at a time.
429
+
430
+ ### Prompt input
431
+
101
432
  `prompt(input, { signal? })` accepts a string or text, image, and resource
102
433
  blocks. It returns an async iterable of normalized events:
103
434
 
@@ -124,7 +455,7 @@ A turn has one event consumer. Breaking out of its iterator cancels the turn;
124
455
  `turn.cancel()` and `agent.close()` also release blocked output. Embedded agents
125
456
  have no implicit model-step cap, so hosts should cancel turns that exceed their
126
457
  own budgets. The CLI's `max_agent_steps` setting does not apply to
127
- `createFxAgent()`. Transport or message-decoding failures reject the result
458
+ `createFxEngine()`. Transport or message-decoding failures reject the result
128
459
  instead of returning success with missing text.
129
460
 
130
461
  Native transport buffers at most 8 MiB of output bytes. Unread SDK events apply
@@ -191,7 +522,7 @@ For a Node.js host that already uses `sharp`:
191
522
  ```js
192
523
  import sharp from "sharp";
193
524
 
194
- const agent = await createFxAgent({
525
+ const agent = await createFxEngine({
195
526
  apiKey,
196
527
  model,
197
528
  async resizeImage({ bytes }) {
@@ -250,7 +581,7 @@ const prepareImage = {
250
581
  };
251
582
  },
252
583
  };
253
- // Supply prepareImage in createFxAgent({ tools: [prepareImage], ... }).
584
+ // Supply prepareImage in createFxEngine({ tools: [prepareImage], ... }).
254
585
  ```
255
586
 
256
587
  `imageStore` is application code, not a libfx API. It must authorize references,
@@ -285,9 +616,36 @@ for await (const event of turn) {
285
616
  }
286
617
  ```
287
618
 
619
+ `steer()` resolves to `{ id }`, and the returned promise carries the same `id`
620
+ at once. Until the model sees a steer, `turn.withdraw(id)` takes it back and
621
+ resolves to `withdrawn`; after that it resolves to `already_placed`. With
622
+ [persistence](#persistence), a steer resolves only once its acceptance is stored, a model request
623
+ carries it only after that, and a withdrawal resolves once it is stored, so a
624
+ crash never loses a steer the call reported as accepted: `agent.resume()`
625
+ delivers one the model had not seen yet. The web core accepts a steer when it
626
+ takes it at the next boundary, so there `steer()` resolves then, and a steer
627
+ still queued when the turn ends rejects.
628
+
629
+ `agent.followUp(input)` queues text to run as its own turn once the current
630
+ turn ends, or at once when none is running, instead of failing with
631
+ `a prompt is already in progress`. It returns a promise for that turn; the
632
+ promise carries the follow-up's `id` and `accepted`, which resolves `{ id }`
633
+ once it is stored. With persistence, a follow-up survives a crash. After a
634
+ restore, the follow-ups the session held have no caller, so each waits for
635
+ `agent.resume()`: every call continues the open turn first, then starts the
636
+ next held follow-up, and returns `null` once none is left. Follow-ups this
637
+ agent queues run on their own and do not wait behind held ones. Call `resume()`
638
+ until it returns `null` whenever a session opens, before this agent queues
639
+ follow-ups of its own: one queued during a resumed turn starts when that turn
640
+ ends, and `resume()` throws while it runs. A follow-up whose turn ends before
641
+ libfx records its first `turn_progress` is withdrawn, and one that ends after
642
+ it is recorded like any other turn, so a follow-up never runs twice. `close()`
643
+ rejects follow-ups that have not started; the session's records still hold them.
644
+
288
645
  Cancelling a steered turn drops any guidance that has not reached a safe
289
646
  boundary and releases its queue. Applied guidance is part of the same history
290
647
  turn, so an idle `checkpoint()` includes the full steered conversation.
648
+
291
649
  `checkpoint()` returns opaque, bounded, versioned bytes. Concurrent calls run
292
650
  one at a time, and a call still waiting for an earlier one fails the same way a
293
651
  direct call would if a prompt starts or the agent closes first. A newer libfx
@@ -295,7 +653,7 @@ restores checkpoints from older versions, but an older libfx cannot restore one
295
653
  written by a newer version. Restore them only when creating a fresh agent:
296
654
 
297
655
  ```js
298
- const restored = await createFxAgent({ apiKey, model, checkpoint });
656
+ const restored = await createFxEngine({ apiKey, model, checkpoint });
299
657
  ```
300
658
 
301
659
  An already-aborted prompt signal returns `cancelled` without a model request
@@ -308,6 +666,148 @@ agent-creation options and are not stored in a checkpoint: recreate the agent
308
666
  with new `model.effort`, `model.fast`, or `model.ultrafast` values to change
309
667
  them, the same path as switching models.
310
668
 
669
+ ### Persistence
670
+
671
+ Pass a store as `persistence`, and libfx records the session as it runs, so a
672
+ new agent can continue it after the process running it stops. libfx writes
673
+ opaque records and checkpoints to the store and reads them back. The store
674
+ never parses them, and libfx never sees how they are stored.
675
+
676
+ ```js
677
+ import { createFxEngine, createMemoryPersistence } from "libfx";
678
+
679
+ const persistence = createMemoryPersistence();
680
+ const agent = await createFxEngine({ apiKey, model, persistence, sessionId: "support-42" });
681
+ ```
682
+
683
+ A store is an object with these methods:
684
+
685
+ ```ts
686
+ interface Persistence {
687
+ load(): Promise<{
688
+ checkpoint?: { data: Uint8Array; through: string };
689
+ journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
690
+ }>;
691
+ append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array }): Promise<{ cursor: string }>;
692
+ saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
693
+ }
694
+
695
+ interface JournalRecord {
696
+ cursor: string;
697
+ data: Uint8Array;
698
+ }
699
+ ```
700
+
701
+ `append()` stores one record and resolves to its cursor, a string the store
702
+ chooses that places the record after the ones before it. The session's records
703
+ are its journal. libfx sends records one at a time, in order, and `expected`
704
+ is the cursor of the last record the agent knows of, or `null` for a new
705
+ session. Reject the append with an `FxFencedError` when `expected` is not the
706
+ cursor of the last stored record, so that a second agent writing the same
707
+ session stops the first instead of interleaving with it. `idempotencyKey` is
708
+ unique to each write, so a store that retries a write can return the stored
709
+ record's cursor instead of storing it twice.
710
+
711
+ `createFxEngine()` calls `load()` once. Resolve to the latest checkpoint, if any,
712
+ and the records stored after it, oldest first, each with its cursor. Resolve to
713
+ `{}` for a session with nothing stored. libfx skips any record the checkpoint
714
+ already covers.
715
+
716
+ libfx waits for the store only where a crash could otherwise lose work or
717
+ repeat it: the first record of each turn, which holds the prompt, is stored
718
+ before the model sees it, and a response's tool calls are stored before any of
719
+ them starts. Other appends are not waited for, so a turn's `result` can settle
720
+ before its last records land, and `agent.close()` waits for every append. If
721
+ `append` rejects, libfx stops the current turn at once: it starts no further
722
+ model request or tool call and writes nothing more. The turn fails with an
723
+ error whose `code` is `FX_JOURNAL_APPEND_FAILED` and whose `cause` is your
724
+ error, and the agent refuses later prompts. When no turn was left to report
725
+ the failure, `close()` rejects with it. A model request the turn started while
726
+ the failed append was in flight can still complete.
727
+
728
+ When the store has `saveCheckpoint()`, libfx saves a checkpoint after a turn
729
+ ends or yields once the records stored since the last checkpoint reach
730
+ `checkpointAfterBytes` (default 1 MiB). A checkpoint where a turn yielded
731
+ holds the open turn, and the next load continues it. `through` is the cursor of the last
732
+ record the checkpoint covers, so `load()` can return that checkpoint and only
733
+ the records after it, and the cost of opening a session stays bounded as its
734
+ history grows. libfx takes a checkpoint only while no turn is running. A saved
735
+ checkpoint emits a `checkpoint.save` event. A failed save emits
736
+ `checkpoint.error` and changes nothing: the records still restore the session,
737
+ and the next turn's end tries again. Deleting the records a checkpoint covers
738
+ is up to the store.
739
+
740
+ The records a load reads, after libfx drops the older progress of an open
741
+ turn, must fit in 4 MiB of JSON, a checkpoint in 4 MiB, and the history in
742
+ 1,024 turns. Otherwise `createFxEngine()` rejects with an error whose `code` is
743
+ `FX_JOURNAL_TOO_LARGE`. libfx refuses records that repeat, skip, or do not
744
+ parse, and `createFxEngine()` rejects with the reason and the `code`
745
+ `FX_JOURNAL_INVALID`. For records written by a newer libfx, it rejects with an
746
+ `FxJournalVersionError`, whose `code` is `FX_JOURNAL_VERSION`. Each libfx
747
+ release resumes the sessions and checkpoints that the release before it saved,
748
+ so upgrade the processes that read a session before the ones that write it.
749
+
750
+ AI Gateway keys session affinity and prompt caching to the session's id. libfx
751
+ picks a new id for each agent unless you pass `sessionId`, so give a restored
752
+ session the id it had before. An id is 1 to 255 letters, digits, `.`, `_`, or
753
+ `-`. `agent.sessionId` is the id the agent uses.
754
+
755
+ If the last process stopped during a turn, `agent.resume()` continues that
756
+ turn with no new input and returns it, like `prompt()`. The model is told
757
+ "Resuming from unexpected session interruption.". A tool call that was
758
+ running does not run again: it comes back answered with an error saying it may
759
+ have partly run, and the model decides whether to check its effects, call it
760
+ again, or ask the user. A turn that fails or is cancelled ends in the journal
761
+ as it does in the agent, so only a stopped process leaves one to resume.
762
+ `resume()` returns `null` when the session holds no open turn and no held
763
+ follow-up, so a host can call it until it does every time it opens a session:
764
+
765
+ ```js
766
+ const agent = await createFxEngine({ apiKey, model, persistence, sessionId, tools });
767
+ for (let turn = agent.resume(); turn; turn = agent.resume()) {
768
+ for await (const event of turn) console.log(event);
769
+ await turn.result;
770
+ }
771
+ ```
772
+
773
+ When your host knows a call never ran, such as one it handed to another
774
+ process before the call did anything, pass `resume({ onAmbiguous })`. libfx
775
+ calls `onAmbiguous({ executionId, name, input })` once for each call the turn
776
+ left running, and each call it returns `"rerun"` for runs again under the same
777
+ `executionId` before the turn continues. Every other call keeps the answer that it may
778
+ have partly run.
779
+
780
+ Calling `prompt()` instead ends the open turn as interrupted and starts a new
781
+ one.
782
+
783
+ Give a prompt a `turnId` when the same request can reach libfx more than once,
784
+ such as from a queue that delivers it again after a crash. When the open turn
785
+ has that id, `prompt(input, { turnId })` continues it as `resume()` does, and
786
+ takes `onAmbiguous` the same way. When
787
+ the session's last turn ended with that id, it returns a turn that has already
788
+ ended, with the stop reason `end_turn`, without calling the model. Any other id
789
+ starts a new turn. A turn id is 1 to 128 letters, digits, `.`, `_`, or `-`.
790
+ Without one, libfx names the turn. `turn.id` is the turn's id.
791
+
792
+ To move a running session to another process, call
793
+ `turn.cancel({ reason: "handoff" })`. The turn stops at once, like any
794
+ cancellation, but libfx stores nothing more, so the journal keeps the turn
795
+ open and the agent that opens the session next continues it with `resume()`.
796
+ The handed-off agent then refuses `prompt()`, `resume()`, and `followUp()`;
797
+ close it. A handoff needs persistence.
798
+
799
+ Pass `persistence` instead of a `checkpoint` option; the two cannot be
800
+ combined. Like a checkpoint, persistence holds conversation history only:
801
+ resupply models, credentials, instructions, tools, MCP clients, and skill
802
+ records when you create the agent. A resumed turn continues under the tools the
803
+ new agent has. `createMemoryPersistence()` keeps the records and the latest
804
+ checkpoint in memory as `persistence.records` and `persistence.checkpoint`,
805
+ which suits tests and hosts that copy them to their own storage. It fences an
806
+ append whose `expected` cursor is not its last record's, and returns the stored
807
+ cursor for a repeated `idempotencyKey`. `FxFencedError` is exported by `libfx`;
808
+ its `code` is `FX_FENCED`, which a store of your own can use for the same case.
809
+ `FxJournalVersionError` is exported by `libfx` too.
810
+
311
811
  ## Models
312
812
 
313
813
  Model discovery is explicit and does not create an Agent or load native or Wasm
@@ -325,10 +825,25 @@ const models = await listModels({
325
825
  language-model IDs. It accepts the same optional `fetch` override as the Agent
326
826
  API.
327
827
 
828
+ An agent reads the AI Gateway model catalog to learn what its model supports,
829
+ such as image input, reasoning effort, and output limits, and those details
830
+ shape every request. It fetches the catalog through your `fetch`. If that
831
+ request fails, for example behind a proxy that only forwards chat requests,
832
+ the agent cannot confirm the model's capabilities and refuses image prompts.
833
+ Pass `modelCatalog` to supply the catalog instead: the entries from
834
+ `https://ai-gateway.vercel.sh/coding-agent/v1/models`, either the response's
835
+ `{ data }` or the array, or only the entries for the models you use. The agent
836
+ then makes no catalog request, so every process that receives the same
837
+ entries builds the same requests:
838
+
839
+ ```js
840
+ const agent = createFxAgent({ apiKey, model, modelCatalog });
841
+ ```
842
+
328
843
  ## JavaScript tools and instructions
329
844
 
330
845
  ```js
331
- const agent = await createFxAgent({
846
+ const agent = createFxAgent({
332
847
  apiKey,
333
848
  model,
334
849
  instructions: "Keep answers concise.",
@@ -351,7 +866,7 @@ Gateway web search can run at the provider instead of in the JavaScript host.
351
866
  Mark its canonical tool name with `providerExecuted: true` and omit `execute`:
352
867
 
353
868
  ```js
354
- const agent = await createFxAgent({
869
+ const agent = createFxAgent({
355
870
  apiKey,
356
871
  tools: [{ name: "web_search", providerExecuted: true }],
357
872
  });
@@ -365,6 +880,25 @@ call host code or require a separate provider key; their `tool_start` and
365
880
  Their built-in permission policy is enforced when the request is projected, as
366
881
  there is no local call-time effect to approve.
367
882
 
883
+ `tools` may also be an object keyed by tool name, such as
884
+ `tools: { lookup, save }`, where each value is a descriptor without `name`.
885
+ A descriptor that does include `name` must match its key.
886
+
887
+ When one model response calls several tools, the native backend runs them at
888
+ the same time and returns their results to the model in the order it called
889
+ them. Mark a tool `writes: true` when its calls must not overlap others: it
890
+ starts after every earlier call in the response finishes, and later calls
891
+ wait for it. WebAssembly runs calls one at a time. Every call still goes
892
+ through its own permission check before any of them starts.
893
+
894
+ `execute` receives `{ signal, executionId, sessionId }`, plus `context` when
895
+ the turn has one. `executionId` is the model's id for the call and stays the same when
896
+ the call runs again after a crash, so a tool with an external effect can pass
897
+ it on as an idempotency key. [Tools with effects](#tools-with-effects)
898
+ describes which calls run again.
899
+ When a tool rejects with an empty message, the model receives a non-empty error
900
+ so the provider does not refuse the conversation.
901
+
368
902
  For ordinary tools, the JavaScript host is the authority for effects. The same
369
903
  descriptors, schemas, cancellation, results, and events are used by N-API and
370
904
  WebAssembly.
@@ -430,7 +964,7 @@ const mcp = await createMcpAdapter(client, {
430
964
  prompts: ["review"],
431
965
  });
432
966
 
433
- const agent = await createFxAgent({
967
+ const agent = createFxAgent({
434
968
  apiKey,
435
969
  model,
436
970
  tools: mcp.tools,
@@ -453,15 +987,15 @@ import { createSkillsAdapter } from "libfx/skills";
453
987
 
454
988
  const record = await loadSkillFile("./skills/review/SKILL.md");
455
989
  const skills = createSkillsAdapter([record]);
456
- const agent = await createFxAgent({ apiKey, model, ...skills });
990
+ const agent = createFxAgent({ apiKey, model, ...skills });
457
991
  ```
458
992
 
459
993
  ## Backends
460
994
 
461
995
  ```js
462
- await createFxAgent({ apiKey, backend: "auto" }); // native, then Wasm fallback
463
- await createFxAgent({ apiKey, backend: "native" }); // require N-API
464
- await createFxAgent({ apiKey, backend: "wasm" }); // require Wasm + JSPI
996
+ createFxAgent({ apiKey, backend: "auto" }); // native, then Wasm fallback
997
+ createFxAgent({ apiKey, backend: "native" }); // require N-API
998
+ createFxAgent({ apiKey, backend: "wasm" }); // require Wasm + JSPI
465
999
  ```
466
1000
 
467
1001
  CommonJS applications can load the same Node API with `require("libfx")`. The
@@ -540,28 +1074,75 @@ This native setup does not require JSPI. Explicit WebAssembly use still needs
540
1074
  JSPI and available Wasm assets; Next.js's standalone tracer excludes `.wasm`
541
1075
  files, so a standalone Wasm host must supply those assets separately.
542
1076
 
1077
+ Create one agent for the server and open a session per request. The route
1078
+ returns the turn's stream and never runs the agent itself:
1079
+
543
1080
  ```js
1081
+ // lib/agent.js
544
1082
  import { createFxAgent } from "libfx";
545
1083
 
546
- export const runtime = "nodejs";
1084
+ export const agent = createFxAgent({ model: "anthropic/claude-haiku-4.5", tools });
1085
+ ```
1086
+
1087
+ ```js
1088
+ // app/api/chat/route.js
1089
+ import { agent } from "@/lib/agent";
547
1090
 
548
1091
  export async function POST(request) {
549
- const { prompt } = await request.json();
550
- const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY });
551
- try {
552
- let text = "";
553
- const turn = agent.prompt(prompt, { signal: request.signal });
554
- for await (const event of turn) {
555
- if (event.type === "text_delta") text += event.delta;
1092
+ const sessionId = new URL(request.url).searchParams.get("sessionId") ?? undefined;
1093
+ // Check that the caller may use this session.
1094
+ const turn = agent.session(sessionId).prompt(await request.text());
1095
+ return new Response(turn.readable);
1096
+ }
1097
+
1098
+ // Reconnects from any server.
1099
+ export async function GET(request) {
1100
+ const params = new URL(request.url).searchParams;
1101
+ const sessionId = params.get("sessionId");
1102
+ if (!sessionId) return new Response("sessionId is required", { status: 400 });
1103
+ // The same check as POST.
1104
+ const stream = agent.session(sessionId).stream(Number(params.get("cursor") ?? 0));
1105
+ return new Response(stream);
1106
+ }
1107
+ ```
1108
+
1109
+ On your machine, the server runs each turn in its own process and keeps the
1110
+ session with `local()`. On Vercel, `prompt()` sends the prompt to Vercel Queues, and a separate
1111
+ invocation runs the turn while the `POST` response streams it. If the route's
1112
+ response ends first, the turn keeps running, and the client reconnects with
1113
+ the last `cursor` it read.
1114
+
1115
+ Until Vercel's World can deliver queue messages to the function itself, mount
1116
+ the agent's delivery route and subscribe it to libfx's queue topic in
1117
+ `vercel.json`:
1118
+
1119
+ ```js
1120
+ // app/api/libfx/route.js
1121
+ import { agent } from "@/lib/agent";
1122
+
1123
+ export const POST = agent.wakeHandler();
1124
+ ```
1125
+
1126
+ ```json
1127
+ {
1128
+ "functions": {
1129
+ "app/api/libfx/route.js": {
1130
+ "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
556
1131
  }
557
- await turn.result;
558
- return Response.json({ text });
559
- } finally {
560
- await agent.close();
561
1132
  }
562
1133
  }
563
1134
  ```
564
1135
 
1136
+ The delivery route's maximum duration bounds each invocation. libfx stops a
1137
+ turn 30 seconds before the deadline, at its next model request, and cuts off a
1138
+ call still running 2 seconds before it; either way the same queue message runs
1139
+ the rest of the turn in a new invocation. Pass
1140
+ `durability: vercel({ reserveMs })` from `libfx/durable-vercel` to change that
1141
+ margin. A function that dies mid-turn holds its session for at most
1142
+ `leaseMs` after its last renewal, 15 seconds unless you pass
1143
+ `vercel({ leaseMs })`. libfx authenticates to the AI Gateway with the deployment's OIDC token
1144
+ unless you pass `apiKey` or set `AI_GATEWAY_API_KEY`.
1145
+
565
1146
  Use the application's normal authentication and request limits around the
566
1147
  route. JavaScript tools and MCP clients remain host-owned and must be supplied
567
1148
  when creating an agent, including after checkpoint restoration. The native