libfx 0.0.13 → 0.0.14

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,611 +1,579 @@
1
1
  # libfx
2
2
 
3
- `libfx` is the small fx agent kernel for JavaScript hosts. One agent is one
4
- in-memory conversation with `prompt`, `checkpoint`, and `close` operations,
5
- plus mid-turn steering on each running turn.
3
+ libfx is the 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`.
6
6
 
7
7
  ```sh
8
8
  npm install libfx
9
9
  ```
10
10
 
11
- Node.js uses the native addon when available and falls back to WebAssembly.
12
- Browsers use WebAssembly with JSPI. The default package has no runtime
13
- dependencies and performs no MCP connection, skill scan, process spawn, or
14
- filesystem read when imported.
15
-
16
- ## Agent
17
-
18
11
  ```js
19
12
  import { createFxAgent } from "libfx";
20
13
 
21
- const agent = await createFxAgent({
22
- apiKey: process.env.AI_GATEWAY_API_KEY,
23
- model: "google/gemini-2.5-flash-lite",
24
- onEvent(event) {
25
- if (event.type === "transport.response") console.log(event.elapsedMs);
26
- },
14
+ const agent = createFxAgent({
15
+ model: "anthropic/claude-haiku-5.5",
16
+ instructions: "Answer in one paragraph.",
27
17
  });
28
18
 
29
- const turn = agent.prompt("Explain this project.");
19
+ const turn = agent.session().prompt("What is an agent loop?");
30
20
 
31
21
  for await (const event of turn) {
32
22
  if (event.type === "text_delta") process.stdout.write(event.delta);
33
23
  }
34
24
 
35
- console.log(await turn.result); // { stopReason, usage }
36
- const checkpoint = await agent.checkpoint();
37
25
  await agent.close();
38
26
  ```
39
27
 
40
- `apiKey` is required. `model` is optional and defaults to fx's built-in model.
41
- Agent configuration uses named options; `env` is reserved for
42
- `createFxTerminal()`. The canonical model configuration groups the model ID
43
- and model-specific options:
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).
29
+
30
+ Importing libfx and calling `createFxAgent()` touch no network, files or processes. The engine loads when a session runs its first turn.
31
+
32
+ The full documentation is at [fx.sh/docs/lib](https://fx.sh/docs/lib).
33
+
34
+ ## How it fits together
35
+
36
+ | Object | What it is | How long it lives |
37
+ | --- | --- | --- |
38
+ | Agent | Configuration: model, instructions, tools, provider and where sessions live | As long as the 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 |
41
+
42
+ An agent is configuration, and its conversations live in its sessions. Create each agent 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. An app can have [several agents](#several-agents), each with its own name.
43
+
44
+ Underneath the agent is `createFxEngine()`, a single conversation with no store, for when you don't want sessions. See [Without sessions](#without-sessions).
45
+
46
+ ## Sessions
44
47
 
45
48
  ```js
46
- const agent = await createFxAgent({
47
- apiKey,
48
- model: { id: "anthropic/claude-opus-5.5-fast", effort: "low", fast: true },
49
- });
49
+ const turn = agent.session().prompt("Plan the migration.");
50
+ const { sessionId } = await turn.accepted; // the prompt is stored
51
+
52
+ // Later, in any process that creates the same agent:
53
+ agent.session(sessionId).prompt("Start with the schema.");
50
54
  ```
51
55
 
52
- A string `model` remains supported as shorthand. Top-level `effort` and
53
- `fast` are deprecated but remain supported with a string model or no model;
54
- they cannot be mixed with a model object. New code should use the model object.
55
-
56
- `model.effort` sets the reasoning effort for models that advertise effort
57
- levels. It uses the same vocabulary as the fx CLI's `--effort` flag:
58
- `"default"` leaves the choice to the model; named levels such as `"low"`,
59
- `"medium"`, `"high"`, or `"xhigh"` request a specific level. A named level is
60
- validated at creation; an unsupported level rejects with an Error carrying
61
- `code: "LIBFX_MODEL_UNSUPPORTED_EFFORT"`, `model`, and
62
- `capability: "effort"`. Its message names the supported set when available.
63
- Omitting effort or using `"default"` leaves the model default in place.
64
-
65
- `model.fast` enables the fast lane for models that advertise one, matching the
66
- fx CLI's `--fast` flag. A model without a fast path rejects at creation with
67
- `code: "LIBFX_MODEL_UNSUPPORTED_FAST"`, `model`, and `capability: "fast"`.
68
- Omitting fast or setting it to `false` leaves the model default in place.
69
-
70
- `model.ultrafast` requests Ultra mode. It is off by default and maps to
71
- `openai.serviceTier: "ultrafast"` through the Vercel AI Gateway for models
72
- whose metadata advertises Ultra eligibility. It uses the higher-cost service tier.
73
- Set it to `true` only after the host has selected an eligible model; an
74
- unsupported request rejects with `code: "LIBFX_MODEL_UNSUPPORTED_ULTRAFAST"`,
75
- `model`, and `capability: "ultrafast"`. Set it to `false` to explicitly disable
76
- an inherited request. Ultra and Fast are mutually exclusive, so `ultrafast:
77
- true` disables Fast for the agent. Gateway metadata currently marks Astra
78
- eligible; libfx does not select Ultra automatically.
79
-
80
- The new codes replace `LIBFX_UNSUPPORTED_EFFORT` and `LIBFX_UNSUPPORTED_FAST`
81
- for both nested and legacy top-level settings. Callers that check the old codes
82
- must update their error handling.
83
-
84
- The host selects the model. Agent creation does not fetch the Gateway model
85
- catalog unless effort requests a named level, fast is enabled, or ultrafast is enabled. Prompting
86
- can resolve model capabilities and context capacity through the supplied
87
- `fetch`; fx caches that metadata for the agent.
88
-
89
- `onEvent` receives runtime diagnostics separately from model output. Transport
90
- events report request start, response status and elapsed time, safe Gateway
91
- request metadata, failures, and throttled `transport.activity` liveness while a
92
- response body is streaming. Activity events include the attempt, current chunk
93
- bytes, and cumulative response bytes. They call the host directly at most once
94
- per 250 ms rather than entering the normalized turn queue, so an unread turn
95
- cannot accumulate heartbeat events. Credentials and raw headers are never
96
- included.
97
-
98
- libfx makes at most one automatic retry after a retryable transport failure and
99
- only before model output or tool effects escape. Cancellation prevents a retry.
100
-
101
- `prompt(input, { signal? })` accepts a string or text, image, and resource
102
- blocks. It returns an async iterable of normalized events:
103
-
104
- - `text_delta`
105
- - `reasoning_delta` when supplied by the provider
106
- - `tool_start` with `id`, `name`, and the tool's `input` object. Inputs over
107
- 64 KiB of JSON arrive as an `inputPreview` string prefix plus
108
- `inputTruncated: true` instead; the tool still receives complete arguments.
109
- - `tool_end`
110
- - `user_message` with `text` when accepted mid-turn steering enters the turn
111
-
112
- Consume the turn while it runs, then await `turn.result`. Streamed text and
113
- tool results are lossless and backpressured: a slow reader pauses production
114
- instead of growing an unlimited event queue. Awaiting only `turn.result` can wait for an unread stream to drain.
115
- If you only need the result, explicitly discard events:
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, and a turn nobody reads holds no connection open: its view starts reading only once you use its events, `readable` or `result`. Stop it with `session.cancel()`, or with the `signal` you passed to `prompt()`.
58
+ - **Steering.** `session.steer(input)` steers the running turn the way the fx CLI does. While the model streams a reply, the steer cuts it short and the turn continues with it. While a tool runs, the steer waits for the tool. A steer sent while a turn waits to start joins that turn, and when no turn runs or waits, the steer becomes the next turn. A steer with images can't join a turn, so the running turn ends at its next safe point and the steer runs next.
59
+ - **Retries are safe.** Give a prompt or a steer a `messageId`, and a repeated request with the same id runs nothing new. A repeated prompt follows the original turn, and its result has `repeated: true`. One id names one input, so a steer that reuses a prompt's id is rejected with `FX_MESSAGE_ID_IN_USE`. A steer's `user_message` event carries its id as `id` where it joined a turn.
60
+ - **Inputs have a size limit.** A prompt or steer whose input, with the session's `context` and settings, comes to more than 8 MiB of JSON is rejected with `FX_INPUT_TOO_LARGE` before anything is stored. The model can't take a larger one.
61
+
62
+ Each conversation can bring its own settings, which apply to the turns that session object starts:
116
63
 
117
64
  ```js
118
- const turn = agent.prompt("Update the index.");
119
- for await (const _ of turn) {}
120
- const result = await turn.result;
65
+ agent.session(sessionId, {
66
+ model: { id: "anthropic/claude-opus-5.5", effort: "high" },
67
+ instructions: "You review pull requests.",
68
+ context: { userId }, // handed to every tool call
69
+ });
121
70
  ```
122
71
 
123
- A turn has one event consumer. Breaking out of its iterator cancels the turn;
124
- `turn.cancel()` and `agent.close()` also release blocked output. Embedded agents
125
- have no implicit model-step cap, so hosts should cancel turns that exceed their
126
- own budgets. The CLI's `max_agent_steps` setting does not apply to
127
- `createFxAgent()`. Transport or message-decoding failures reject the result
128
- instead of returning success with missing text.
129
-
130
- Native transport buffers at most 8 MiB of output bytes. Unread SDK events apply
131
- backpressure at 1 MiB of encoded messages or 256 events. One message can exceed
132
- that threshold when the queue is empty; an individual encoded ACP message is
133
- limited to 64 MiB on both backends. These are transport bounds, not a total
134
- answer-size limit or a bound on retained conversation history.
135
-
136
- Image blocks accept a `Blob` or `File` with a non-empty `type`, raw bytes
137
- (`Uint8Array`, `Buffer`, `ArrayBuffer`, or another typed array) with an
138
- explicit `mimeType`, or canonical base64 (no line wrapping) with an explicit
139
- `mimeType`. The payload must be PNG, JPEG, GIF, or WebP. For a Blob, the MIME
140
- type is inferred from `Blob.type`; an empty type or a conflicting explicit
141
- `mimeType` rejects the input:
72
+ A turn's result says how it ended:
142
73
 
143
74
  ```js
144
- const turn = agent.prompt([
145
- { type: "text", text: "What does this screenshot show?" },
146
- { type: "image", data: file, sourceRef: "uploads:screenshot-1" }, // File or Blob, with file.type
147
- // Or: { type: "image", data: pngBytes, mimeType: "image/png" }
148
- // Or: { type: "image", data: base64Png, mimeType: "image/png" }
149
- ]);
75
+ const { stopReason, usage, error } = await turn.result;
150
76
  ```
151
77
 
152
- An optional `sourceRef` identifies a host-owned original. It must be a non-empty
153
- UTF-8 string of at most 512 bytes, without ASCII control characters (0–31 or
154
- DEL). With a reference, you may omit `data` entirely:
78
+ `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.
79
+
80
+ ### Streaming to a browser
81
+
82
+ `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.
155
83
 
156
84
  ```js
157
- agent.prompt([
158
- { type: "image", mimeType: "image/png", sourceRef: "uploads:screenshot-1" },
159
- ]);
85
+ // app/api/chat/route.js
86
+ import { agent } from "@/lib/agent";
87
+
88
+ export async function POST(request) {
89
+ const sessionId = new URL(request.url).searchParams.get("sessionId") ?? undefined;
90
+ // Check that the caller may use this session.
91
+ const turn = agent.session(sessionId).prompt(await request.text());
92
+ return new Response(turn.readable);
93
+ }
94
+
95
+ export async function GET(request) {
96
+ const params = new URL(request.url).searchParams;
97
+ const sessionId = params.get("sessionId");
98
+ if (!sessionId) return new Response("sessionId is required", { status: 400 });
99
+ // Same check as POST.
100
+ return new Response(agent.session(sessionId).stream(Number(params.get("cursor") ?? 0)));
101
+ }
160
102
  ```
161
103
 
162
- Image bytes reach the agent core beside the ACP prompt message rather than
163
- inside it. Base64 input is decoded before transfer; prompt image bytes are
164
- encoded as base64 only for the model request. A prompt may contain up to
165
- 8 images, including reference-only blocks. Each image may contain up to
166
- 3.75 MiB (3,932,160 bytes) of raw data, with at most 6 MiB of raw image data
167
- per prompt. Once encoded for the model request, those limits are 5 MiB per
168
- image and 8 MiB per prompt. The prompt's text, encoded images, and metadata
169
- must fit within the same 8 MiB frame budget.
170
-
171
- Without `resizeImage`, the SDK checks Blob size before reading it and the actual
172
- byte count after reading it. If referenced image data would exceed a per-image,
173
- aggregate image, or combined frame limit, the SDK sends its MIME type and
174
- reference without image data. Referenced Blobs known to exceed those limits
175
- remain unread and host-owned. A reference does not increase the limits;
176
- unreferenced oversized input rejects with typed `RangeError`s.
177
-
178
- Base64 that is not canonical throws a `TypeError` from `prompt()`. Raw bytes
179
- are copied before `prompt()` returns, so the caller can reuse its buffer. Blob
180
- reads are asynchronous: `prompt()` returns a turn, and read failures reject
181
- `turn.result`. Cancelling or closing during a Blob read settles the turn without
182
- sending its prompt. Without `resizeImage`, size errors for unreferenced base64
183
- and raw-byte input throw synchronously from `prompt()`.
184
-
185
- To downscale or convert prompt images before they are sent, pass the optional
186
- `resizeImage` hook when creating the agent. It receives `{ bytes, mimeType }`,
187
- where `bytes` is a `Uint8Array`, and returns `{ bytes, mimeType }` directly or
188
- as a promise. Returned `bytes` may be any typed array or `ArrayBuffer`.
189
- For a Node.js host that already uses `sharp`:
104
+ 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.
105
+
106
+ ## Durability
107
+
108
+ 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.
109
+
110
+ Where sessions live depends on where your code runs. libfx picks for you:
111
+
112
+ | Where | Durability | Sessions live in |
113
+ | --- | --- | --- |
114
+ | Vercel | `vercel()` | Vercel's [Workflow World](https://workflow-sdk.dev/worlds), the store and queue behind Vercel Workflow |
115
+ | Node.js | `local()` | Files in `FX_SESSIONS_DIR`, or in `libfx/sessions` under the system's temporary directory |
116
+ | Browsers | `memory()` | The page's memory |
117
+
118
+ To choose yourself, pass `durability`:
190
119
 
191
120
  ```js
192
- import sharp from "sharp";
193
-
194
- const agent = await createFxAgent({
195
- apiKey,
196
- model,
197
- async resizeImage({ bytes }) {
198
- const png = await sharp(bytes).resize({ width: 1568, withoutEnlargement: true }).png().toBuffer();
199
- return { bytes: png, mimeType: "image/png" };
200
- },
201
- });
121
+ import { createFxAgent, memory } from "libfx";
122
+ import { local } from "libfx/durable-local";
123
+
124
+ createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
125
+ createFxAgent({ durability: memory() }); // nothing outlives the process
202
126
  ```
203
127
 
204
- In a browser, return the `ArrayBuffer` from `Blob.arrayBuffer()`, including a
205
- Blob produced by `OffscreenCanvas.convertToBlob()`. The returned bytes are
206
- copied, so the hook may reuse its buffer. With `resizeImage`, image size limits
207
- apply to its output rather than its input; image count and frame metadata
208
- limits still apply. Image prompts with data are prepared asynchronously like
209
- a Blob prompt, and a failure inside the hook rejects `turn.result`. This
210
- opt-in preprocessing reads supplied Blob data even when its original size
211
- exceeds the limits. Reference-only blocks have no bytes and do not call the
212
- hook or fetch the original. The hook is host-provided, not a built-in image
213
- converter or a libfx runtime dependency.
214
-
215
- The kernel sniffs the final bytes and compares them with the claimed MIME type
216
- for every input form that carries data; a mismatch fails the turn with
217
- `Invalid image prompt block`. Eligible images reach the model unchanged unless
218
- your hook changes them. Images are routed only to models that advertise image
219
- input; for any other model the turn fails with
220
- `Image prompts are unavailable for the selected model` and no image bytes
221
- leave the process. Images outside the request's pixel or encoded-size limits,
222
- and reference-only originals, are withheld with model-visible recovery feedback
223
- that includes their `sourceRef` when supplied.
224
-
225
- A source reference is metadata, not an access grant or an automatic fetch.
226
- Your host owns the original's lifetime, reference resolution, and authorization.
227
- Expose preparation or retrieval through your existing tools' `execute()`
228
- callbacks if the agent needs a smaller copy. libfx has no built-in image resizer,
229
- shell, global converter, or source store, and a reference grants no native
230
- filesystem authority. If a tool is absent or fails, the original remains
231
- withheld rather than being sent anyway.
232
-
233
- For example, a tool can delegate to your app's authorized image store:
128
+ 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.
129
+
130
+ A session stores the conversation, and with each prompt and steer the caller's `context` and the session object's settings. Every process passes its own model, credentials, tools, MCP clients and skills when it creates the agent.
131
+
132
+ libfx doesn't know who is calling. Anyone with a session's id can prompt, steer, cancel, stream or checkpoint it, so your app must check that the caller may use a session before it opens it. A queue message only names an input libfx already stored: a delivery that names one the session's log doesn't hold runs nothing, and the agent's `onEvent` gets a `session.refused` event. 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.
133
+
134
+ ### Several agents
135
+
136
+ An app can have more than one durable agent, each with its own model, instructions and tools. Give each one a name:
234
137
 
235
138
  ```js
236
- const prepareImage = {
237
- name: "prepare_image",
238
- description: "Return a new smaller copy of a host-owned image. Use the request limit for maxSide.",
239
- inputSchema: {
240
- type: "object",
241
- properties: { sourceRef: { type: "string" }, maxSide: { type: "integer" } },
242
- required: ["sourceRef", "maxSide"],
243
- },
244
- async execute({ sourceRef, maxSide }, { signal }) {
245
- const copy = await imageStore.prepareCopy(sourceRef, { maxSide, signal });
246
- return {
247
- type: "libfx.tool-result",
248
- text: "Prepared a new copy; original unchanged.",
249
- images: [{ type: "image", data: copy.base64, mimeType: copy.mimeType, sourceRef }],
250
- };
251
- },
252
- };
253
- // Supply prepareImage in createFxAgent({ tools: [prepareImage], ... }).
139
+ export const support = createFxAgent({ name: "support", instructions: "Answer billing questions." });
140
+ export const coding = createFxAgent({ name: "coding", model: "anthropic/claude-opus-5.5", tools: codingTools });
254
141
  ```
255
142
 
256
- `imageStore` is application code, not a libfx API. It must authorize references,
257
- validate the requested dimensions, and stop conversion when `signal` aborts.
258
- The returned copy is checked again by the kernel before model submission.
259
- Typed host-tool images still use base64 `data`; `resizeImage` prepares prompt
260
- images only.
261
-
262
- Version 2 checkpoints retain prompt images as raw image blobs and preserve
263
- source refs within the existing 4 MiB checkpoint bound on both backends. A
264
- checkpoint does not store a source file or host-owned original for a
265
- reference-only block. On restoration, resupply your tools and restore the
266
- sources those refs identify.
267
-
268
- Only one top-level prompt may run at a time. While it runs,
269
- `await turn.steer(text)` appends guidance at the next safe model boundary
270
- without discarding the in-flight response or completed tool work. Steering also
271
- accepts an array of text blocks; image and resource steering blocks are rejected.
272
- Each message is limited to 64 KiB, with at most 64 queued messages and 1 MiB of
273
- queued steering text. Accepted steering appears as a `user_message` event before
274
- the model's continued output. For a Blob prompt or one prepared by
275
- `resizeImage`, steering during that preparation waits for the prompt to be
276
- sent; cancelling before then rejects the pending steering. Calling `steer()` after the turn settles rejects with
277
- `no prompt is running`.
143
+ A session belongs to the agent that created it, and its turns run only on an agent with that name. A prompt sent through another agent, such as `coding.session(supportSessionId).prompt()`, still runs on `support`, and the sending agent's `onEvent` gets a `session.forwarded` event. An agent created without a name is named `default`.
144
+
145
+ - Agents with the same name are copies of one agent, such as one on each server, and take turns running its sessions. In one process, every agent with a name on a store must have the same configuration: creating one with different options throws `FX_AGENT_NAME_IN_USE`. Agents created without a name or a durability share the name `default` and the default store, so an app that serves several customers gives each customer's agent its own name. Create an agent once, at module scope, or close it when you're done with it.
146
+ - Keep names stable. A session keeps the name it was created with, so renaming an agent leaves its sessions with no agent to run them.
147
+ - A process that gets a turn for a name it has no agent for refuses it with an `FX_NO_AGENT` error naming the agent, and the queue delivers it again.
148
+ - Agents share sessions through their durability. Give agents that share sessions the same durability, or none: agents created without one share the default.
149
+ - One route takes the deliveries for every World in the process and hands each to the agent of its name on the store it names. A World's store is its `store` option, or its name. An app that serves several customers gives each customer a store of its own, with `world()`'s `store`, and never shares one store between customers who must not reach each other's sessions.
150
+
151
+ ### On Vercel
152
+
153
+ Add a route for libfx's queue deliveries:
278
154
 
279
155
  ```js
280
- const turn = agent.prompt("Build the feature.");
281
- for await (const event of turn) {
282
- if (event.type === "tool_end" && event.name === "read_file") {
283
- await turn.steer("Keep the public API backward compatible.");
156
+ // app/api/libfx/route.js
157
+ import { wakeHandler } from "libfx";
158
+ import "@/lib/agents"; // creates the app's agents, so this function has them
159
+
160
+ export const POST = wakeHandler();
161
+ ```
162
+
163
+ The route delivers each turn to the agent it belongs to, so one route serves every agent the module it imports creates. With one agent, `agent.wakeHandler()` does the same.
164
+
165
+ Then subscribe it to libfx's topic in `vercel.json`:
166
+
167
+ ```json
168
+ {
169
+ "functions": {
170
+ "app/api/libfx/route.js": {
171
+ "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session", "retryAfterSeconds": 5 }]
172
+ }
284
173
  }
285
174
  }
286
175
  ```
287
176
 
288
- Cancelling a steered turn drops any guidance that has not reached a safe
289
- boundary and releases its queue. Applied guidance is part of the same history
290
- turn, so an idle `checkpoint()` includes the full steered conversation.
291
- `checkpoint()` returns opaque, bounded, versioned bytes. Concurrent calls run
292
- one at a time, and a call still waiting for an earlier one fails the same way a
293
- direct call would if a prompt starts or the agent closes first. A newer libfx
294
- restores checkpoints from older versions, but an older libfx cannot restore one
295
- written by a newer version. Restore them only when creating a fresh agent:
177
+ The trigger makes the route private: it has no public URL, and only Vercel Queues can call it. Keep `wakeHandler()` on that route only. An app whose own World queues to a route the internet can reach must check that each request comes from its queue before it calls `wakeHandler()`. libfx runs only inputs its sessions already stored, but a forged request can still wake a session.
178
+
179
+ That's all. When no other invocation holds the session, `prompt()` runs the turn in the request that called it, so the first model request goes out without a queue hop, and the function stays alive for the turn after the route responds. Vercel Queues keeps the prompt until after that request's time limit, and if the request dies mid-turn, that delivery continues the turn. Other turns go through Vercel Queues to this route:
180
+
181
+ - When a turn gets within 30 seconds of the function's time limit, libfx stops it, and the next invocation continues it. `vercel({ reserveMs })` from `libfx/durable-vercel` changes that margin.
182
+ - A function holds its session for as long as Vercel Queues keeps the turn's message for it, so nothing is written while a turn runs to show the function is alive. If the function's process exits mid-turn, Vercel runs the same delivery again about a second later, and that invocation continues the turn.
183
+ - A function that stops without its process exiting holds the message until the queue's hold on it runs out, up to five minutes. A prompt sent to the session meanwhile continues the turn at that function's time limit, if that comes sooner. `vercel({ leaseMs: 15_000 })` bounds that case: the function renews a 15-second lease on its session every 5 seconds, and the session frees about 15 seconds after the last renewal. With it, every prompt goes through the queue instead of running in the request.
184
+ - A function that only stalled and wakes up after that is refused at its next write, so two processes never write one session.
185
+
186
+ ### Tools that change things
187
+
188
+ 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:
296
189
 
297
190
  ```js
298
- const restored = await createFxAgent({ apiKey, model, checkpoint });
191
+ const byId = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
192
+
193
+ const tools = [
194
+ {
195
+ name: "get_order",
196
+ description: "Look up an order.",
197
+ inputSchema: byId,
198
+ idempotent: true, // a lookup is harmless to repeat
199
+ execute: ({ id }) => orders.get(id),
200
+ },
201
+ {
202
+ name: "refund_order",
203
+ description: "Refund an order.",
204
+ inputSchema: byId,
205
+ idempotent: true, // keyed on executionId, so running it again refunds once
206
+ writes: true,
207
+ execute: ({ id }, { executionId }) => payments.refund(id, { idempotencyKey: executionId }),
208
+ },
209
+ ];
299
210
  ```
300
211
 
301
- An already-aborted prompt signal returns `cancelled` without a model request
302
- or a history change. The next prompt can run normally.
212
+ 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. The turn's events end the call with that error too, as a `tool_end` with `isError: true`. libfx refuses a tool that doesn't set `idempotent`, so each one makes that choice. A call that finished before the process died keeps its result, whichever kind of tool it is: libfx stores each result as the call finishes.
303
213
 
304
- The checkpoint contains conversation history and usage only. The host owns
305
- durable storage and must resupply models, credentials, instructions, tools,
306
- MCP clients, and skill records. Reasoning effort, Fast mode, and Ultra mode are
307
- agent-creation options and are not stored in a checkpoint: recreate the agent
308
- with new `model.effort`, `model.fast`, or `model.ultrafast` values to change
309
- them, the same path as switching models.
214
+ 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. That makes the tool safe to mark `idempotent: true` with `writes: true`.
310
215
 
311
- ## Models
216
+ A tool marked `idempotent: true` without `writes: true` starts as soon as the model asks for it, while libfx stores the call. If the process dies before the call is stored, the model asks again and the call gets a new `executionId`, which is harmless for a lookup. Every other tool starts only once its call is stored, so its `executionId` never changes.
217
+
218
+ ### Your own store
312
219
 
313
- Model discovery is explicit and does not create an Agent or load native or Wasm
314
- artifacts:
220
+ `world()` from `libfx/durable-world` keeps sessions in a [Workflow World](https://workflow-sdk.dev/worlds) you create, such as Workflow's Postgres World:
315
221
 
316
222
  ```js
317
- import { listModels } from "libfx";
223
+ import { createWorld } from "@workflow/world-postgres";
224
+ import { createFxAgent } from "libfx";
225
+ import { world } from "libfx/durable-world";
318
226
 
319
- const models = await listModels({
320
- apiKey: process.env.AI_GATEWAY_API_KEY,
227
+ const sessions = createWorld({
228
+ connectionString: process.env.DATABASE_URL,
229
+ namespace: "libfx",
230
+ jobPrefix: "libfx_",
231
+ });
232
+ await sessions.start();
233
+
234
+ export const agent = createFxAgent({
235
+ durability: world(sessions, { name: "postgres", queueDurable: true }),
321
236
  });
322
237
  ```
323
238
 
324
- `listModels()` performs one bounded Gateway request and returns sorted, unique
325
- language-model IDs. It accepts the same optional `fetch` override as the Agent
326
- API.
239
+ A World that delivers queue messages over HTTP needs `agent.wakeHandler()`, or `wakeHandler()` for several agents, 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.
240
+
241
+ On a World with Workflow's in-band writer fence, libfx writes each claim and record so that the World itself refuses a write from a worker another has replaced, and nothing of it is stored. libfx finds the fence in the World's capabilities, so there's nothing to configure. On a World without it, libfx detects the replaced worker after its write instead, and the session's log ignores what that worker wrote.
327
242
 
328
- ## JavaScript tools and instructions
243
+ ## Tools
329
244
 
330
245
  ```js
331
- const agent = await createFxAgent({
332
- apiKey,
333
- model,
334
- instructions: "Keep answers concise.",
246
+ const agent = createFxAgent({
335
247
  tools: [{
336
- name: "lookup",
337
- description: "Look up a value.",
338
- inputSchema: {
339
- type: "object",
340
- properties: { key: { type: "string" } },
341
- required: ["key"],
342
- },
343
- async execute(input, { signal }) {
344
- return database.get(input.key, { signal });
248
+ name: "get_order",
249
+ description: "Look up an order by id.",
250
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
251
+ idempotent: true,
252
+ async execute({ id }, { signal }) {
253
+ return orders.get(id, { signal });
345
254
  },
346
255
  }],
347
256
  });
348
257
  ```
349
258
 
350
- Gateway web search can run at the provider instead of in the JavaScript host.
351
- Mark its canonical tool name with `providerExecuted: true` and omit `execute`:
259
+ `execute` receives the model's input and a context:
260
+
261
+ | Field | What it is |
262
+ | --- | --- |
263
+ | `signal` | Aborts when the turn is cancelled |
264
+ | `executionId` | The call's id, the same if the call runs again after a crash |
265
+ | `sessionId` | The session the call belongs to |
266
+ | `context` | A copy of the JSON passed as `context` to `agent.session()`, taken when you call it |
267
+
268
+ 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.
269
+
270
+ Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
271
+
272
+ On the native backend, calls to idempotent tools from one model response run at the same time, up to 16 at once. A tool that isn't idempotent runs alone, and so does one marked `writes: true`. Mark a tool `writes: false` to let it overlap with others anyway. On WebAssembly, calls run one after another.
273
+
274
+ `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.
275
+
276
+ ### MCP and skills
352
277
 
353
278
  ```js
354
- const agent = await createFxAgent({
355
- apiKey,
356
- tools: [{ name: "web_search", providerExecuted: true }],
279
+ import { createMcpAdapter } from "libfx/mcp";
280
+ import { createSkillsAdapter } from "libfx/skills";
281
+ import { loadSkillFile } from "libfx/skills/node";
282
+
283
+ const mcp = await createMcpAdapter(client, { prefix: "github_" }); // your MCP SDK client
284
+ const skills = createSkillsAdapter([await loadSkillFile("./skills/review/SKILL.md")]);
285
+
286
+ const agent = createFxAgent({
287
+ tools: [...mcp.tools, ...skills.tools],
288
+ instructions: [mcp.instructions, skills.instructions].join("\n\n"),
357
289
  });
290
+
291
+ // When you're done:
292
+ await agent.close();
293
+ await mcp.close();
358
294
  ```
359
295
 
360
- The kernel supplies the canonical schema and Gateway advertisement. Currently
361
- `web_search` is the supported provider-executed descriptor; unknown or local
362
- names reject agent creation. Provider-executed tools do not
363
- call host code or require a separate provider key; their `tool_start` and
364
- `tool_end` events, results, and checkpoint history use the same turn contract.
365
- Their built-in permission policy is enforced when the request is projected, as
366
- there is no local call-time effect to approve.
367
-
368
- For ordinary tools, the JavaScript host is the authority for effects. The same
369
- descriptors, schemas, cancellation, results, and events are used by N-API and
370
- WebAssembly.
371
- Cancelling a prompt aborts its tools' signals and stops waiting for their
372
- callbacks. Late results and rejections are ignored. Tools remain responsible
373
- for stopping their own work when their signal is aborted.
374
-
375
- A host tool may use any name, including the kernel's builtin names such as
376
- `write_file` and `edit_file`: the kernel routes by the registered executor, so
377
- a host-defined `write_file` calls the host's `execute()` rather than the
378
- builtin file mutation.
379
-
380
- Ordinary objects returned by tools are JSON text. To return rich image content,
381
- use the typed result:
296
+ 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.
297
+
298
+ Every MCP tool comes with `idempotent: false`, since a server's hints can't be trusted. Set it to `true` on a tool you know is safe to run twice. A skill's tools set `idempotent` themselves.
299
+
300
+ ## Models
382
301
 
383
302
  ```js
384
- return {
385
- type: "libfx.tool-result",
386
- text: "Original is available through the host image tools.",
387
- images: [
388
- { type: "image", mimeType: "image/png", sourceRef: "uploads:screenshot-1" },
389
- // A prepared copy may also include data: base64Png.
390
- ],
391
- };
303
+ createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "high", fast: true } });
392
304
  ```
393
305
 
394
- Tool images require `mimeType` and accept base64 `data`, or a `sourceRef` with no
395
- `data`. They do not accept raw bytes or Blobs and do not run `resizeImage`.
396
- References use the same validation, ownership, recovery feedback, and checkpoint
397
- rules as prompt images. A result may contain up to 8 images, each with up to
398
- 5 MiB of base64-encoded data. Its text, encoded images, and metadata must fit
399
- within the 8 MiB result/frame bound. Referenced data is omitted if those bounds
400
- would overflow; an unreferenced oversized result remains a tool error.
401
-
402
- Instructions are limited to 64 KiB of UTF-8 text, including text assembled by
403
- the MCP and skills adapters. They are the complete host-owned system context:
404
- libfx adds no hidden base prompt, and omitting `instructions` sends no system
405
- message.
406
-
407
- ## MCP
408
-
409
- `libfx/mcp` accepts a host-owned MCP client. Transport, authentication,
410
- elicitation, and cleanup remain outside the kernel. The client uses the MCP
411
- TypeScript SDK v1 signature: `callTool(params, resultSchema?, options?)`, with
412
- cancellation passed in `options`. Tool text and structured data
413
- reach the model together. PNG, JPEG, GIF, and WebP tool images reach models that
414
- advertise image input support; other models receive an explicit omission notice.
415
- Images are retained in checkpoints within the existing checkpoint size limit.
416
- Each image may contain up to 5 MiB of base64 data, with at most eight images and
417
- an 8 MiB result frame. Ordinary host tool objects remain JSON text. Resource and
418
- prompt options supply text instructions; non-text context has an omission notice.
419
- Tool catalogs are paginated up to the existing 64-tool bound. Tool names are
420
- normalized for model APIs, with collisions kept distinct and original names used
421
- for calls to the MCP client. Each tool description and JSON schema may contain up
422
- to 64 KiB, within the control message's 8 MiB limit.
306
+ `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`.
423
307
 
424
- ```js
425
- import { createMcpAdapter } from "libfx/mcp";
308
+ `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.
426
309
 
427
- const mcp = await createMcpAdapter(client, {
428
- prefix: "github_",
429
- resources: ["repo://instructions"],
430
- prompts: ["review"],
431
- });
310
+ ## Providers
311
+
312
+ ### AI Gateway
313
+
314
+ 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.
315
+
316
+ ### ChatGPT plans
317
+
318
+ An agent can run on a user's ChatGPT plan through Codex. Set `provider: "codex"` and give libfx the user's credentials:
432
319
 
433
- const agent = await createFxAgent({
434
- apiKey,
435
- model,
436
- tools: mcp.tools,
437
- instructions: mcp.instructions,
320
+ ```js
321
+ const agent = createFxAgent({
322
+ provider: "codex",
323
+ model: "gpt-6.1-sol",
324
+ credentials: ({ provider, sessionId }) => tokens.load(provider, sessionId),
325
+ onCredentials: (credentials, { provider, sessionId }) => tokens.save(provider, sessionId, credentials),
438
326
  });
327
+ ```
439
328
 
440
- // ...
441
- await agent.close();
442
- await mcp.close();
329
+ 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.
330
+
331
+ To sign a user in, use `libfx/chatgpt`:
332
+
333
+ ```js
334
+ import { startSignIn, finishSignIn } from "libfx/chatgpt";
335
+
336
+ // One request: send the user to signIn.url and keep signIn on the server.
337
+ const signIn = await startSignIn();
338
+
339
+ // A later request: the user pastes back the address OpenAI opened.
340
+ const credentials = await finishSignIn(signIn, pastedAddress);
443
341
  ```
444
342
 
445
- ## Skills
343
+ 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.
344
+
345
+ 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.
346
+
347
+ ### Grok plans
446
348
 
447
- Use `libfx/skills` for already-loaded records or `libfx/skills/node` to load a
448
- `SKILL.md` explicitly in Node or Bun.
349
+ `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.
350
+
351
+ To sign a user in, use `libfx/grok`. Device code is the simplest:
449
352
 
450
353
  ```js
451
- import { loadSkillFile } from "libfx/skills/node";
452
- import { createSkillsAdapter } from "libfx/skills";
354
+ import { startDeviceSignIn, completeDeviceSignIn } from "libfx/grok";
453
355
 
454
- const record = await loadSkillFile("./skills/review/SKILL.md");
455
- const skills = createSkillsAdapter([record]);
456
- const agent = await createFxAgent({ apiKey, model, ...skills });
356
+ // Show device.userCode and a link to device.verificationUrl.
357
+ const device = await startDeviceSignIn();
358
+
359
+ // Waits until the user enters the code. pollDeviceSignIn() checks once, for a later request.
360
+ const credentials = await completeDeviceSignIn(device);
457
361
  ```
458
362
 
459
- ## Backends
363
+ `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.
364
+
365
+ ### Several servers, one account
366
+
367
+ 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:
460
368
 
461
369
  ```js
462
- await createFxAgent({ apiKey, backend: "auto" }); // native, then Wasm fallback
463
- await createFxAgent({ apiKey, backend: "native" }); // require N-API
464
- await createFxAgent({ apiKey, backend: "wasm" }); // require Wasm + JSPI
370
+ refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
465
371
  ```
466
372
 
467
- CommonJS applications can load the same Node API with `require("libfx")`. The
468
- package chooses its generated CommonJS entry automatically and keeps asset
469
- paths relative to the installed package.
373
+ 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.
470
374
 
471
- Use `getBackendInfo()` to inspect backend availability without creating an
472
- Agent or terminal:
375
+ ### Custom providers
376
+
377
+ An agent can run on a server that speaks the OpenAI Chat Completions API, such as a local model server or a router:
473
378
 
474
379
  ```js
475
- import { getBackendInfo } from "libfx";
476
-
477
- const info = await getBackendInfo({ surface: "agent", backend: "auto" });
478
- // {
479
- // surface: "agent",
480
- // backend: "native" | "wasm-jspi" | "unavailable",
481
- // attempts: [{ backend, available, reason }]
482
- // }
380
+ const router = {
381
+ name: "router",
382
+ baseUrl: "https://openrouter.ai/api/v1",
383
+ models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
384
+ };
385
+
386
+ const agent = createFxAgent({
387
+ provider: router,
388
+ model: "qwen/qwen3-coder",
389
+ credentials: async ({ provider, sessionId }) => ({ apiKey: await keys.load(provider, sessionId) }),
390
+ });
483
391
  ```
484
392
 
485
- `surface` is `agent` by default and may also be `terminal`. `backend` has the
486
- same `auto`, `native`, and `wasm` selection as the factories. `nativeAddon` and
487
- `wasm` select explicit assets with the same meanings as their factory options.
488
- The probe loads and validates the native module or compiles the selected Wasm
489
- asset, then stops. It does not create a core, open a runtime socket, read
490
- credentials, start a model request, or write session state. A remote Wasm
491
- source can perform its normal asset fetch.
393
+ 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.
492
394
 
493
- Expected environmental failures resolve as structured attempts. Invalid
494
- options reject with `TypeError`. The stable reason codes are:
395
+ Saved sessions follow the agent's provider: point an agent at another base URL and its sessions continue there, history included.
495
396
 
496
- | Code | Meaning |
497
- | --- | --- |
498
- | `LIBFX_UNSUPPORTED_PLATFORM` | No packaged native addon supports the current platform and architecture. |
499
- | `LIBFX_NATIVE_ARTIFACT_MISSING` | The selected native addon file is absent. |
500
- | `LIBFX_NATIVE_LOAD_FAILED` | Node could not load the selected native addon. |
501
- | `LIBFX_NATIVE_API_MISMATCH` | The addon API version is incompatible. |
502
- | `LIBFX_NATIVE_SURFACE_MISSING` | The addon does not implement the selected Agent or terminal surface. |
503
- | `LIBFX_NATIVE_DISABLED` | `nativeAddon: false` disabled native loading. |
504
- | `LIBFX_JSPI_UNAVAILABLE` | The current JavaScript runtime does not provide JSPI. |
505
- | `LIBFX_WASM_LOAD_FAILED` | The selected Wasm asset could not be loaded or compiled. |
506
-
507
- The optional `causeCode` field retains a Node error code such as `ENOENT` or
508
- `ERR_DLOPEN_FAILED` when one exists. Probe success establishes backend loading
509
- only; it does not validate credentials, a future Agent initialization, or a
510
- model request.
511
-
512
- Within one loaded SDK module, libfx compiles each stable Wasm source once and
513
- creates a separate WebAssembly instance for every Agent. Agent memory, history,
514
- tools, cancellation, and shutdown remain isolated. Workers and separate
515
- processes maintain their own module caches, as do the ESM and CommonJS entries.
516
-
517
- Node factories and `getBackendInfo()` also accept a `wasm` Promise resolving
518
- to an HTTP(S) URL string, a `Response`, Wasm bytes, or a compiled
519
- `WebAssembly.Module`. Asset resolver failures propagate from factories and
520
- appear as `LIBFX_WASM_LOAD_FAILED` in diagnostics.
521
-
522
- Node.js 20+ is supported. Browser WebAssembly requires a JSPI-capable browser.
523
- The Linux x64 and arm64 native addons require glibc 2.34 or newer. Native
524
- agents do not require JSPI or experimental Node flags.
525
- Some Node versions require `--experimental-wasm-jspi`.
526
- Bun 1.4.2 is the tested recommendation for Bun's WebAssembly backend.
527
- Bun 1.3.14 can crash when a hot WebAssembly loop resumes through JSPI during
528
- JIT tier-up.
529
-
530
- ### Next.js and Vercel
531
-
532
- Create agents in a server route using the Node.js runtime. Import `libfx`
533
- normally; the package includes its native assets and exposes both ESM and
534
- CommonJS Node entrypoints. Native agents support Next.js 15 with webpack and
535
- Next.js 16 with webpack or Turbopack, without `serverExternalPackages` or manual
536
- native-file inclusion. Webpack's emitted assets are resolved relative to the
537
- server bundle, including standalone builds with a custom `distDir` or `assetPrefix`.
538
-
539
- This native setup does not require JSPI. Explicit WebAssembly use still needs
540
- JSPI and available Wasm assets; Next.js's standalone tracer excludes `.wasm`
541
- files, so a standalone Wasm host must supply those assets separately.
397
+ ### Where credentials go
398
+
399
+ 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.
400
+
401
+ ## Images
542
402
 
543
403
  ```js
544
- import { createFxAgent } from "libfx";
404
+ agent.session().prompt([
405
+ { type: "text", text: "What's wrong with this chart?" },
406
+ { type: "image", mimeType: "image/png", data: base64Png },
407
+ ]);
408
+ ```
545
409
 
546
- export const runtime = "nodejs";
410
+ 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.
547
411
 
548
- export async function POST(request) {
549
- const { prompt } = await request.json();
550
- const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY });
551
- try {
552
- let text = "";
553
- const turn = agent.prompt(prompt, { signal: request.signal });
554
- for await (const event of turn) {
555
- if (event.type === "text_delta") text += event.delta;
556
- }
557
- await turn.result;
558
- return Response.json({ text });
559
- } finally {
560
- await agent.close();
561
- }
412
+ ## Diagnostics
413
+
414
+ `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.
415
+
416
+ ## Instrumentation hooks
417
+
418
+ Pass `hooks` to `createFxAgent()` or `createFxEngine()` to observe semantic execution on both native and Wasm backends. Your host owns span topology, naming, redaction, and export. libfx doesn't load a tracing library.
419
+
420
+ ```ts
421
+ import { createFxAgent, type FxHookEvent } from "libfx";
422
+
423
+ const observed: FxHookEvent[] = [];
424
+ const agent = createFxAgent({
425
+ hooks: {
426
+ captureContent: true,
427
+ onEvent(event) { observed.push(event); },
428
+ onError(error) { console.error("Instrumentation callback failed", error); },
429
+ },
430
+ });
431
+ ```
432
+
433
+ `FxHookEvent` includes turn and step boundaries, model requests and completions, provider admission and first output, tool execution and accepted results, permission evaluation, and journal restore/commit/error receipts. Events carry the session ID, logical turn ID, and a fresh execution ID for each prompt or resume. Model-call and step ordinals are scoped to that execution; tool call IDs come from the model and survive recovery. Join a tool to its issuing model through `model.end.data.toolCalls`, without matching tool names or parsing UI events.
434
+
435
+ `tool.end` reports execution; `tool.result` means the result entered the conversation, not that storage accepted it. `journal.commit` follows a successful append and includes its validated cursor and sequence range. Journal receipts are session-scoped, with null turn and execution IDs. A yielded or paused turn stays resumable; `turn.end.data.handoff` identifies an explicit host handoff. Replaying saved results doesn't emit fresh tool execution events; an authorized rerun emits `tool.start` with `rerun: true` and the original call ID.
436
+
437
+ `wait.start` and `wait.end` describe actual input requests separately from permission evaluation. Their `kind` is `approval`, `question`, `mcp_form`, or `mcp_url`; request IDs pair outcomes, including denial, cancellation, and failure. Native delegation events carry the child session and durable work ID. Child events retain that work ID, their own session/turn/execution identity, and the root and parent session IDs, so the host can join them even if delivery interleaves.
438
+
439
+ MCP events include the configured server, original remote tool name, and owning fx call ID. `mcp.request.data.requestIdKind` distinguishes a native JSON-RPC ID, an MCP-v2 engine handle, and an explicitly propagated host correlation token. Hooks don't change remote MCP requests by default: the host adapter emits `mcp.start` and `mcp.end` without adding metadata or emitting a request ID. Opt in with `createMcpAdapter(client, { propagateCorrelation: true })` to send an opaque UUID as `_meta["libfx/correlationId"]` and record it in `mcp.request`. It contains no internal execution/call IDs and doesn't request progress notifications. Give the adapter a `serverName` for a stable local identity; otherwise it uses the client's reported server name when available. Native resource/prompt operations also report their server and action.
440
+
441
+ `mcp.end.status` uses `success`, `failure`, `cancelled`, `input_required`, `unavailable`, or `unknown`. Native tool/protocol failures map to `failure`; `unavailable` means no operation result was available, and `unknown` means observation couldn't classify a returned host result.
442
+
443
+ Host tools receive `context.observation` when hooks are enabled. Wrap host-owned approvals or questions with `observation.wait(request, effect)`; the effect still owns the decision, response, cancellation, and error. For a host-created child engine, pass `hooks: context.observation.childHooks()` to retain the parent tool relationship. These helpers are optional, and neither requires a tracing library. Native review, vision, title, and compaction calls use the model event family with an explicit purpose; tool-owned model calls include `parentCallId`.
444
+
445
+ Always call the host effect when `context.observation` is absent too. Disabling hooks must not skip an approval or question; the observer doesn't make permission decisions.
446
+
447
+ JavaScript host-tool callbacks apply to the engine's own root session, not native descendant sessions. Descendant hook metadata doesn't authorize a cross-session host-tool request. A host-created child engine has its own root session and can install `childHooks()` for lineage.
448
+
449
+ Content capture defaults to off. Enabling it includes bounded messages, instructions, tool arguments/results, input prompts/answers, and schemas, which can contain sensitive application content. Each event has a shared 64 KiB text budget. For model requests, the newest messages are kept first from the last 128 messages, followed by instructions, tool descriptions, and schemas. Collections keep at most 128 entries, and tool `inputSchemaJson` fields keep at most 8 KiB each. `contentTruncated` marks omissions. JSON text fields can be prefixes after truncation; `inputSchemaJson` is complete JSON or null. Credentials, request headers, raw image bytes, and opaque provider replay state aren't fields in this protocol. Apply your own redaction before export.
450
+
451
+ Callbacks run in delivery order, but libfx doesn't await their promises. Callback throws and rejections go to `hooks.onError` and don't fail or retry agent work. A slow synchronous callback still delays delivery, so enqueue events and let the host own buffering and flushing. Hooks are best-effort observations, not a durable trace outbox or exactly-once delivery contract. Existing `onEvent` diagnostics and turn UI streams remain separate.
452
+
453
+ Auxiliary title-model calls can outlive `turn.end` and report model events with the same turn/execution IDs afterward. Their model-call IDs and `purpose: "title"` distinguish them from the finished agent loop.
454
+
455
+ ## Without sessions
456
+
457
+ `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.
458
+
459
+ ```js
460
+ import { createFxEngine } from "libfx";
461
+
462
+ const engine = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5" });
463
+
464
+ const turn = engine.prompt("Summarize this file.");
465
+ for await (const event of turn) {
466
+ if (event.type === "text_delta") process.stdout.write(event.delta);
562
467
  }
468
+
469
+ const checkpoint = await engine.checkpoint(); // opaque bytes, up to 4 MiB
470
+ await engine.close();
471
+
472
+ const restored = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5", checkpoint });
563
473
  ```
564
474
 
565
- Use the application's normal authentication and request limits around the
566
- route. JavaScript tools and MCP clients remain host-owned and must be supplied
567
- when creating an agent, including after checkpoint restoration. The native
568
- backend does not enable the CLI's built-in shell or filesystem tools.
475
+ An engine differs from a session:
476
+
477
+ - On AI Gateway, it needs an `apiKey`.
478
+ - One prompt runs at a time, and breaking out of a turn's events cancels the turn.
479
+ - The turn itself has `cancel()` and `steer()`. Like a session's steer, it cuts a streaming reply short and waits for a running tool, and it takes text only.
480
+
481
+ 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:
482
+
483
+ ```ts
484
+ interface Persistence {
485
+ load(): Promise<{
486
+ checkpoint?: { data: Uint8Array; through: string };
487
+ journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
488
+ head?: string | null;
489
+ }>;
490
+ append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array; barrier: boolean; marks?: object[] }): Promise<{ cursor: string }>;
491
+ saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
492
+ marks?: boolean;
493
+ }
494
+
495
+ interface JournalRecord {
496
+ cursor: string;
497
+ data: Uint8Array;
498
+ }
499
+ ```
569
500
 
570
- ## Interactive terminal
501
+ Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation. If your store keeps entries of its own after the last record, return the cursor the next `append()` should name as `head`. When a resumed turn left a tool call running, `resume({ onAmbiguous })` lets you return `"rerun"` to run it again under the same `executionId`.
571
502
 
572
- `createFxTerminal()` remains a separate terminal harness API. In browsers,
573
- connect it to xterm.js with `xtermAdapter()`:
503
+ The engine sends one `append()` at a time, and everything that happens while one is in flight goes in the next record. `barrier` is `true` when the engine waits for the record before it goes on, such as before a tool call with effects. A store may acknowledge a record with `barrier: false` before it is durable, as long as it keeps records in order and acknowledges a barrier only once it and every record before it are durable. Set `marks: true` to receive each record's `marks`, which say where turns start (`{ start: turnId }`), yield and end, and which inputs a turn took (`{ accepted: id }`), so a store can track turns without reading records.
504
+
505
+ `prompt()` and `resume()` take `yieldAt`, a time in milliseconds since the epoch: the turn stops before its next model request after that time, with the stop reason `yielded`, and the next `resume()` continues it without telling the model it was interrupted.
506
+
507
+ A journal or `checkpoint` from a newer libfx fails with `FxJournalVersionError`. `createMemoryPersistence()` is a reference store for tests.
508
+
509
+ ## Runtimes
510
+
511
+ | Runtime | Backend |
512
+ | --- | --- |
513
+ | Node.js 20+ on macOS or Linux, x64 or arm64 | Native addon. Linux needs glibc 2.34 or newer. |
514
+ | Node.js elsewhere | WebAssembly with JSPI. Some Node versions need `--experimental-wasm-jspi`. |
515
+ | Browsers | WebAssembly with JSPI, from `libfx/browser` |
516
+ | Bun | The same as Node.js. For WebAssembly, use Bun 1.4.2 or newer. |
517
+
518
+ 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.
519
+
520
+ 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.
521
+
522
+ ## The fx terminal in a browser
574
523
 
575
524
  ```js
576
525
  import { createFxTerminal, xtermAdapter } from "libfx/browser";
577
526
 
578
- const runtime = await createFxTerminal({
579
- terminal: xtermAdapter(term),
580
- env: { AI_GATEWAY_API_KEY: "<short-lived credential>" },
527
+ const terminal = await createFxTerminal({
528
+ terminal: xtermAdapter(term), // an xterm.js Terminal
529
+ env: { AI_GATEWAY_API_KEY: shortLivedKey },
581
530
  });
582
531
 
583
- await runtime.interactive;
532
+ await terminal.interactive;
584
533
  ```
585
534
 
586
- The xterm adapter preserves browser-style composer editing for Shift+Enter,
587
- Command+A, Command+C, Command+X, Command+Z, and Command+Shift+Z. Shift+Enter
588
- inserts a newline without submitting. A click inside the visible composer moves
589
- its caret; pointer drags remain xterm terminal-output selections.
590
- When xterm already has an output selection, Command+C copies that selection
591
- instead of the composer selection.
592
-
593
- The terminal runtime exposes `interactive`, `exited`, `write`, `resize`, and
594
- `abort`. Terminal session, config, OAuth, prompt-history, clipboard, URL, and
595
- workspace stores remain terminal-only host integrations. Clipboard copy writes
596
- through the host `clipboard.writeText(text)` adapter and defaults to
597
- `navigator.clipboard`.
598
-
599
- During `/compact` and automatic compaction, the terminal shows a live
600
- `Compacting` activity row with elapsed time. Input and cancellation remain
601
- responsive while the summary request is pending. Compaction progress and
602
- outcomes do not add transcript entries, including cancellation after resume.
603
- Stored snapshots retain cancellation-origin metadata; keep them opaque and
604
- resume with the same or a newer SDK build. Older snapshots remain readable.
535
+ 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).
536
+
537
+ ## Entry points
538
+
539
+ | Import | What's in it |
540
+ | --- | --- |
541
+ | `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` and `wakeHandler` |
542
+ | `libfx/node`, `libfx/browser` | The same, for Node.js or browsers explicitly |
543
+ | `libfx/wasm` | The WebAssembly SDK without packaged asset paths, so you pass `wasm` yourself |
544
+ | `libfx/durable-local`, `libfx/durable-vercel`, `libfx/durable-world` | `local()`, `vercel()` and `world()` |
545
+ | `libfx/chatgpt`, `libfx/grok` | Sign-in for ChatGPT and Grok plans, and `refreshCredentials` |
546
+ | `libfx/mcp` | `createMcpAdapter` |
547
+ | `libfx/skills`, `libfx/skills/node` | `createSkillsAdapter`, and `loadSkillFile` for Node.js and Bun |
548
+
549
+ Every entry point ships TypeScript types, and the package has no runtime dependencies.
550
+
551
+ ## For coding agents
552
+
553
+ - **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.
554
+ - **Agents at module scope:** create each agent once at module scope, not per request, and open `agent.session(id)` for each conversation.
555
+ - **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.
556
+ - **Side effects:** every tool sets `idempotent`. Set it to `true` only on tools that are safe to run twice, and pass `executionId` to services that take an idempotency key.
557
+ - **Deployments:** on Vercel, the `wakeHandler()` route and its `vercel.json` trigger are both required, or prompts are stored and never run. The route must import the module that creates the agents.
558
+ - **Several agents:** give each durable agent its own `name`, and never rename one that has sessions.
559
+ - **Credentials:** keep keys and ChatGPT or Grok credentials on the server, and the object `startSignIn()` returns too.
560
+ - **Checking a setup:** `getBackendInfo()` reports which backend would load without creating anything or making requests.
561
+ - **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.
562
+
563
+ ## Upgrading from 0.0.13
564
+
565
+ 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 runs against the agent once its tools set `idempotent`, 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.
566
+
567
+ `libfxApiVersion` is now 3. Every tool given to `createFxAgent()` must set `idempotent`, and a tool's `writes` now defaults to the opposite of `idempotent`, in the engine too, so a tool that isn't marked idempotent runs alone. A journal written by this libfx can't be loaded by an older one, which fails with `FxJournalVersionError`.
605
568
 
606
569
  ## Security
607
570
 
608
- Treat `nativeAddon` and `gatewayChatUrl` as trusted host
609
- configuration. Do not embed long-lived credentials in public browser code.
610
- Host tool functions, MCP clients, and skill loaders retain their own authority;
611
- libfx validates and sequences them but does not grant operating-system access.
571
+ 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.
572
+
573
+ ## Contributing
574
+
575
+ 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.
576
+
577
+ ## License
578
+
579
+ [Apache-2.0](https://github.com/vercel-labs/fx/blob/main/LICENSE)