libfx 0.0.13-dev.1432.g1df91f0b4ebf → 0.0.13-dev.1472.g2c7a4f83f06f

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,1220 +1,497 @@
1
1
  # libfx
2
2
 
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.
3
+ libfx is the agent from [fx](https://github.com/vercel-labs/fx), the open source coding agent from Vercel Labs, as a JavaScript library. You give it a model, instructions and your own tools, and it runs the conversation: the model loop, streaming, tool calls, retries, steering and images. It's the same Zig engine that runs fx in a terminal, built as a Node.js addon and as WebAssembly.
4
+
5
+ The hard part of running an agent on a server is that the server goes away. A function reaches its time limit in the middle of a reply, a deploy replaces the process, or it crashes during a tool call. libfx stores every prompt before it accepts it and every step of a turn as the turn runs, so the next process that picks up the session continues the turn where it stopped. On Vercel, that takes one route and one line of `vercel.json`.
7
6
 
8
7
  ```sh
9
8
  npm install libfx
10
9
  ```
11
10
 
12
- Node.js uses the native addon when available and falls back to WebAssembly.
13
- Browsers use WebAssembly with JSPI. The default package has no runtime
14
- dependencies and performs no MCP connection, skill scan, process spawn, or
15
- filesystem read when imported.
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
-
21
- ## Agent
22
-
23
11
  ```js
24
12
  import { createFxAgent } from "libfx";
25
13
 
26
14
  const agent = createFxAgent({
27
- model: "google/gemini-2.5-flash-lite",
15
+ model: "anthropic/claude-haiku-5.5",
28
16
  instructions: "Answer in one paragraph.",
29
17
  });
30
18
 
31
- const turn = agent.session().prompt("Explain this project.");
19
+ const turn = agent.session().prompt("What is an agent loop?");
32
20
 
33
21
  for await (const event of turn) {
34
22
  if (event.type === "text_delta") process.stdout.write(event.delta);
35
23
  }
36
24
 
37
- console.log(await turn.result); // { messageId, stopReason, usage }
38
25
  await agent.close();
39
26
  ```
40
27
 
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.
28
+ Set `AI_GATEWAY_API_KEY` and run it with Node.js 20 or newer. Model calls go through [AI Gateway](https://vercel.com/docs/ai-gateway), so any model it lists works, and on Vercel libfx uses the deployment's OIDC token instead of a key. libfx can also run on a user's own ChatGPT or Grok plan, or on another server through the OpenAI Chat Completions API. See [Providers](#providers).
47
29
 
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`.
30
+ Importing libfx and calling `createFxAgent()` touch no network, files or processes. The engine loads when a session runs its first turn.
53
31
 
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.
32
+ The full documentation is at [fx.sh/docs/lib](https://fx.sh/docs/lib).
59
33
 
60
- ### Sessions
34
+ ## How it fits together
61
35
 
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`:
36
+ | Object | What it is | How long it lives |
37
+ | --- | --- | --- |
38
+ | Agent | Configuration: model, instructions, tools, provider and where sessions live | One per server process |
39
+ | Session | One conversation | As long as its store keeps it |
40
+ | Turn | One prompt and everything the agent does to answer it | Until it ends with a stop reason |
66
41
 
67
- ```js
68
- const turn = agent.session().prompt("Plan the migration.");
69
- const { sessionId } = await turn.accepted;
42
+ An agent is configuration, and its conversations live in its sessions. Create it once per process with the same options on every server, and open a session for each conversation. Any process with that agent can run, continue or stream any of its sessions.
70
43
 
71
- // Later, in any process:
72
- const followUp = agent.session(sessionId).prompt("Start with the schema.");
73
- ```
44
+ Underneath the agent is `createFxEngine()`, a single conversation with no store, for when you don't want sessions. See [Without sessions](#without-sessions).
74
45
 
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 |
46
+ ## Sessions
178
47
 
179
48
  ```js
180
- import { createFxAgent, memory } from "libfx";
181
- import { local } from "libfx/durable-local";
49
+ const turn = agent.session().prompt("Plan the migration.");
50
+ const { sessionId } = await turn.accepted; // the prompt is stored
182
51
 
183
- createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
184
- createFxAgent({ durability: memory() }); // nothing survives the process
52
+ // Later, in any process that creates the same agent:
53
+ agent.session(sessionId).prompt("Start with the schema.");
185
54
  ```
186
55
 
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.
56
+ - **One turn at a time.** A prompt sent while a turn runs waits behind it, even when it reaches a different server.
57
+ - **Turns finish on their own.** A turn runs to the end whether or not anyone reads its events. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
58
+ - **Steering.** `session.steer(text)` adds guidance to the running turn at its next model request. When no turn is running, the text becomes the next turn.
59
+ - **Retries are safe.** Give a prompt a `messageId`, and a repeated request with the same id runs nothing new. It follows the original turn, and its result has `repeated: true`.
190
60
 
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`:
61
+ Each conversation can bring its own settings, which apply to the turns that session object starts:
193
62
 
194
63
  ```js
195
- import { createFxAgent } from "libfx";
196
- import { world } from "libfx/durable-world";
197
- import { createWorld } from "@workflow/world-postgres";
64
+ agent.session(sessionId, {
65
+ model: { id: "anthropic/claude-opus-5.5", effort: "high" },
66
+ instructions: "You review pull requests.",
67
+ context: { userId }, // handed to every tool call
68
+ });
69
+ ```
198
70
 
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();
71
+ A turn's result says how it ended:
202
72
 
203
- const agent = createFxAgent({ durability: world(sessions, { queueDurable: true }) });
73
+ ```js
74
+ const { stopReason, usage, error } = await turn.result;
204
75
  ```
205
76
 
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.
77
+ `stopReason` is `end_turn`, `max_output_tokens`, `max_model_turns`, `refused` or `cancelled`. It's `error` when the turn failed, with the reason in `error`, and `unknown` when the process running it stopped before recording how it ended.
214
78
 
215
- `world()` takes what libfx can't learn from the World itself:
79
+ ### Streaming to a browser
216
80
 
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:
81
+ `turn.readable` is the turn as newline-delimited JSON, ready to return from a route. `session.stream(cursor)` returns every line after `cursor` and stays open for new ones, so a client that loses its connection reconnects to any server and carries on.
317
82
 
318
83
  ```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
- ];
84
+ // app/api/chat/route.js
85
+ import { agent } from "@/lib/agent";
86
+
87
+ export async function POST(request) {
88
+ const sessionId = new URL(request.url).searchParams.get("sessionId") ?? undefined;
89
+ // Check that the caller may use this session.
90
+ const turn = agent.session(sessionId).prompt(await request.text());
91
+ return new Response(turn.readable);
92
+ }
93
+
94
+ export async function GET(request) {
95
+ const params = new URL(request.url).searchParams;
96
+ const sessionId = params.get("sessionId");
97
+ if (!sessionId) return new Response("sessionId is required", { status: 400 });
98
+ // Same check as POST.
99
+ return new Response(agent.session(sessionId).stream(Number(params.get("cursor") ?? 0)));
100
+ }
323
101
  ```
324
102
 
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.
103
+ Each line is one JSON object with a `type`: the model's events, such as `text_delta` and `tool_start`, and the turn's own, `turn_start` (which carries the `sessionId`), `turn_end`, `turn_resume` and `turn_yield`. Lines from `session.stream()` carry a `cursor`. Store the last one you read and pass it back to resume after it. The `FxStreamLine` type lists every field.
329
104
 
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.
105
+ ## Durability
334
106
 
335
- ## Model options
107
+ libfx stores a prompt before `turn.accepted` resolves, and stores each step of a turn while the turn runs. If the process running a turn dies, the next process that gets work for the session continues the turn from its last stored step. The model is told the turn was interrupted, so it can check what happened before going on.
336
108
 
337
- Model configuration groups the model ID and model-specific options:
109
+ Where sessions live depends on where your code runs. libfx picks for you:
338
110
 
339
- ```js
340
- const agent = createFxAgent({
341
- model: { id: "anthropic/claude-opus-5.5-fast", effort: "low", fast: true },
342
- });
343
- ```
111
+ | Where | Durability | Sessions live in |
112
+ | --- | --- | --- |
113
+ | Vercel | `vercel()` | Vercel's [Workflow World](https://workflow-sdk.dev/worlds), the store and queue behind Vercel Workflow |
114
+ | Node.js | `local()` | Files in `FX_SESSIONS_DIR`, or in `libfx/sessions` under the system's temporary directory |
115
+ | Browsers | `memory()` | The page's memory |
344
116
 
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.
350
-
351
- `model.effort` sets the reasoning effort for models that advertise effort
352
- levels. It uses the same vocabulary as the fx CLI's `--effort` flag:
353
- `"default"` leaves the choice to the model; named levels such as `"low"`,
354
- `"medium"`, `"high"`, or `"xhigh"` request a specific level. A named level is
355
- validated at creation; an unsupported level rejects with an Error carrying
356
- `code: "LIBFX_MODEL_UNSUPPORTED_EFFORT"`, `model`, and
357
- `capability: "effort"`. Its message names the supported set when available.
358
- Omitting effort or using `"default"` leaves the model default in place.
359
-
360
- `model.fast` enables the fast lane for models that advertise one, matching the
361
- fx CLI's `--fast` flag. A model without a fast path rejects at creation with
362
- `code: "LIBFX_MODEL_UNSUPPORTED_FAST"`, `model`, and `capability: "fast"`.
363
- Omitting fast or setting it to `false` leaves the model default in place.
364
-
365
- `model.ultrafast` requests Ultra mode. It is off by default and maps to
366
- `openai.serviceTier: "ultrafast"` through the Vercel AI Gateway for models
367
- whose metadata advertises Ultra eligibility. It uses the higher-cost service tier.
368
- Set it to `true` only after the host has selected an eligible model; an
369
- unsupported request rejects with `code: "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"`,
370
- `model`, and `capability: "ultrafast"`. Set it to `false` to explicitly disable
371
- an inherited request. Ultra and Fast are mutually exclusive, so `ultrafast:
372
- true` disables Fast for the agent. Gateway metadata currently marks Astra
373
- eligible; libfx does not select Ultra automatically.
374
-
375
- The new codes replace `LIBFX_UNSUPPORTED_EFFORT` and `LIBFX_UNSUPPORTED_FAST`
376
- for both nested and legacy top-level settings. Callers that check the old codes
377
- must update their error handling.
378
-
379
- The host selects the model. Agent creation does not fetch the Gateway model
380
- catalog unless effort requests a named level, fast is enabled, or ultrafast is enabled. Prompting
381
- can resolve model capabilities and context capacity through the supplied
382
- `fetch`; fx caches that metadata for the agent.
383
-
384
- `onEvent` receives runtime diagnostics separately from model output. Transport
385
- events report request start, response status and elapsed time, safe Gateway
386
- request metadata, failures, and throttled `transport.activity` liveness while a
387
- response body is streaming. Activity events include the attempt, current chunk
388
- bytes, and cumulative response bytes. They call the host directly at most once
389
- per 250 ms rather than entering the normalized turn queue, so an unread turn
390
- cannot accumulate heartbeat events. Credentials and raw headers are never
391
- included.
392
-
393
- libfx makes at most one automatic retry after a retryable transport failure and
394
- only before model output or tool effects escape. Cancellation prevents a retry.
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:
117
+ To choose yourself, pass `durability`:
404
118
 
405
119
  ```js
406
- import { createFxEngine } from "libfx";
120
+ import { createFxAgent, memory } from "libfx";
121
+ import { local } from "libfx/durable-local";
407
122
 
408
- const agent = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model });
123
+ createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
124
+ createFxAgent({ durability: memory() }); // nothing outlives the process
409
125
  ```
410
126
 
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.
127
+ On `local()`, `vercel()` and `world()`, a session id is a Workflow run id, `wrun_` followed by letters and digits. Take it from `turn.accepted` or `await agent.newSessionId()` instead of making one up. On `memory()`, any 1 to 128 letters, digits, `.`, `_` or `-` work.
429
128
 
430
- ### Prompt input
129
+ A session stores the conversation and nothing else. Every process passes its own model, credentials, tools, MCP clients and skills when it creates the agent. If a session's saved checkpoint came from a different libfx version, tool set or model, the session still loads, and `onEvent` gets a `checkpoint.mismatch` event naming what changed.
431
130
 
432
- `prompt(input, { signal? })` accepts a string or text, image, and resource
433
- blocks. It returns an async iterable of normalized events:
131
+ ### On Vercel
434
132
 
435
- - `text_delta`
436
- - `reasoning_delta` when supplied by the provider
437
- - `tool_start` with `id`, `name`, and the tool's `input` object. Inputs over
438
- 64 KiB of JSON arrive as an `inputPreview` string prefix plus
439
- `inputTruncated: true` instead; the tool still receives complete arguments.
440
- - `tool_end`
441
- - `user_message` with `text` when accepted mid-turn steering enters the turn
442
-
443
- Consume the turn while it runs, then await `turn.result`. Streamed text and
444
- tool results are lossless and backpressured: a slow reader pauses production
445
- instead of growing an unlimited event queue. Awaiting only `turn.result` can wait for an unread stream to drain.
446
- If you only need the result, explicitly discard events:
133
+ Add a route for libfx's queue deliveries:
447
134
 
448
135
  ```js
449
- const turn = agent.prompt("Update the index.");
450
- for await (const _ of turn) {}
451
- const result = await turn.result;
136
+ // app/api/libfx/route.js
137
+ import { agent } from "@/lib/agent";
138
+
139
+ export const POST = agent.wakeHandler();
452
140
  ```
453
141
 
454
- A turn has one event consumer. Breaking out of its iterator cancels the turn;
455
- `turn.cancel()` and `agent.close()` also release blocked output. Embedded agents
456
- have no implicit model-step cap, so hosts should cancel turns that exceed their
457
- own budgets. The CLI's `max_agent_steps` setting does not apply to
458
- `createFxEngine()`. Transport or message-decoding failures reject the result
459
- instead of returning success with missing text.
460
-
461
- Native transport buffers at most 8 MiB of output bytes. Unread SDK events apply
462
- backpressure at 1 MiB of encoded messages or 256 events. One message can exceed
463
- that threshold when the queue is empty; an individual encoded ACP message is
464
- limited to 64 MiB on both backends. These are transport bounds, not a total
465
- answer-size limit or a bound on retained conversation history.
466
-
467
- Image blocks accept a `Blob` or `File` with a non-empty `type`, raw bytes
468
- (`Uint8Array`, `Buffer`, `ArrayBuffer`, or another typed array) with an
469
- explicit `mimeType`, or canonical base64 (no line wrapping) with an explicit
470
- `mimeType`. The payload must be PNG, JPEG, GIF, or WebP. For a Blob, the MIME
471
- type is inferred from `Blob.type`; an empty type or a conflicting explicit
472
- `mimeType` rejects the input:
142
+ Then subscribe it to libfx's topic in `vercel.json`:
473
143
 
474
- ```js
475
- const turn = agent.prompt([
476
- { type: "text", text: "What does this screenshot show?" },
477
- { type: "image", data: file, sourceRef: "uploads:screenshot-1" }, // File or Blob, with file.type
478
- // Or: { type: "image", data: pngBytes, mimeType: "image/png" }
479
- // Or: { type: "image", data: base64Png, mimeType: "image/png" }
480
- ]);
144
+ ```json
145
+ {
146
+ "functions": {
147
+ "app/api/libfx/route.js": {
148
+ "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
149
+ }
150
+ }
151
+ }
481
152
  ```
482
153
 
483
- An optional `sourceRef` identifies a host-owned original. It must be a non-empty
484
- UTF-8 string of at most 512 bytes, without ASCII control characters (0–31 or
485
- DEL). With a reference, you may omit `data` entirely:
154
+ That's all. `prompt()` sends each turn to Vercel Queues, and this route runs it:
486
155
 
487
- ```js
488
- agent.prompt([
489
- { type: "image", mimeType: "image/png", sourceRef: "uploads:screenshot-1" },
490
- ]);
491
- ```
156
+ - When a turn gets within 30 seconds of the function's time limit, libfx stops it, and the next invocation continues it.
157
+ - The function running a turn renews a 15-second lease on its session. If the function dies, the session frees when the lease runs out, and the turn continues in a new invocation.
158
+ - A function that only stalled and wakes up after that is refused at its next write, so two processes never write one session.
492
159
 
493
- Image bytes reach the agent core beside the ACP prompt message rather than
494
- inside it. Base64 input is decoded before transfer; prompt image bytes are
495
- encoded as base64 only for the model request. A prompt may contain up to
496
- 8 images, including reference-only blocks. Each image may contain up to
497
- 3.75 MiB (3,932,160 bytes) of raw data, with at most 6 MiB of raw image data
498
- per prompt. Once encoded for the model request, those limits are 5 MiB per
499
- image and 8 MiB per prompt. The prompt's text, encoded images, and metadata
500
- must fit within the same 8 MiB frame budget.
501
-
502
- Without `resizeImage`, the SDK checks Blob size before reading it and the actual
503
- byte count after reading it. If referenced image data would exceed a per-image,
504
- aggregate image, or combined frame limit, the SDK sends its MIME type and
505
- reference without image data. Referenced Blobs known to exceed those limits
506
- remain unread and host-owned. A reference does not increase the limits;
507
- unreferenced oversized input rejects with typed `RangeError`s.
508
-
509
- Base64 that is not canonical throws a `TypeError` from `prompt()`. Raw bytes
510
- are copied before `prompt()` returns, so the caller can reuse its buffer. Blob
511
- reads are asynchronous: `prompt()` returns a turn, and read failures reject
512
- `turn.result`. Cancelling or closing during a Blob read settles the turn without
513
- sending its prompt. Without `resizeImage`, size errors for unreferenced base64
514
- and raw-byte input throw synchronously from `prompt()`.
515
-
516
- To downscale or convert prompt images before they are sent, pass the optional
517
- `resizeImage` hook when creating the agent. It receives `{ bytes, mimeType }`,
518
- where `bytes` is a `Uint8Array`, and returns `{ bytes, mimeType }` directly or
519
- as a promise. Returned `bytes` may be any typed array or `ArrayBuffer`.
520
- For a Node.js host that already uses `sharp`:
160
+ `vercel({ reserveMs, leaseMs })` from `libfx/durable-vercel` changes both timings.
521
161
 
522
- ```js
523
- import sharp from "sharp";
524
-
525
- const agent = await createFxEngine({
526
- apiKey,
527
- model,
528
- async resizeImage({ bytes }) {
529
- const png = await sharp(bytes).resize({ width: 1568, withoutEnlargement: true }).png().toBuffer();
530
- return { bytes: png, mimeType: "image/png" };
531
- },
532
- });
533
- ```
162
+ ### Tools that change things
534
163
 
535
- In a browser, return the `ArrayBuffer` from `Blob.arrayBuffer()`, including a
536
- Blob produced by `OffscreenCanvas.convertToBlob()`. The returned bytes are
537
- copied, so the hook may reuse its buffer. With `resizeImage`, image size limits
538
- apply to its output rather than its input; image count and frame metadata
539
- limits still apply. Image prompts with data are prepared asynchronously like
540
- a Blob prompt, and a failure inside the hook rejects `turn.result`. This
541
- opt-in preprocessing reads supplied Blob data even when its original size
542
- exceeds the limits. Reference-only blocks have no bytes and do not call the
543
- hook or fetch the original. The hook is host-provided, not a built-in image
544
- converter or a libfx runtime dependency.
545
-
546
- The kernel sniffs the final bytes and compares them with the claimed MIME type
547
- for every input form that carries data; a mismatch fails the turn with
548
- `Invalid image prompt block`. Eligible images reach the model unchanged unless
549
- your hook changes them. Images are routed only to models that advertise image
550
- input; for any other model the turn fails with
551
- `Image prompts are unavailable for the selected model` and no image bytes
552
- leave the process. Images outside the request's pixel or encoded-size limits,
553
- and reference-only originals, are withheld with model-visible recovery feedback
554
- that includes their `sourceRef` when supplied.
555
-
556
- A source reference is metadata, not an access grant or an automatic fetch.
557
- Your host owns the original's lifetime, reference resolution, and authorization.
558
- Expose preparation or retrieval through your existing tools' `execute()`
559
- callbacks if the agent needs a smaller copy. libfx has no built-in image resizer,
560
- shell, global converter, or source store, and a reference grants no native
561
- filesystem authority. If a tool is absent or fails, the original remains
562
- withheld rather than being sent anyway.
563
-
564
- For example, a tool can delegate to your app's authorized image store:
164
+ When a process dies during a tool call, libfx can't know whether the call finished. So each tool says whether it's safe to run twice:
565
165
 
566
166
  ```js
567
- const prepareImage = {
568
- name: "prepare_image",
569
- description: "Return a new smaller copy of a host-owned image. Use the request limit for maxSide.",
570
- inputSchema: {
571
- type: "object",
572
- properties: { sourceRef: { type: "string" }, maxSide: { type: "integer" } },
573
- required: ["sourceRef", "maxSide"],
167
+ const byId = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
168
+
169
+ const tools = [
170
+ {
171
+ name: "get_order",
172
+ description: "Look up an order.",
173
+ inputSchema: byId,
174
+ idempotent: true, // a lookup is harmless to repeat
175
+ execute: ({ id }) => orders.get(id),
574
176
  },
575
- async execute({ sourceRef, maxSide }, { signal }) {
576
- const copy = await imageStore.prepareCopy(sourceRef, { maxSide, signal });
577
- return {
578
- type: "libfx.tool-result",
579
- text: "Prepared a new copy; original unchanged.",
580
- images: [{ type: "image", data: copy.base64, mimeType: copy.mimeType, sourceRef }],
581
- };
177
+ {
178
+ name: "refund_order",
179
+ description: "Refund an order.",
180
+ inputSchema: byId,
181
+ execute: ({ id }, { executionId }) => payments.refund(id, { idempotencyKey: executionId }),
582
182
  },
583
- };
584
- // Supply prepareImage in createFxEngine({ tools: [prepareImage], ... }).
183
+ ];
585
184
  ```
586
185
 
587
- `imageStore` is application code, not a libfx API. It must authorize references,
588
- validate the requested dimensions, and stop conversion when `signal` aborts.
589
- The returned copy is checked again by the kernel before model submission.
590
- Typed host-tool images still use base64 `data`; `resizeImage` prepares prompt
591
- images only.
592
-
593
- Version 2 checkpoints retain prompt images as raw image blobs and preserve
594
- source refs within the existing 4 MiB checkpoint bound on both backends. A
595
- checkpoint does not store a source file or host-owned original for a
596
- reference-only block. On restoration, resupply your tools and restore the
597
- sources those refs identify.
598
-
599
- Only one top-level prompt may run at a time. While it runs,
600
- `await turn.steer(text)` appends guidance at the next safe model boundary
601
- without discarding the in-flight response or completed tool work. Steering also
602
- accepts an array of text blocks; image and resource steering blocks are rejected.
603
- Each message is limited to 64 KiB, with at most 64 queued messages and 1 MiB of
604
- queued steering text. Accepted steering appears as a `user_message` event before
605
- the model's continued output. For a Blob prompt or one prepared by
606
- `resizeImage`, steering during that preparation waits for the prompt to be
607
- sent; cancelling before then rejects the pending steering. Calling `steer()` after the turn settles rejects with
608
- `no prompt is running`.
186
+ When the turn continues, a call to an `idempotent: true` tool runs again. A call to any other tool never runs twice on its own: the model gets an error saying the call may have partly run, and decides whether to check, try again or ask the user.
609
187
 
610
- ```js
611
- const turn = agent.prompt("Build the feature.");
612
- for await (const event of turn) {
613
- if (event.type === "tool_end" && event.name === "read_file") {
614
- await turn.steer("Keep the public API backward compatible.");
615
- }
616
- }
617
- ```
188
+ Each call's `executionId` stays the same when it runs again. Pass it to the service your tool calls as an idempotency key, and a service that honors idempotency keys won't refund twice.
618
189
 
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
-
645
- Cancelling a steered turn drops any guidance that has not reached a safe
646
- boundary and releases its queue. Applied guidance is part of the same history
647
- turn, so an idle `checkpoint()` includes the full steered conversation.
648
-
649
- `checkpoint()` returns opaque, bounded, versioned bytes. Concurrent calls run
650
- one at a time, and a call still waiting for an earlier one fails the same way a
651
- direct call would if a prompt starts or the agent closes first. A newer libfx
652
- restores checkpoints from older versions, but an older libfx cannot restore one
653
- written by a newer version. Restore them only when creating a fresh agent:
190
+ ### Your own store
191
+
192
+ `world()` from `libfx/durable-world` keeps sessions in a [Workflow World](https://workflow-sdk.dev/worlds) you create, such as Workflow's Postgres World:
654
193
 
655
194
  ```js
656
- const restored = await createFxEngine({ apiKey, model, checkpoint });
657
- ```
195
+ import { createWorld } from "@workflow/world-postgres";
196
+ import { createFxAgent } from "libfx";
197
+ import { world } from "libfx/durable-world";
658
198
 
659
- An already-aborted prompt signal returns `cancelled` without a model request
660
- or a history change. The next prompt can run normally.
199
+ const sessions = createWorld({
200
+ connectionString: process.env.DATABASE_URL,
201
+ namespace: "libfx",
202
+ jobPrefix: "libfx_",
203
+ });
204
+ await sessions.start();
661
205
 
662
- The checkpoint contains conversation history and usage only. The host owns
663
- durable storage and must resupply models, credentials, instructions, tools,
664
- MCP clients, and skill records. Reasoning effort, Fast mode, and Ultra mode are
665
- agent-creation options and are not stored in a checkpoint: recreate the agent
666
- with new `model.effort`, `model.fast`, or `model.ultrafast` values to change
667
- them, the same path as switching models.
206
+ export const agent = createFxAgent({
207
+ durability: world(sessions, { name: "postgres", queueDurable: true }),
208
+ });
209
+ ```
668
210
 
669
- ### Persistence
211
+ A World that delivers queue messages over HTTP needs `agent.wakeHandler()` at the address it posts to. Workflow's Postgres World 5.0.2 posts every message to `/.well-known/workflow/v1/flow` on your app. `world()` takes options for what libfx can't learn from the World itself, such as how long a lease lasts and whether the queue keeps a message across crashes. The `WorldOptions` type documents each one.
670
212
 
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.
213
+ ## Tools
675
214
 
676
215
  ```js
677
- import { createFxEngine, createMemoryPersistence } from "libfx";
678
-
679
- const persistence = createMemoryPersistence();
680
- const agent = await createFxEngine({ apiKey, model, persistence, sessionId: "support-42" });
216
+ const agent = createFxAgent({
217
+ tools: [{
218
+ name: "get_order",
219
+ description: "Look up an order by id.",
220
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
221
+ idempotent: true,
222
+ async execute({ id }, { signal }) {
223
+ return orders.get(id, { signal });
224
+ },
225
+ }],
226
+ });
681
227
  ```
682
228
 
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
- }
229
+ `execute` receives the model's input and a context:
694
230
 
695
- interface JournalRecord {
696
- cursor: string;
697
- data: Uint8Array;
698
- }
699
- ```
231
+ | Field | What it is |
232
+ | --- | --- |
233
+ | `signal` | Aborts when the turn is cancelled |
234
+ | `executionId` | The call's id, the same if the call runs again after a crash |
235
+ | `sessionId` | The session the call belongs to |
236
+ | `context` | The JSON passed as `context` to `agent.session()` |
700
237
 
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:
238
+ Return a string to send it as is, or any other value to send it as JSON. A thrown error reaches the model as a failed tool result. To return images, return `{ type: "libfx.tool-result", text, images }`, with base64 image data.
764
239
 
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
- ```
240
+ On the native backend, the calls from one model response run at the same time, up to 16 at once. Mark a tool `writes: true` to keep its calls from overlapping any other. On WebAssembly, calls run one after another.
772
241
 
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.
242
+ Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
810
243
 
811
- ## Models
244
+ `tools` can also be an object keyed by tool name. Add `{ name: "web_search", providerExecuted: true }` for AI Gateway's web search, which runs at the provider instead of in your code.
812
245
 
813
- Model discovery is explicit and does not create an Agent or load native or Wasm
814
- artifacts:
246
+ ### MCP and skills
815
247
 
816
248
  ```js
817
- import { listModels } from "libfx";
249
+ import { createMcpAdapter } from "libfx/mcp";
250
+ import { createSkillsAdapter } from "libfx/skills";
251
+ import { loadSkillFile } from "libfx/skills/node";
252
+
253
+ const mcp = await createMcpAdapter(client, { prefix: "github_" }); // your MCP SDK client
254
+ const skills = createSkillsAdapter([await loadSkillFile("./skills/review/SKILL.md")]);
818
255
 
819
- const models = await listModels({
820
- apiKey: process.env.AI_GATEWAY_API_KEY,
256
+ const agent = createFxAgent({
257
+ tools: [...mcp.tools, ...skills.tools],
258
+ instructions: [mcp.instructions, skills.instructions].join("\n\n"),
821
259
  });
260
+
261
+ // When you're done:
262
+ await agent.close();
263
+ await mcp.close();
822
264
  ```
823
265
 
824
- `listModels()` performs one bounded Gateway request and returns sorted, unique
825
- language-model IDs. It accepts the same optional `fetch` override as the Agent
826
- API.
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:
266
+ libfx doesn't own the MCP connection. You create the client, handle its auth and close it after the agent. `loadSkillFile()` reads one `SKILL.md` and nothing it references.
267
+
268
+ ## Models
838
269
 
839
270
  ```js
840
- const agent = createFxAgent({ apiKey, model, modelCatalog });
271
+ createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "high", fast: true } });
841
272
  ```
842
273
 
843
- ## JavaScript tools and instructions
274
+ `effort` and `fast` mean the same as the fx CLI's `--effort` and `--fast` flags. `ultrafast: true` asks for the faster, more expensive service tier on models that offer it. A setting the model doesn't support fails with an error whose `code` names it, such as `LIBFX_MODEL_UNSUPPORTED_EFFORT`.
844
275
 
845
- ```js
846
- const agent = createFxAgent({
847
- apiKey,
848
- model,
849
- instructions: "Keep answers concise.",
850
- tools: [{
851
- name: "lookup",
852
- description: "Look up a value.",
853
- inputSchema: {
854
- type: "object",
855
- properties: { key: { type: "string" } },
856
- required: ["key"],
857
- },
858
- async execute(input, { signal }) {
859
- return database.get(input.key, { signal });
860
- },
861
- }],
862
- });
863
- ```
276
+ `listModels({ apiKey })` returns the ids of AI Gateway's language models. Behind a proxy that forwards only chat requests, pass the catalog yourself as `modelCatalog`, and the agent won't fetch it.
277
+
278
+ ## Providers
279
+
280
+ ### AI Gateway
864
281
 
865
- Gateway web search can run at the provider instead of in the JavaScript host.
866
- Mark its canonical tool name with `providerExecuted: true` and omit `execute`:
282
+ AI Gateway is the default. libfx uses the first credential it finds: the `apiKey` option, then `AI_GATEWAY_API_KEY`, then on Vercel the deployment's OIDC token.
283
+
284
+ ### ChatGPT plans
285
+
286
+ An agent can run on a user's ChatGPT plan through Codex. Set `provider: "codex"` and give libfx the user's credentials:
867
287
 
868
288
  ```js
869
289
  const agent = createFxAgent({
870
- apiKey,
871
- tools: [{ name: "web_search", providerExecuted: true }],
290
+ provider: "codex",
291
+ model: "gpt-6.1-sol",
292
+ credentials: ({ provider, sessionId }) => tokens.load(provider, sessionId),
293
+ onCredentials: (credentials, { provider, sessionId }) => tokens.save(provider, sessionId, credentials),
872
294
  });
873
295
  ```
874
296
 
875
- The kernel supplies the canonical schema and Gateway advertisement. Currently
876
- `web_search` is the supported provider-executed descriptor; unknown or local
877
- names reject agent creation. Provider-executed tools do not
878
- call host code or require a separate provider key; their `tool_start` and
879
- `tool_end` events, results, and checkpoint history use the same turn contract.
880
- Their built-in permission policy is enforced when the request is projected, as
881
- there is no local call-time effect to approve.
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
-
902
- For ordinary tools, the JavaScript host is the authority for effects. The same
903
- descriptors, schemas, cancellation, results, and events are used by N-API and
904
- WebAssembly.
905
- Cancelling a prompt aborts its tools' signals and stops waiting for their
906
- callbacks. Late results and rejections are ignored. Tools remain responsible
907
- for stopping their own work when their signal is aborted.
908
-
909
- A host tool may use any name, including the kernel's builtin names such as
910
- `write_file` and `edit_file`: the kernel routes by the registered executor, so
911
- a host-defined `write_file` calls the host's `execute()` rather than the
912
- builtin file mutation.
913
-
914
- Ordinary objects returned by tools are JSON text. To return rich image content,
915
- use the typed result:
297
+ libfx calls `credentials()` in whichever process runs the turn. It refreshes the credentials shortly before they expire and once after a rejected request, then passes the new ones to `onCredentials` for you to save. Codex doesn't accept requests from web pages, so these turns run on your server.
298
+
299
+ To sign a user in, use `libfx/chatgpt`:
916
300
 
917
301
  ```js
918
- return {
919
- type: "libfx.tool-result",
920
- text: "Original is available through the host image tools.",
921
- images: [
922
- { type: "image", mimeType: "image/png", sourceRef: "uploads:screenshot-1" },
923
- // A prepared copy may also include data: base64Png.
924
- ],
925
- };
926
- ```
302
+ import { startSignIn, finishSignIn } from "libfx/chatgpt";
927
303
 
928
- Tool images require `mimeType` and accept base64 `data`, or a `sourceRef` with no
929
- `data`. They do not accept raw bytes or Blobs and do not run `resizeImage`.
930
- References use the same validation, ownership, recovery feedback, and checkpoint
931
- rules as prompt images. A result may contain up to 8 images, each with up to
932
- 5 MiB of base64-encoded data. Its text, encoded images, and metadata must fit
933
- within the 8 MiB result/frame bound. Referenced data is omitted if those bounds
934
- would overflow; an unreferenced oversized result remains a tool error.
935
-
936
- Instructions are limited to 64 KiB of UTF-8 text, including text assembled by
937
- the MCP and skills adapters. They are the complete host-owned system context:
938
- libfx adds no hidden base prompt, and omitting `instructions` sends no system
939
- message.
940
-
941
- ## MCP
942
-
943
- `libfx/mcp` accepts a host-owned MCP client. Transport, authentication,
944
- elicitation, and cleanup remain outside the kernel. The client uses the MCP
945
- TypeScript SDK v1 signature: `callTool(params, resultSchema?, options?)`, with
946
- cancellation passed in `options`. Tool text and structured data
947
- reach the model together. PNG, JPEG, GIF, and WebP tool images reach models that
948
- advertise image input support; other models receive an explicit omission notice.
949
- Images are retained in checkpoints within the existing checkpoint size limit.
950
- Each image may contain up to 5 MiB of base64 data, with at most eight images and
951
- an 8 MiB result frame. Ordinary host tool objects remain JSON text. Resource and
952
- prompt options supply text instructions; non-text context has an omission notice.
953
- Tool catalogs are paginated up to the existing 64-tool bound. Tool names are
954
- normalized for model APIs, with collisions kept distinct and original names used
955
- for calls to the MCP client. Each tool description and JSON schema may contain up
956
- to 64 KiB, within the control message's 8 MiB limit.
304
+ // One request: send the user to signIn.url and keep signIn on the server.
305
+ const signIn = await startSignIn();
957
306
 
958
- ```js
959
- import { createMcpAdapter } from "libfx/mcp";
307
+ // A later request: the user pastes back the address OpenAI opened.
308
+ const credentials = await finishSignIn(signIn, pastedAddress);
309
+ ```
960
310
 
961
- const mcp = await createMcpAdapter(client, {
962
- prefix: "github_",
963
- resources: ["repo://instructions"],
964
- prompts: ["review"],
965
- });
311
+ After the user signs in, OpenAI opens a page at `localhost:1455` that doesn't load. That's expected: ask the user to copy the page's whole address and paste it back. If their account or ChatGPT workspace allows device code sign-in, `startDeviceSignIn()` returns a short code to enter on OpenAI's site instead, and `pollDeviceSignIn()` or `completeDeviceSignIn()` waits for it. Each step returns plain data, so a later request or function can finish it.
966
312
 
967
- const agent = createFxAgent({
968
- apiKey,
969
- model,
970
- tools: mcp.tools,
971
- instructions: mcp.instructions,
972
- });
313
+ The sign-in uses the same OpenAI client as the Codex CLI, and the user's ChatGPT plan and OpenAI's terms apply to the turns.
973
314
 
974
- // ...
975
- await agent.close();
976
- await mcp.close();
977
- ```
315
+ ### Grok plans
978
316
 
979
- ## Skills
317
+ `provider: "grok"` runs on a user's Grok plan, with the same `credentials` and `onCredentials` callbacks, called with `provider: "grok"`. libfx also checks that refreshed credentials still belong to the same xAI account. These turns run on your server too.
980
318
 
981
- Use `libfx/skills` for already-loaded records or `libfx/skills/node` to load a
982
- `SKILL.md` explicitly in Node or Bun.
319
+ To sign a user in, use `libfx/grok`. Device code is the simplest:
983
320
 
984
321
  ```js
985
- import { loadSkillFile } from "libfx/skills/node";
986
- import { createSkillsAdapter } from "libfx/skills";
322
+ import { startDeviceSignIn, completeDeviceSignIn } from "libfx/grok";
987
323
 
988
- const record = await loadSkillFile("./skills/review/SKILL.md");
989
- const skills = createSkillsAdapter([record]);
990
- const agent = createFxAgent({ apiKey, model, ...skills });
324
+ // Show device.userCode and a link to device.verificationUrl.
325
+ const device = await startDeviceSignIn();
326
+
327
+ // Waits until the user enters the code. pollDeviceSignIn() checks once, for a later request.
328
+ const credentials = await completeDeviceSignIn(device);
991
329
  ```
992
330
 
993
- ## Backends
331
+ `startSignIn()` and `finishSignIn()` work as for ChatGPT, except that xAI opens a page at `127.0.0.1`. The sign-in uses the same xAI client as the fx CLI, and the user's Grok plan and xAI's terms apply to the turns.
332
+
333
+ ### Several servers, one account
334
+
335
+ The sessions in one process share each refresh. Across servers, two can notice the same expiring credentials at once, and the provider accepts only the first refresh. Pass `refreshLock` so they take turns:
994
336
 
995
337
  ```js
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
338
+ refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
999
339
  ```
1000
340
 
1001
- CommonJS applications can load the same Node API with `require("libfx")`. The
1002
- package chooses its generated CommonJS entry automatically and keeps asset
1003
- paths relative to the installed package.
341
+ libfx calls it with a function to run while you hold a lock on that user's account, for example in Redis. Inside it, libfx calls `credentials()` again and uses what another server already saved.
342
+
343
+ ### Custom providers
1004
344
 
1005
- Use `getBackendInfo()` to inspect backend availability without creating an
1006
- Agent or terminal:
345
+ An agent can run on a server that speaks the OpenAI Chat Completions API, such as a local model server or a router:
1007
346
 
1008
347
  ```js
1009
- import { getBackendInfo } from "libfx";
1010
-
1011
- const info = await getBackendInfo({ surface: "agent", backend: "auto" });
1012
- // {
1013
- // surface: "agent",
1014
- // backend: "native" | "wasm-jspi" | "unavailable",
1015
- // attempts: [{ backend, available, reason }]
1016
- // }
348
+ const router = {
349
+ name: "router",
350
+ baseUrl: "https://openrouter.ai/api/v1",
351
+ models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
352
+ };
353
+
354
+ const agent = createFxAgent({
355
+ provider: router,
356
+ model: "qwen/qwen3-coder",
357
+ credentials: async ({ provider, sessionId }) => ({ apiKey: await keys.load(provider, sessionId) }),
358
+ });
1017
359
  ```
1018
360
 
1019
- `surface` is `agent` by default and may also be `terminal`. `backend` has the
1020
- same `auto`, `native`, and `wasm` selection as the factories. `nativeAddon` and
1021
- `wasm` select explicit assets with the same meanings as their factory options.
1022
- The probe loads and validates the native module or compiles the selected Wasm
1023
- asset, then stops. It does not create a core, open a runtime socket, read
1024
- credentials, start a model request, or write session state. A remote Wasm
1025
- source can perform its normal asset fetch.
361
+ Requests go to `{baseUrl}/chat/completions`, which must use HTTPS, or HTTP on `localhost`. Leave out `credentials` for a server that needs no key. `models` tells libfx what it can't ask the server: each model's context window and output limit, and whether it takes tools and images. A key isn't refreshed, so a rejected one ends the turn with the server's error. In a browser, the server must accept cross-origin requests, and the page can read the key.
1026
362
 
1027
- Expected environmental failures resolve as structured attempts. Invalid
1028
- options reject with `TypeError`. The stable reason codes are:
363
+ Saved sessions follow the agent's provider: point an agent at another base URL and its sessions continue there, history included.
1029
364
 
1030
- | Code | Meaning |
1031
- | --- | --- |
1032
- | `LIBFX_UNSUPPORTED_PLATFORM` | No packaged native addon supports the current platform and architecture. |
1033
- | `LIBFX_NATIVE_ARTIFACT_MISSING` | The selected native addon file is absent. |
1034
- | `LIBFX_NATIVE_LOAD_FAILED` | Node could not load the selected native addon. |
1035
- | `LIBFX_NATIVE_API_MISMATCH` | The addon API version is incompatible. |
1036
- | `LIBFX_NATIVE_SURFACE_MISSING` | The addon does not implement the selected Agent or terminal surface. |
1037
- | `LIBFX_NATIVE_DISABLED` | `nativeAddon: false` disabled native loading. |
1038
- | `LIBFX_JSPI_UNAVAILABLE` | The current JavaScript runtime does not provide JSPI. |
1039
- | `LIBFX_WASM_LOAD_FAILED` | The selected Wasm asset could not be loaded or compiled. |
1040
-
1041
- The optional `causeCode` field retains a Node error code such as `ENOENT` or
1042
- `ERR_DLOPEN_FAILED` when one exists. Probe success establishes backend loading
1043
- only; it does not validate credentials, a future Agent initialization, or a
1044
- model request.
1045
-
1046
- Within one loaded SDK module, libfx compiles each stable Wasm source once and
1047
- creates a separate WebAssembly instance for every Agent. Agent memory, history,
1048
- tools, cancellation, and shutdown remain isolated. Workers and separate
1049
- processes maintain their own module caches, as do the ESM and CommonJS entries.
1050
-
1051
- Node factories and `getBackendInfo()` also accept a `wasm` Promise resolving
1052
- to an HTTP(S) URL string, a `Response`, Wasm bytes, or a compiled
1053
- `WebAssembly.Module`. Asset resolver failures propagate from factories and
1054
- appear as `LIBFX_WASM_LOAD_FAILED` in diagnostics.
1055
-
1056
- Node.js 20+ is supported. Browser WebAssembly requires a JSPI-capable browser.
1057
- The Linux x64 and arm64 native addons require glibc 2.34 or newer. Native
1058
- agents do not require JSPI or experimental Node flags.
1059
- Some Node versions require `--experimental-wasm-jspi`.
1060
- Bun 1.4.2 is the tested recommendation for Bun's WebAssembly backend.
1061
- Bun 1.3.14 can crash when a hot WebAssembly loop resumes through JSPI during
1062
- JIT tier-up.
1063
-
1064
- ### Next.js and Vercel
1065
-
1066
- Create agents in a server route using the Node.js runtime. Import `libfx`
1067
- normally; the package includes its native assets and exposes both ESM and
1068
- CommonJS Node entrypoints. Native agents support Next.js 15 with webpack and
1069
- Next.js 16 with webpack or Turbopack, without `serverExternalPackages` or manual
1070
- native-file inclusion. Webpack's emitted assets are resolved relative to the
1071
- server bundle, including standalone builds with a custom `distDir` or `assetPrefix`.
1072
-
1073
- This native setup does not require JSPI. Explicit WebAssembly use still needs
1074
- JSPI and available Wasm assets; Next.js's standalone tracer excludes `.wasm`
1075
- files, so a standalone Wasm host must supply those assets separately.
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:
365
+ ### Where credentials go
1079
366
 
1080
- ```js
1081
- // lib/agent.js
1082
- import { createFxAgent } from "libfx";
367
+ libfx adds ChatGPT, Grok and custom-provider credentials to each request in JavaScript, just before it's sent, and sends them only to that provider. They never reach the engine. No credential is written to a saved session or passed to `onEvent`, and if a server quotes a key or token back in an error, libfx masks it before the error reaches the turn.
368
+
369
+ ## Images
1083
370
 
1084
- export const agent = createFxAgent({ model: "anthropic/claude-haiku-4.5", tools });
371
+ ```js
372
+ agent.session().prompt([
373
+ { type: "text", text: "What's wrong with this chart?" },
374
+ { type: "image", mimeType: "image/png", data: base64Png },
375
+ ]);
1085
376
  ```
1086
377
 
378
+ A session stores each prompt as JSON before it runs, so a session prompt takes image data as a base64 string. `createFxEngine()` also takes bytes, a `Blob` or a `File`. Each prompt takes up to 8 PNG, JPEG, GIF or WebP images, at most 3.75 MiB each and 6 MiB together. Pass `resizeImage` to downscale or convert prompt images before they're sent.
379
+
380
+ ## Diagnostics
381
+
382
+ `onEvent` receives diagnostics apart from the model's output: request timing and retries, and session events such as `session.deadline` when a turn stops for a function's time limit. The `FxAgentEvent` type lists them all.
383
+
384
+ ## Without sessions
385
+
386
+ `createFxEngine()` is the engine under the agent: one conversation in the memory of one process, with no queue and no session store. Use it for a single request, a subagent inside a tool call, or a conversation you store yourself.
387
+
1087
388
  ```js
1088
- // app/api/chat/route.js
1089
- import { agent } from "@/lib/agent";
389
+ import { createFxEngine } from "libfx";
1090
390
 
1091
- export async function POST(request) {
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
- }
391
+ const engine = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5" });
1097
392
 
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);
393
+ const turn = engine.prompt("Summarize this file.");
394
+ for await (const event of turn) {
395
+ if (event.type === "text_delta") process.stdout.write(event.delta);
1106
396
  }
397
+
398
+ const checkpoint = await engine.checkpoint(); // opaque bytes, up to 4 MiB
399
+ await engine.close();
400
+
401
+ const restored = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5", checkpoint });
1107
402
  ```
1108
403
 
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.
404
+ An engine differs from a session:
1114
405
 
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`:
406
+ - On AI Gateway, it needs an `apiKey`.
407
+ - One prompt runs at a time, and breaking out of a turn's events cancels the turn.
408
+ - The turn itself has `cancel()` and `steer()`.
1118
409
 
1119
- ```js
1120
- // app/api/libfx/route.js
1121
- import { agent } from "@/lib/agent";
410
+ To have the engine record the conversation as it runs, pass a `persistence` store instead of a checkpoint. A new engine on the same store can then `resume()` a turn the last process left open. A store needs two methods and can add a third, and the bytes are opaque, so it never has to understand them:
1122
411
 
1123
- export const POST = agent.wakeHandler();
1124
- ```
412
+ ```ts
413
+ interface Persistence {
414
+ load(): Promise<{
415
+ checkpoint?: { data: Uint8Array; through: string };
416
+ journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
417
+ }>;
418
+ append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array }): Promise<{ cursor: string }>;
419
+ saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
420
+ }
1125
421
 
1126
- ```json
1127
- {
1128
- "functions": {
1129
- "app/api/libfx/route.js": {
1130
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
1131
- }
1132
- }
422
+ interface JournalRecord {
423
+ cursor: string;
424
+ data: Uint8Array;
1133
425
  }
1134
426
  ```
1135
427
 
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`.
428
+ Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation. `createMemoryPersistence()` is a reference store for tests. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`.
429
+
430
+ ## Runtimes
431
+
432
+ | Runtime | Backend |
433
+ | --- | --- |
434
+ | Node.js 20+ on macOS or Linux, x64 or arm64 | Native addon. Linux needs glibc 2.34 or newer. |
435
+ | Node.js elsewhere | WebAssembly with JSPI. Some Node versions need `--experimental-wasm-jspi`. |
436
+ | Browsers | WebAssembly with JSPI, from `libfx/browser` |
437
+ | Bun | The same as Node.js. For WebAssembly, use Bun 1.4.2 or newer. |
1145
438
 
1146
- Use the application's normal authentication and request limits around the
1147
- route. JavaScript tools and MCP clients remain host-owned and must be supplied
1148
- when creating an agent, including after checkpoint restoration. The native
1149
- backend does not enable the CLI's built-in shell or filesystem tools.
439
+ The default `backend: "auto"` tries the native addon first. Pass `"native"` or `"wasm"` to require one, and call `getBackendInfo()` to see what would load without creating anything.
1150
440
 
1151
- ## Interactive terminal
441
+ Next.js works in Node.js route handlers, with webpack or Turbopack, and doesn't need `serverExternalPackages`. CommonJS apps can `require("libfx")`. With TypeScript's `moduleResolution: "bundler"`, add `"node"` to `customConditions` to get the Node.js types.
1152
442
 
1153
- `createFxTerminal()` remains a separate terminal harness API. In browsers,
1154
- connect it to xterm.js with `xtermAdapter()`:
443
+ ## The fx terminal in a browser
1155
444
 
1156
445
  ```js
1157
446
  import { createFxTerminal, xtermAdapter } from "libfx/browser";
1158
447
 
1159
- const runtime = await createFxTerminal({
1160
- terminal: xtermAdapter(term),
1161
- env: { AI_GATEWAY_API_KEY: "<short-lived credential>" },
448
+ const terminal = await createFxTerminal({
449
+ terminal: xtermAdapter(term), // an xterm.js Terminal
450
+ env: { AI_GATEWAY_API_KEY: shortLivedKey },
1162
451
  });
1163
452
 
1164
- await runtime.interactive;
453
+ await terminal.interactive;
1165
454
  ```
1166
455
 
1167
- Pass `args: ["--fast"]` to request Fast mode. The terminal loads model
1168
- capabilities when its first Fast or Ultra turn starts, without adding catalog
1169
- requests to startup. After the catalog loads, `/fast` toggles Fast for later
1170
- turns. Models without Fast support use their normal routing.
1171
-
1172
- The xterm adapter preserves browser-style composer editing for Shift+Enter,
1173
- Command+A, Command+C, Command+X, Command+Z, and Command+Shift+Z. Shift+Enter
1174
- inserts a newline without submitting. A click inside the visible composer moves
1175
- its caret; pointer drags remain xterm terminal-output selections.
1176
- When xterm already has an output selection, Command+C copies that selection
1177
- instead of the composer selection.
1178
-
1179
- The terminal runtime exposes `interactive`, `exited`, `write`, `resize`, and
1180
- `abort`. Terminal session, config, OAuth, prompt-history, clipboard, URL, and
1181
- workspace stores remain terminal-only host integrations. Clipboard copy writes
1182
- through the host `clipboard.writeText(text)` adapter and defaults to
1183
- `navigator.clipboard`.
1184
-
1185
- A terminal with a `workspace` adapter loads AGENTS.md project instructions from
1186
- it the way the CLI reads them from disk: `<home>/.fx/AGENTS.md`,
1187
- `<root>/AGENTS.md`, and, when `root` is below `home`, the `AGENTS.md` files in
1188
- the directories between them. Provide them through an optional `readFile`
1189
- method:
456
+ This is the fx CLI's interface compiled to WebAssembly. Your page supplies storage for its sessions, settings and sign-in, and can give it a workspace that runs commands. See [Terminal](https://fx.sh/docs/lib/terminal).
1190
457
 
1191
- ```js
1192
- const workspace = {
1193
- info: { version: 1, root: "/workspace", cwd: "/workspace", home: "/home/visitor", gitAvailable: false, ephemeral: true },
1194
- permission: "allow-sandboxed",
1195
- exec({ command, cwd, signal, timeoutMs, outputLimitBytes }) { /* run one command */ },
1196
- async readFile({ path, signal }) {
1197
- // Return a string, UTF-8 bytes (a Uint8Array or ArrayBuffer), or null when the file does not exist.
1198
- },
1199
- };
1200
- ```
458
+ ## Entry points
459
+
460
+ | Import | What's in it |
461
+ | --- | --- |
462
+ | `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` |
463
+ | `libfx/node`, `libfx/browser` | The same, for Node.js or browsers explicitly |
464
+ | `libfx/wasm` | The WebAssembly SDK without packaged asset paths, so you pass `wasm` yourself |
465
+ | `libfx/durable-local`, `libfx/durable-vercel`, `libfx/durable-world` | `local()`, `vercel()` and `world()` |
466
+ | `libfx/chatgpt`, `libfx/grok` | Sign-in for ChatGPT and Grok plans, and `refreshCredentials` |
467
+ | `libfx/mcp` | `createMcpAdapter` |
468
+ | `libfx/skills`, `libfx/skills/node` | `createSkillsAdapter`, and `loadSkillFile` for Node.js and Bun |
1201
469
 
1202
- fx calls `readFile` only for absolute `AGENTS.md` paths inside `root` or
1203
- `home`. Each read has a 10-second deadline, the interrupt key aborts it through
1204
- `signal`, and the CLI's project instruction size limits apply. Without
1205
- `readFile`, fx tells the model that the workspace's project instructions were
1206
- omitted and records the omission in the full transcript.
470
+ Every entry point ships TypeScript types, and the package has no runtime dependencies.
1207
471
 
1208
- During `/compact` and automatic compaction, the terminal shows a live
1209
- `Compacting` activity row with elapsed time. Input and cancellation remain
1210
- responsive while the summary request is pending. Compaction progress and
1211
- outcomes do not add transcript entries, including cancellation after resume.
1212
- Stored snapshots retain cancellation-origin metadata; keep them opaque and
1213
- resume with the same or a newer SDK build. Older snapshots remain readable.
472
+ ## For coding agents
473
+
474
+ - **Docs as Markdown:** [fx.sh/llms.txt](https://fx.sh/llms.txt) indexes every page, and each page has a `.md` version, such as `https://fx.sh/docs/lib/durability.md`. The declarations in `types/` are the exact API.
475
+ - **One agent per process:** create it once at module scope, not per request, and open `agent.session(id)` for each conversation.
476
+ - **Session ids:** take them from `turn.accepted` or `agent.newSessionId()`. On every durability except `memory()`, an id that isn't a Workflow run id throws.
477
+ - **Side effects:** set `idempotent: true` only on tools that are safe to run twice, and pass `executionId` to services that take an idempotency key.
478
+ - **Deployments:** on Vercel, the `wakeHandler()` route and its `vercel.json` trigger are both required, or prompts are stored and never run.
479
+ - **Credentials:** keep keys and ChatGPT or Grok credentials on the server, and the object `startSignIn()` returns too.
480
+ - **Checking a setup:** `getBackendInfo()` reports which backend would load without creating anything or making requests.
481
+ - **Changing libfx:** the source is in the [fx repository](https://github.com/vercel-labs/fx) under `sdk/`. Its `AGENTS.md` files name the tests each change needs.
482
+
483
+ ## Upgrading from 0.0.13
484
+
485
+ In libfx 0.0.13 and earlier, `createFxAgent()` created the engine. It now creates the durable agent, so rename those calls to `createFxEngine()`. Old code keeps running against the agent, with a few differences: breaking out of a turn's events no longer cancels it, turns have no `cancel()` or `steer()` (the session does), and `persistence` and `sessionId` throw because the durability holds the session.
1214
486
 
1215
487
  ## Security
1216
488
 
1217
- Treat `nativeAddon` and `gatewayChatUrl` as trusted host
1218
- configuration. Do not embed long-lived credentials in public browser code.
1219
- Host tool functions, MCP clients, and skill loaders retain their own authority;
1220
- libfx validates and sequences them but does not grant operating-system access.
489
+ Your tools, MCP clients and skill loaders have whatever access your code gives them. libfx orders their calls but grants nothing itself, and the native backend doesn't turn on fx's built-in shell or file tools. Don't put long-lived keys in browser code, and treat the `nativeAddon` and `gatewayChatUrl` options as trusted configuration.
490
+
491
+ ## Contributing
492
+
493
+ libfx is developed in the [fx repository](https://github.com/vercel-labs/fx), under `sdk/`. Bug reports and pull requests are welcome. See [CONTRIBUTING.md](https://github.com/vercel-labs/fx/blob/main/CONTRIBUTING.md) to get started.
494
+
495
+ ## License
496
+
497
+ [Apache-2.0](https://github.com/vercel-labs/fx/blob/main/LICENSE)