libfx 0.0.13-dev.1470.g5ec2519d98b9 → 0.0.13-dev.1506.gbc968ad65622

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # libfx
2
2
 
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: streaming, tool calls, retries, steering and images. It's the same Zig engine that runs fx in your terminal, built as a native addon and as WebAssembly.
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
4
 
5
- Sessions are durable. libfx records each step of a turn as it happens, so when a server crashes, a function hits its time limit or you redeploy in the middle of a reply, the next process picks the turn up where it stopped. On Vercel, that takes one route and one line of `vercel.json`.
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
@@ -25,43 +25,40 @@ for await (const event of turn) {
25
25
  await agent.close();
26
26
  ```
27
27
 
28
- Model calls go through [AI Gateway](https://vercel.com/docs/ai-gateway), so any model it lists works. Set `AI_GATEWAY_API_KEY` on your machine. On Vercel, libfx uses the deployment's OIDC token and there's no key to manage.
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
29
 
30
- Importing libfx doesn't touch the network, read files or start processes, and `createFxAgent()` returns right away. Nothing loads until a session runs its first turn.
30
+ Importing libfx and calling `createFxAgent()` touch no network, files or processes. The engine loads when a session runs its first turn.
31
31
 
32
32
  The full documentation is at [fx.sh/docs/lib](https://fx.sh/docs/lib).
33
33
 
34
- ## Features
34
+ ## How it fits together
35
35
 
36
- - [Streams](https://fx.sh/docs/lib/prompts) text, reasoning and tool calls as the agent works
37
- - Runs your JavaScript functions as [tools](https://fx.sh/docs/lib/tools), along with [MCP](https://fx.sh/docs/lib/mcp) clients and [skills](https://fx.sh/docs/lib/skills)
38
- - Keeps [sessions](https://fx.sh/docs/lib/durability) going through crashes, deploys and function timeouts
39
- - Reconnects to a running turn from any server, starting at the last event you saw
40
- - [Steers](https://fx.sh/docs/lib/steering) a turn while it runs
41
- - Takes [images](https://fx.sh/docs/lib/images) in prompts and tool results
42
- - Gives each conversation its own model, instructions and context
43
- - Runs on AI Gateway, on a user's own [ChatGPT](#chatgpt-plans) or [Grok](#grok-plans) plan, or on [any OpenAI-compatible server](#custom-providers)
44
- - Uses a native addon on macOS and Linux, and WebAssembly everywhere else, including browsers
45
- - Puts the [fx terminal](https://fx.sh/docs/lib/webassembly#embed-the-terminal) in a web page
46
- - Ships TypeScript types for every entry point, with no runtime dependencies
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 |
47
41
 
48
- ## Sessions
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).
49
45
 
50
- An agent is configuration. Create one per server and open a session per conversation:
46
+ ## Sessions
51
47
 
52
48
  ```js
53
49
  const turn = agent.session().prompt("Plan the migration.");
54
- const { sessionId } = await turn.accepted;
50
+ const { sessionId } = await turn.accepted; // the prompt is stored
55
51
 
56
- // Later, from any process that creates the same agent:
52
+ // Later, in any process that creates the same agent:
57
53
  agent.session(sessionId).prompt("Start with the schema.");
58
54
  ```
59
55
 
60
- A session runs one turn at a time. A prompt that arrives while a turn is running waits behind it, even when it reaches a different server. A turn runs to the end whether or not anyone reads it; stop it with `session.cancel()` or the prompt's `signal`. `session.steer(text)` adds guidance to the running turn at its next model request.
61
-
62
- If a client might send the same request twice, give the prompt a `messageId`. A prompt with an id the session already accepted runs nothing and follows the original turn.
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(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`.
63
60
 
64
- Each conversation can bring its own settings. They apply to the turns that call starts:
61
+ Each conversation can bring its own settings, which apply to the turns that session object starts:
65
62
 
66
63
  ```js
67
64
  agent.session(sessionId, {
@@ -71,9 +68,17 @@ agent.session(sessionId, {
71
68
  });
72
69
  ```
73
70
 
71
+ A turn's result says how it ended:
72
+
73
+ ```js
74
+ const { stopReason, usage, error } = await turn.result;
75
+ ```
76
+
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.
78
+
74
79
  ### Streaming to a browser
75
80
 
76
- `turn.readable` is the turn as newline-delimited JSON, ready to return from a route. Every line has a `cursor`. `session.stream(cursor)` replays everything from that point and stays open, so a client that loses its connection can reconnect to any server and carry on.
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.
77
82
 
78
83
  ```js
79
84
  // app/api/chat/route.js
@@ -91,22 +96,23 @@ export async function GET(request) {
91
96
  const sessionId = params.get("sessionId");
92
97
  if (!sessionId) return new Response("sessionId is required", { status: 400 });
93
98
  // Same check as POST.
94
- const stream = agent.session(sessionId).stream(Number(params.get("cursor") ?? 0));
95
- return new Response(stream);
99
+ return new Response(agent.session(sessionId).stream(Number(params.get("cursor") ?? 0)));
96
100
  }
97
101
  ```
98
102
 
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.
104
+
99
105
  ## Durability
100
106
 
101
- libfx stores a prompt before it reports the prompt as accepted, and it stores each step of a turn while the turn runs. If the process running a turn dies, the next process that gets work for that session continues the turn from its last step. The model is told the turn was interrupted, so it can check what happened before going on.
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.
102
108
 
103
- Where sessions live depends on where your code runs, and libfx picks for you:
109
+ Where sessions live depends on where your code runs. libfx picks for you:
104
110
 
105
111
  | Where | Durability | Sessions live in |
106
112
  | --- | --- | --- |
107
- | Vercel | `vercel()` | Vercel's [Workflow World](https://workflow-sdk.dev/worlds) |
108
- | Node.js | `local()` | files in `$TMPDIR/libfx/sessions`, or `FX_SESSIONS_DIR` |
109
- | Browsers | `memory()` | the page's memory |
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 |
110
116
 
111
117
  To choose yourself, pass `durability`:
112
118
 
@@ -115,14 +121,28 @@ import { createFxAgent, memory } from "libfx";
115
121
  import { local } from "libfx/durable-local";
116
122
 
117
123
  createFxAgent({ durability: local({ dir: ".fx/sessions" }) });
118
- createFxAgent({ durability: memory() }); // nothing survives the process
124
+ createFxAgent({ durability: memory() }); // nothing outlives the process
119
125
  ```
120
126
 
121
- To keep sessions in your own database, create a World such as Workflow's Postgres World and pass it to `world()` from `libfx/durable-world`. The [durability guide](https://fx.sh/docs/lib/durability) shows how.
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.
128
+
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.
122
130
 
123
- With `local()` and `vercel()`, a session id is a Workflow run id. Take it from `turn.accepted` or `await agent.newSessionId()` instead of making one up.
131
+ ### Several agents
124
132
 
125
- A session stores only the conversation. Every process passes its own model, credentials, tools, MCP clients and skills when it creates the agent.
133
+ An app can have more than one durable agent, each with its own model, instructions and tools. Give each one a name:
134
+
135
+ ```js
136
+ export const support = createFxAgent({ name: "support", instructions: "Answer billing questions." });
137
+ export const coding = createFxAgent({ name: "coding", model: "anthropic/claude-opus-5.5", tools: codingTools });
138
+ ```
139
+
140
+ 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`.
141
+
142
+ - Agents with the same name are copies of one agent, such as one on each server, and take turns running its sessions.
143
+ - 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.
144
+ - 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.
145
+ - Agents share sessions through their durability. Give agents that share sessions the same durability, or none: agents created without one share the default.
126
146
 
127
147
  ### On Vercel
128
148
 
@@ -130,30 +150,89 @@ Add a route for libfx's queue deliveries:
130
150
 
131
151
  ```js
132
152
  // app/api/libfx/route.js
133
- import { agent } from "@/lib/agent";
153
+ import { wakeHandler } from "libfx";
154
+ import "@/lib/agents"; // creates the app's agents, so this function has them
134
155
 
135
- export const POST = agent.wakeHandler();
156
+ export const POST = wakeHandler();
136
157
  ```
137
158
 
159
+ 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.
160
+
138
161
  Then subscribe it to libfx's topic in `vercel.json`:
139
162
 
140
163
  ```json
141
164
  {
142
165
  "functions": {
143
166
  "app/api/libfx/route.js": {
144
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session" }]
167
+ "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session", "retryAfterSeconds": 5 }]
145
168
  }
146
169
  }
147
170
  }
148
171
  ```
149
172
 
150
- That's all. `prompt()` sends each turn to Vercel Queues, and this route runs it. When a turn gets close to the function's time limit, libfx saves it and the next invocation continues it. When a function dies mid-turn, its session frees about 15 seconds after it last checked in, and the turn carries on in a new invocation. `vercel({ reserveMs, leaseMs })` from `libfx/durable-vercel` changes both timings.
173
+ 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:
174
+
175
+ - 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.
176
+ - 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.
177
+ - 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.
178
+ - A function that only stalled and wakes up after that is refused at its next write, so two processes never write one session.
179
+
180
+ ### Tools that change things
181
+
182
+ 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:
183
+
184
+ ```js
185
+ const byId = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
186
+
187
+ const tools = [
188
+ {
189
+ name: "get_order",
190
+ description: "Look up an order.",
191
+ inputSchema: byId,
192
+ idempotent: true, // a lookup is harmless to repeat
193
+ execute: ({ id }) => orders.get(id),
194
+ },
195
+ {
196
+ name: "refund_order",
197
+ description: "Refund an order.",
198
+ inputSchema: byId,
199
+ idempotent: true, // keyed on executionId, so running it again refunds once
200
+ writes: true,
201
+ execute: ({ id }, { executionId }) => payments.refund(id, { idempotencyKey: executionId }),
202
+ },
203
+ ];
204
+ ```
205
+
206
+ 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.
207
+
208
+ 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`.
209
+
210
+ 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.
151
211
 
152
- ### Tools with side effects
212
+ ### Your own store
153
213
 
154
- When a process dies during a tool call, libfx can't know whether the call finished. Mark a tool `idempotent: true` if running it twice is harmless, like a lookup, and it runs again when the turn continues. 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.
214
+ `world()` from `libfx/durable-world` keeps sessions in a [Workflow World](https://workflow-sdk.dev/worlds) you create, such as Workflow's Postgres World:
155
215
 
156
- Each call also gets an `executionId` that stays the same when the call runs again. Pass it to the service your tool calls as an idempotency key, and a repeated call won't change anything twice.
216
+ ```js
217
+ import { createWorld } from "@workflow/world-postgres";
218
+ import { createFxAgent } from "libfx";
219
+ import { world } from "libfx/durable-world";
220
+
221
+ const sessions = createWorld({
222
+ connectionString: process.env.DATABASE_URL,
223
+ namespace: "libfx",
224
+ jobPrefix: "libfx_",
225
+ });
226
+ await sessions.start();
227
+
228
+ export const agent = createFxAgent({
229
+ durability: world(sessions, { name: "postgres", queueDurable: true }),
230
+ });
231
+ ```
232
+
233
+ 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.
234
+
235
+ 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.
157
236
 
158
237
  ## Tools
159
238
 
@@ -164,16 +243,29 @@ const agent = createFxAgent({
164
243
  description: "Look up an order by id.",
165
244
  inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
166
245
  idempotent: true,
167
- async execute({ id }, { signal, executionId, context }) {
246
+ async execute({ id }, { signal }) {
168
247
  return orders.get(id, { signal });
169
248
  },
170
249
  }],
171
250
  });
172
251
  ```
173
252
 
174
- `execute` gets a `signal` that aborts when the turn is cancelled, the call's `executionId`, and the session's `context`. Return any JSON value, or `{ type: "libfx.tool-result", text, images }` to return images.
253
+ `execute` receives the model's input and a context:
254
+
255
+ | Field | What it is |
256
+ | --- | --- |
257
+ | `signal` | Aborts when the turn is cancelled |
258
+ | `executionId` | The call's id, the same if the call runs again after a crash |
259
+ | `sessionId` | The session the call belongs to |
260
+ | `context` | The JSON passed as `context` to `agent.session()` |
261
+
262
+ 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.
263
+
264
+ Validate the input and authorize the action inside `execute`. libfx orders the calls but grants nothing itself.
265
+
266
+ 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.
175
267
 
176
- On the native backend, the calls from one model response run at the same time. Mark a tool `writes: true` to keep it from overlapping with others. Add `{ name: "web_search", providerExecuted: true }` for AI Gateway's web search, which runs at the provider instead of in your code.
268
+ `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.
177
269
 
178
270
  ### MCP and skills
179
271
 
@@ -195,23 +287,29 @@ await agent.close();
195
287
  await mcp.close();
196
288
  ```
197
289
 
198
- libfx doesn't own the MCP connection. You create the client, handle its auth and close it after the agent.
290
+ 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.
291
+
292
+ 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.
199
293
 
200
294
  ## Models
201
295
 
202
296
  ```js
203
- createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "low", fast: true } });
297
+ createFxAgent({ model: { id: "anthropic/claude-opus-5.5", effort: "high", fast: true } });
204
298
  ```
205
299
 
206
300
  `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`.
207
301
 
208
- `listModels({ apiKey })` returns the Gateway's language models. Behind a proxy that only forwards chat requests, pass the catalog yourself as `modelCatalog`, and the agent won't fetch it.
302
+ `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.
303
+
304
+ ## Providers
305
+
306
+ ### AI Gateway
209
307
 
210
- `onEvent` receives diagnostics apart from the model's output: request timing, and session events such as `session.deadline` and `checkpoint.mismatch`. Credentials never appear in them.
308
+ 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.
211
309
 
212
310
  ### ChatGPT plans
213
311
 
214
- An agent can run on a user's ChatGPT plan through Codex instead of AI Gateway. Set `provider: "codex"` and hand libfx the user's ChatGPT credentials:
312
+ An agent can run on a user's ChatGPT plan through Codex. Set `provider: "codex"` and give libfx the user's credentials:
215
313
 
216
314
  ```js
217
315
  const agent = createFxAgent({
@@ -222,15 +320,7 @@ const agent = createFxAgent({
222
320
  });
223
321
  ```
224
322
 
225
- libfx calls `credentials()` before Codex requests, in whichever process runs the turn, and adds the credentials to those requests itself, so they never reach the engine or a saved session. It refreshes them 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.
226
-
227
- Sessions in one process share each refresh. If your app runs on several servers, two of them can notice the same expiring credentials at once, and the provider accepts only the first refresh. Pass `refreshLock` so they take turns:
228
-
229
- ```js
230
- refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
231
- ```
232
-
233
- 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 asks `credentials()` again and uses what another server already saved. This works for Grok too.
323
+ 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.
234
324
 
235
325
  To sign a user in, use `libfx/chatgpt`:
236
326
 
@@ -240,28 +330,17 @@ import { startSignIn, finishSignIn } from "libfx/chatgpt";
240
330
  // One request: send the user to signIn.url and keep signIn on the server.
241
331
  const signIn = await startSignIn();
242
332
 
243
- // A later request: the user pastes the address OpenAI opened.
333
+ // A later request: the user pastes back the address OpenAI opened.
244
334
  const credentials = await finishSignIn(signIn, pastedAddress);
245
335
  ```
246
336
 
247
- 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()` and `pollDeviceSignIn()` show a short code to enter on OpenAI's site instead. Each step returns plain data, so it can finish in another request or function.
337
+ 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.
248
338
 
249
339
  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.
250
340
 
251
341
  ### Grok plans
252
342
 
253
- An agent can also run on a user's Grok plan. Set `provider: "grok"` and pass the same `credentials` callback, which libfx calls with `provider: "grok"`:
254
-
255
- ```js
256
- const agent = createFxAgent({
257
- provider: "grok",
258
- model: "grok-4.5",
259
- credentials: ({ provider, sessionId }) => tokens.load(provider, sessionId),
260
- onCredentials: (credentials, { provider, sessionId }) => tokens.save(provider, sessionId, credentials),
261
- });
262
- ```
263
-
264
- libfx handles Grok credentials the same way as Codex's: it adds them to Grok requests itself, refreshes them, and checks that a refreshed token still belongs to the same xAI account. Grok doesn't accept requests from web pages either, so these turns run on your server.
343
+ `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.
265
344
 
266
345
  To sign a user in, use `libfx/grok`. Device code is the simplest:
267
346
 
@@ -271,44 +350,66 @@ import { startDeviceSignIn, completeDeviceSignIn } from "libfx/grok";
271
350
  // Show device.userCode and a link to device.verificationUrl.
272
351
  const device = await startDeviceSignIn();
273
352
 
274
- // Wait until the user enters the code. pollDeviceSignIn() checks once, for a later request.
353
+ // Waits until the user enters the code. pollDeviceSignIn() checks once, for a later request.
275
354
  const credentials = await completeDeviceSignIn(device);
276
355
  ```
277
356
 
278
- `startSignIn()` and `finishSignIn()` work like ChatGPT's: after the user signs in, xAI opens a page at `127.0.0.1` that doesn't load, and the user pastes its address back. 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.
357
+ `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.
279
358
 
280
- ### Custom providers
359
+ ### Several servers, one account
281
360
 
282
- An agent can also run on any server that speaks the OpenAI Chat Completions API, such as a local model server or a router. Pass the provider as an object and name the model it serves:
361
+ 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:
283
362
 
284
363
  ```js
285
- const agent = createFxAgent({
286
- provider: { name: "local", baseUrl: "http://localhost:11434/v1" },
287
- model: "qwen3:8b",
288
- });
364
+ refreshLock: ({ provider, sessionId }, refresh) => locks.withLock(`${provider}:${accountOf(sessionId)}`, refresh),
289
365
  ```
290
366
 
291
- Requests go to `{baseUrl}/chat/completions`, which must use HTTPS, or HTTP on `localhost`. A server that needs a key gets it from the same `credentials` callback, which libfx calls with the provider's `name`:
367
+ 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.
368
+
369
+ ### Custom providers
370
+
371
+ An agent can run on a server that speaks the OpenAI Chat Completions API, such as a local model server or a router:
292
372
 
293
373
  ```js
374
+ const router = {
375
+ name: "router",
376
+ baseUrl: "https://openrouter.ai/api/v1",
377
+ models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
378
+ };
379
+
294
380
  const agent = createFxAgent({
295
- provider: {
296
- name: "router",
297
- baseUrl: "https://openrouter.ai/api/v1",
298
- models: { "qwen/qwen3-coder": { contextWindow: 128000, supportsToolUse: true } },
299
- },
381
+ provider: router,
300
382
  model: "qwen/qwen3-coder",
301
383
  credentials: async ({ provider, sessionId }) => ({ apiKey: await keys.load(provider, sessionId) }),
302
384
  });
303
385
  ```
304
386
 
305
- libfx sends the key as a bearer token to that chat URL only, and the key never reaches the engine or a saved session. A key isn't refreshed, so a rejected one ends the turn with the server's error. `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. Tool calls work when the model and server support them. In a browser, the server must accept cross-origin requests, and the page can read the key.
387
+ 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.
306
388
 
307
389
  Saved sessions follow the agent's provider: point an agent at another base URL and its sessions continue there, history included.
308
390
 
309
- ## One conversation, no sessions
391
+ ### Where credentials go
392
+
393
+ 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.
394
+
395
+ ## Images
396
+
397
+ ```js
398
+ agent.session().prompt([
399
+ { type: "text", text: "What's wrong with this chart?" },
400
+ { type: "image", mimeType: "image/png", data: base64Png },
401
+ ]);
402
+ ```
403
+
404
+ 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.
405
+
406
+ ## Diagnostics
407
+
408
+ `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.
409
+
410
+ ## Without sessions
310
411
 
311
- `createFxEngine()` is the engine under the agent: one conversation in the memory of the process that runs it, with no queue and no session store. Use it for a single request, or to keep the conversation in storage of your own.
412
+ `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.
312
413
 
313
414
  ```js
314
415
  import { createFxEngine } from "libfx";
@@ -317,23 +418,33 @@ const engine = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, mo
317
418
 
318
419
  const turn = engine.prompt("Summarize this file.");
319
420
  for await (const event of turn) {
320
- // ...
421
+ if (event.type === "text_delta") process.stdout.write(event.delta);
321
422
  }
322
423
 
323
- const checkpoint = await engine.checkpoint(); // opaque bytes
324
- const restored = await createFxEngine({ apiKey, model, checkpoint });
424
+ const checkpoint = await engine.checkpoint(); // opaque bytes, up to 4 MiB
425
+ await engine.close();
426
+
427
+ const restored = await createFxEngine({ apiKey: process.env.AI_GATEWAY_API_KEY, model: "anthropic/claude-haiku-5.5", checkpoint });
325
428
  ```
326
429
 
327
- Pass a `persistence` store instead, and the engine records the conversation as it runs, so a new engine can `resume()` a turn the last process left open. A store needs two methods and can add a third. The bytes are opaque, so it never has to understand them:
430
+ An engine differs from a session:
431
+
432
+ - On AI Gateway, it needs an `apiKey`.
433
+ - One prompt runs at a time, and breaking out of a turn's events cancels the turn.
434
+ - The turn itself has `cancel()` and `steer()`.
435
+
436
+ 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:
328
437
 
329
438
  ```ts
330
439
  interface Persistence {
331
440
  load(): Promise<{
332
441
  checkpoint?: { data: Uint8Array; through: string };
333
442
  journal?: Iterable<JournalRecord> | AsyncIterable<JournalRecord>;
443
+ head?: string | null;
334
444
  }>;
335
- append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array }): Promise<{ cursor: string }>;
445
+ append(input: { expected: string | null; idempotencyKey: string; data: Uint8Array; barrier: boolean; marks?: object[] }): Promise<{ cursor: string }>;
336
446
  saveCheckpoint?(input: { through: string; data: Uint8Array }): Promise<void>;
447
+ marks?: boolean;
337
448
  }
338
449
 
339
450
  interface JournalRecord {
@@ -342,11 +453,13 @@ interface JournalRecord {
342
453
  }
343
454
  ```
344
455
 
345
- Reject an `append()` whose `expected` isn't the last stored cursor with `FxFencedError`, so two processes can't write the same conversation at once. `createMemoryPersistence()` is a reference store for tests. [Save and restore](https://fx.sh/docs/lib/checkpoints) has the details.
456
+ 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`.
457
+
458
+ 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.
346
459
 
347
- ### Upgrading from 0.0.13
460
+ `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.
348
461
 
349
- In libfx 0.0.13 and earlier, `createFxAgent()` created this 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.
462
+ A journal or `checkpoint` from a newer libfx fails with `FxJournalVersionError`. `createMemoryPersistence()` is a reference store for tests.
350
463
 
351
464
  ## Runtimes
352
465
 
@@ -357,7 +470,7 @@ In libfx 0.0.13 and earlier, `createFxAgent()` created this engine. It now creat
357
470
  | Browsers | WebAssembly with JSPI, from `libfx/browser` |
358
471
  | Bun | The same as Node.js. For WebAssembly, use Bun 1.4.2 or newer. |
359
472
 
360
- The default `backend: "auto"` tries native first. Pass `"native"` or `"wasm"` to require one, and call `getBackendInfo()` to see what would load without creating anything.
473
+ 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.
361
474
 
362
475
  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.
363
476
 
@@ -374,25 +487,43 @@ const terminal = await createFxTerminal({
374
487
  await terminal.interactive;
375
488
  ```
376
489
 
377
- 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 [WebAssembly](https://fx.sh/docs/lib/webassembly).
490
+ 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).
378
491
 
379
492
  ## Entry points
380
493
 
381
494
  | Import | What's in it |
382
495
  | --- | --- |
383
- | `libfx` | `createFxAgent`, `createFxEngine`, `memory`, `listModels`, `getBackendInfo`, `createMemoryPersistence`, `FxFencedError` |
384
- | `libfx/node` | The same, for Node.js explicitly |
385
- | `libfx/browser` | The browser build, plus `createFxTerminal` and `xtermAdapter` |
496
+ | `libfx` | `createFxAgent`, `createFxEngine`, `createFxTerminal`, `memory`, `listModels`, `createMemoryPersistence`, `FxFencedError`, `FxJournalVersionError`, `supportsJspi`, `xtermAdapter`, `encodeXtermKeyEvent`, and on Node.js `getBackendInfo` and `wakeHandler` |
497
+ | `libfx/node`, `libfx/browser` | The same, for Node.js or browsers explicitly |
386
498
  | `libfx/wasm` | The WebAssembly SDK without packaged asset paths, so you pass `wasm` yourself |
387
499
  | `libfx/durable-local`, `libfx/durable-vercel`, `libfx/durable-world` | `local()`, `vercel()` and `world()` |
388
- | `libfx/chatgpt` | `startSignIn`, `finishSignIn`, `startDeviceSignIn`, `pollDeviceSignIn`, `completeDeviceSignIn`, `refreshCredentials` |
389
- | `libfx/grok` | `startSignIn`, `finishSignIn`, `startDeviceSignIn`, `pollDeviceSignIn`, `completeDeviceSignIn`, `refreshCredentials` |
500
+ | `libfx/chatgpt`, `libfx/grok` | Sign-in for ChatGPT and Grok plans, and `refreshCredentials` |
390
501
  | `libfx/mcp` | `createMcpAdapter` |
391
502
  | `libfx/skills`, `libfx/skills/node` | `createSkillsAdapter`, and `loadSkillFile` for Node.js and Bun |
392
503
 
504
+ Every entry point ships TypeScript types, and the package has no runtime dependencies.
505
+
506
+ ## For coding agents
507
+
508
+ - **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.
509
+ - **Agents at module scope:** create each agent once at module scope, not per request, and open `agent.session(id)` for each conversation.
510
+ - **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.
511
+ - **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.
512
+ - **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.
513
+ - **Several agents:** give each durable agent its own `name`, and never rename one that has sessions.
514
+ - **Credentials:** keep keys and ChatGPT or Grok credentials on the server, and the object `startSignIn()` returns too.
515
+ - **Checking a setup:** `getBackendInfo()` reports which backend would load without creating anything or making requests.
516
+ - **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.
517
+
518
+ ## Upgrading from 0.0.13
519
+
520
+ 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.
521
+
522
+ `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`.
523
+
393
524
  ## Security
394
525
 
395
- Your tools, MCP clients and skill loaders have whatever access your code gives them. libfx validates and 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. Keep ChatGPT credentials, and the `signIn` object `startSignIn()` returns, on the server.
526
+ 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.
396
527
 
397
528
  ## Contributing
398
529
 
package/browser.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "./fx-sdk.js";
15
15
 
16
16
  export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, xtermAdapter };
17
- export const libfxApiVersion = 2;
17
+ export const libfxApiVersion = 3;
18
18
 
19
19
  const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url).href;
20
20
  const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url).href;