foldrun 0.6.0 → 0.7.2

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/docs/tools.md ADDED
@@ -0,0 +1,1810 @@
1
+ # Tools
2
+
3
+ A tool is one markdown file describing something an agent may call. One noun
4
+ for capability: `transport:` says how it connects, and an agent grants it by
5
+ name in `tools:`.
6
+
7
+ ```
8
+ tools/email.md flat — a definition and nothing else
9
+ tools/bounce-verify/ folder — tool.md plus the code it runs
10
+ ├── tool.md
11
+ └── run.py
12
+ ```
13
+
14
+ Nothing is callable until an agent names it. That is the whole security model:
15
+ reading one `agent.md` tells you its blast radius.
16
+
17
+ ## `outward: true` — the tool changes something that is not ours
18
+
19
+ A tool that sends, posts, buys, publishes or uploads says so in its own
20
+ frontmatter:
21
+
22
+ ```yaml
23
+ ---
24
+ name: lawyer_send
25
+ outward: true
26
+ run: send.py
27
+ ---
28
+ ```
29
+
30
+ Nothing changes at run time. What changes is `check`: a flow step whose agent
31
+ grants an outward tool must have a `verify:` the runtime can fail on, or a
32
+ gate (`!` or `ask:`). Neither, and `foldrun check` fails, naming the step —
33
+ this is the gate. A `deploy` also **warns**, naming the step, but still goes
34
+ through: `check` is where the line is drawn, so run it before you push.
35
+
36
+ The reason is in the failure it prevents. A step that sent nothing and a step
37
+ that sent everything both end the same way — reported as done — unless
38
+ something other than the agent says otherwise. A receipt file, a ledger row,
39
+ the reply's own verdict line: any of them is enough. So is a person.
40
+
41
+ ## HTTP — an API as a tool
42
+
43
+ ```yaml
44
+ ---
45
+ transport: http
46
+ name: google-ads
47
+ description: The Google Ads API. GET /customers/… to read; POST to mutate.
48
+ base: https://googleads.googleapis.com/v18
49
+ methods: [GET, POST]
50
+ headers:
51
+ Authorization: Bearer ${GOOGLE_ADS_TOKEN}
52
+ ---
53
+ ```
54
+
55
+ | field | |
56
+ |---|---|
57
+ | `base` | the URL every call is relative to |
58
+ | `methods` | the verb allowlist — a tool that declares none is read-only |
59
+ | `headers` | `${SECRET}` placeholders, resolved host-side at call time |
60
+ | `query` | query parameters appended to every call — for an API that carries its key in the query string; a `${SECRET}` is resolved host-side |
61
+ | `timeout` | seconds a call may take; without one the call runs until it finishes |
62
+ | `rate` | calls per second from one step — `5/s`, `100/m`; a call over the limit waits, it does not fail |
63
+ | `openapi` | a URL or file; turns one generic tool into typed ones, one per operation |
64
+ | `operations` | only these operations, by `operationId` or `METHOD /path`; the generic tool is withheld. It fails closed: if the document does not load or none of them resolve, the API has no tools that run, never the generic one. Keep an allowlisted document in the workspace, so a vendor renaming an operation cannot empty it |
65
+
66
+ The model never sees a credential — the host substitutes it as the request
67
+ goes out. The method allowlist protects against the agent; the key's own scopes
68
+ protect against everyone else, so pair a read-only tool with a read-only key.
69
+
70
+ ## Script — code as a tool
71
+
72
+ ```yaml
73
+ ---
74
+ transport: script
75
+ name: sql
76
+ run: run.py
77
+ description: >
78
+ Run SQL over CSV and JSON files in the working directory. Use whenever the
79
+ answer is a number: totals, averages, joins, de-duplication.
80
+ args:
81
+ query: The SQL — one SELECT or WITH statement
82
+ files: Comma-separated .csv / .json paths, one table each
83
+ timeout: 120
84
+ runtime:
85
+ packages: [pandas]
86
+ ---
87
+ ```
88
+
89
+ | field | |
90
+ |---|---|
91
+ | `run` | the program beside this file. Omit it and a fenced code block in the body is the program (the single-file form) |
92
+ | `args` | each argument and what it means — this is what the model reads |
93
+ | `timeout` | seconds; without one the program runs until it finishes |
94
+ | `interpreter` | what runs the file — `python3`, `bash`, `node` — when the extension does not say, or says wrong. `python3` (or `python`) means the tool's own environment when it has a `runtime:`, not the image's bare python |
95
+ | `secrets` | the vault names the program reads, `[RESEND_API_KEY, EMAIL_FROM]` — granting the tool grants them, so the agent does not list them again; or `proxied` when the program sends through the egress proxy itself (reads `FOLDRUN_EGRESS`, sends `${NAME}`); the default, `materialised`, puts the real values in its environment |
96
+ | `outward` | `true` when the program makes something happen outside — sends, posts, orders. On a [test run](runs#test-runs) a step granting it is handed no real secret at all, whatever their names |
97
+ | `test_mode` | `allow` for a reader that happens to use a send-capable secret (a Business Profile reader on the same token the poster uses): on a test run the step still gets the real values. An `outward` tool in the same step wins. Parsed as `testMode` |
98
+ | `runtime` | interpreters and packages, merged into the step's environment |
99
+
100
+ A folder tool may declare its dependencies the way its ecosystem does
101
+ instead: a `requirements.txt` beside the program counts as
102
+ `runtime: packages:`, and a `package.json`'s `dependencies` count as
103
+ `runtime: npm:`. Both merge with any `runtime:` in `tool.md`, so a tool copied
104
+ out of a repository keeps working. `foldrun check` warns when a program
105
+ imports a package nothing declares — the tool that runs on the laptop that
106
+ happens to have it and fails on its first platform call.
107
+
108
+ A `requirements.txt` is read for its requirements. A line ending in a
109
+ backslash joins the next, as `pip-compile` writes its pins. Options are not
110
+ applied — `-r`, `-e`, an index URL, a pin's `--hash` — and the build log
111
+ names them: a hash-pinned file installs its pins without hash checking, and
112
+ says so. A line with an environment marker (`; python_version < "3.11"`) or
113
+ a `name @ url` is refused, with a line in the log.
114
+
115
+ `args` descriptions are the tool's interface. Write them for someone who has
116
+ never seen the program, and say when to use the tool in `description` — that
117
+ sentence is what decides whether it gets called at all.
118
+
119
+ ## MCP — someone else's tools
120
+
121
+ ```yaml
122
+ ---
123
+ transport: mcp
124
+ name: linear
125
+ url: https://mcp.linear.app/sse
126
+ headers:
127
+ Authorization: Bearer ${LINEAR_TOKEN}
128
+ ---
129
+ ```
130
+
131
+ Both transports the SDK supports: a local process (`command:`, with `args:` and
132
+ `env:`) or a remote endpoint (`url:`, with `headers:`). Every tool the server
133
+ exposes reaches the agent that grants it. `mcpServers:` in an agent's own
134
+ frontmatter is the inline spelling of the same thing.
135
+
136
+ ## The library — define it once
137
+
138
+ ```
139
+ <account>/library/
140
+ ├── tools/<name>.md
141
+ ├── skills/<name>/SKILL.md
142
+ ├── knowledge/ · memory/ · scripts/
143
+ ```
144
+
145
+ Resolution is **nearest wins**: an agent's own beats its project's, which beats
146
+ the account library. So a shared integration is defined once for the whole
147
+ account and rotating its credential is a single edit — and a workspace can
148
+ still shadow it by defining the same name locally.
149
+
150
+ The Library page lists, for every shared file, which agents granted it and
151
+ where a nearer copy overrides it. That list is the blast radius of changing it.
152
+
153
+ ## The gallery — platform-maintained tools
154
+
155
+ Every tool the platform ships — `web` — is **available in every account
156
+ from the start**.
157
+ An agent grants one the same way it grants anything: `tools: [web]`
158
+ in its `agent.md`, and it runs, with nothing installed first. The gallery
159
+ is a shelf beneath your account library, laid down on the box at boot, so
160
+ a platform upgrade reaches every account at once.
161
+
162
+ **Installing** is still there, and it means something narrower now: it
163
+ copies the tool into your account library (or one workspace), and your
164
+ copy shadows the platform's by name. A copy you have **not edited** follows
165
+ the gallery: every platform deploy brings it to the current version. A copy
166
+ you **have** edited is yours and is never changed under you. The Library
167
+ page marks each copy **gallery · current** or **gallery · differs**, with a
168
+ one-click *update to gallery version* for an edited copy — it writes the
169
+ current files through the revision log, so your version is one revision
170
+ back. Nothing from the gallery is granted
171
+ to any agent until its `agent.md` names it.
172
+
173
+ **On a laptop** the shelf comes from the platform you are signed in to.
174
+ `foldrun run` and `foldrun eval` fetch the gallery before they start and keep
175
+ a copy under `~/.foldrun/gallery/<host>/`, so an agent granting `web`
176
+ runs locally exactly as it does on a deploy, with the same lookup order: the
177
+ workspace's copy, then the account library's, then the shelf. Offline, the
178
+ last copy is used and the run says so. `foldrun gallery` lists the shelf and
179
+ marks which tools your account keeps its own copy of, `foldrun gallery pull`
180
+ refreshes the copy by hand, and `foldrun gallery upgrade <tool>` is the
181
+ Library page's *update to gallery version*. A browser tool also needs
182
+ Playwright on the laptop (`npm i -g playwright@1.63.0`, then `npx playwright
183
+ install chromium`); `web action=browse` finds it globally, Homebrew's included, or
184
+ in the project, and says how to install it when it is missing.
185
+
186
+ | tool | what it does | the key it needs |
187
+ |---|---|---|
188
+ | `web action=search` | searches the web on the account's own engine — titles, links, snippets | none |
189
+ | `web action=fetch` | reads one URL, or up to twenty: the page itself, as markdown, text, html, meta or links | none |
190
+ | `web action=browse` | a real browser: JavaScript runs, and it can click, fill, scroll, screenshot, download and extract | none |
191
+
192
+ The gallery is deliberately just these three — the web capability that is
193
+ hard to build from a plain HTTP call. **Everything else you integrate
194
+ yourself**, as your own tool in the library: Slack, GitHub, Stripe, email, a
195
+ CRM, your own service. An `apis:` block turns an OpenAPI or a base URL into a
196
+ tool; a folder tool runs a script. Credentials come from your vault, declared
197
+ under the agent's `secrets:`, and the value never appears in a file. The
198
+ platform ships no third-party integrations, so it never carries their keys or
199
+ versions — you own the tool, its scope and its blast radius. See
200
+ [HTTP — an API as a tool](#http--an-api-as-a-tool) and [Script — code as a tool](#script--code-as-a-tool).
201
+
202
+ ## Reaching the web
203
+
204
+ One tool, `web`, and eight **actions** — `action=` picks one, and the other
205
+ arguments are that action's. Each has a fixed input and output, whoever
206
+ answers it:
207
+
208
+ | action | in → out | foldrun's own |
209
+ |---|---|---|
210
+ | `search` | a query → numbered titles, URLs, snippets | the account's own engine (SearXNG) — free |
211
+ | `fetch` | a URL (or up to 20) → **the page itself**, not a summary | one plain HTTP request — tokens only |
212
+ | `browse` | a URL, and steps → what a person would see; JavaScript runs | the account's browser pod |
213
+ | `crawl` | a site → its pages, one file each under `outputs/crawl/` | plain HTTP, same site, robots.txt honoured |
214
+ | `map` | a site → every URL on it | its sitemaps, else the links on its pages |
215
+ | `extract` | a page → JSON | selectors (`fields=`, `each=`), or the page's own JSON-LD and OpenGraph |
216
+ | `answer` | a question → an answer with its sources | the passages that answer it, quoted and ranked, with sources — no prose |
217
+ | `monitor` | a page or a question → what changed since the last call | lines added and removed, or new results; kept in `state/` |
218
+
219
+ None of foldrun's own has a second model in it. `answer` returns evidence for
220
+ the agent — which is already the model — to answer from; `extract` by
221
+ `schema=` or `prompt=`, which needs a model to read the page, needs a
222
+ provider.
223
+
224
+ ```yaml
225
+ tools: [web]
226
+ web:
227
+ actions: [search, fetch, crawl] # what it may do — all eight when absent
228
+ fetch: jina # who does each; absent is foldrun's own
229
+ crawl: firecrawl
230
+ ```
231
+
232
+ | action | providers |
233
+ |---|---|
234
+ | `search` | brave, exa, tavily, parallel, you, jina, firecrawl, perplexity, linkup, serper, serpapi, dataforseo — and model providers' own (anthropic, zai, …) |
235
+ | `fetch` | jina, firecrawl, exa, tavily, parallel, zyte, scrapingbee |
236
+ | `browse` | browserbase, steel, hyperbrowser, browserless, brightdata, zenrows, cdp |
237
+ | `crawl` | firecrawl, tavily |
238
+ | `map` | firecrawl, tavily |
239
+ | `extract` | firecrawl, zyte, hyperbrowser |
240
+ | `answer` | exa, linkup, tavily, parallel, perplexity, you |
241
+ | `monitor` | parallel (a question), firecrawl (a page) |
242
+
243
+ A provider named for an action it cannot do is an **error in `foldrun check`**
244
+ and at deploy, naming the providers that can — never a quiet fall back to
245
+ ours, which would bill and behave differently from what the file says. Its key
246
+ is the vault name in the provider's own section below (`FIRECRAWL_API_KEY`, …)
247
+ or your own with `{name: firecrawl, key: ${MY_KEY}}`; it rides the egress
248
+ proxy to that provider's host alone.
249
+
250
+ `browse` has a second level: the steps and modes inside a page. A browser
251
+ reached over the DevTools protocol is driven by our code, so it takes every
252
+ step — only Bright Data's has no second `tab` (its docs). Files cross by
253
+ their bytes: an upload is built inside the page, and a download the vendor's
254
+ machine saved is fetched again by its address, from the page and then from
255
+ here with its cookies — so a download that only a form's POST produces, or a
256
+ one-time link, may not come back from a vendor's browser.
257
+
258
+ Every adapter records the vendor page it was built from and the day it was
259
+ last matched to it; a test fails when that is more than 90 days old. The
260
+ adapters for crawl, map, extract, answer and monitor were built from their
261
+ vendors' docs on 2026-09-29 and have not yet been called with a live key.
262
+
263
+ Nothing searches the live web. `search` queries an **index** — somebody's
264
+ stored copy of the web, made earlier. `fetch` and `browse` are the only two
265
+ that touch a live page. Search finds; the other two read.
266
+
267
+ Start with `fetch`. It is one HTTP request, roughly fifty times cheaper than
268
+ `browse`, and it is enough for anything the server sends whole —
269
+ documentation, JSON, RSS, most articles. When a page builds itself with
270
+ JavaScript, `fetch` says so and names `browse` rather than returning an empty
271
+ shell that reads as "no content". That message is the signal to escalate;
272
+ the `url` and `mode` carry over unchanged.
273
+
274
+ `fetch` returns the page at the fidelity you ask for — `markdown` (the
275
+ default), `text`, `html`, `meta`, `links`, or `raw` for the bytes untouched.
276
+ It is deliberately not Anthropic's built-in `WebFetch`, which fetches a page
277
+ and then hands it to a small model, so what reaches your agent is that
278
+ model's answer *about* the page. Ours returns the page, lands on the run
279
+ record with every other step, and works whichever model is driving.
280
+
281
+ ### One page, a list, or a whole site
282
+
283
+ `web action=fetch` reads **one URL per call**. That is not a limitation to work
284
+ around — it is what makes it cheap and what makes a run legible, because every
285
+ page read is its own line on the record. Scale comes from the flow, not from
286
+ the tool:
287
+
288
+ | you want | use |
289
+ |---|---|
290
+ | one page | `web action=fetch url=…` |
291
+ | a list of pages | a flow step with **`each:`** — the step runs once per URL, in parallel |
292
+ | a whole site | `web action=map` (every URL) or `web action=crawl` (page by page) over plain HTTP; `web action=browse mode=map` / `mode=crawl` when the site needs JavaScript |
293
+
294
+ So a search that returns twenty links becomes twenty reads by adding one line
295
+ to the flow, not by passing twenty URLs to one call:
296
+
297
+ ```markdown
298
+ 1. [[researcher]] — Find pages about {{topic}}.
299
+ 2. [[reader]] — Read this page and pull out what matters.
300
+ each: lines
301
+ max: 20
302
+ ```
303
+
304
+ A hundred sites is the same shape one level up: a flow fanning out, each step
305
+ mapping or crawling one site. Not one enormous call.
306
+
307
+ Where a result is written to a file — `fetch --save`, `extract --save`,
308
+ `crawl --out` — the path must stay inside the agent directory. `../`, an
309
+ absolute path elsewhere or a symlink out is refused before any request, so a
310
+ bad path costs no fetch.
311
+
312
+ ### What else the browser does
313
+
314
+ Reading a page is the smallest thing `web action=browse` does. It is a real browser,
315
+ so it can also **act**: click, fill a form, press keys, pick from a dropdown,
316
+ scroll, go back and forward, follow a link, step into an iframe or a popup tab,
317
+ open and close tabs, paste HTML into a rich editor, hand a file to a file
318
+ picker, save a download, type and click and swipe with no selector at all,
319
+ set a cookie or a storage key, stub the server (`mock`), and go offline. It
320
+ can sign in from a credential secret the model never sees (`login`), move a
321
+ single-page app to another route without a reload (`pushstate`), call a tool
322
+ the page itself offers (`webmcp`), and move the pointer the way a person does
323
+ (`"human": true`). Those go in an `actions` list that runs in order before
324
+ the page is read.
325
+
326
+ Some steps are not actions. `expect` is a **claim** — the element is there,
327
+ reads this, the URL contains that — and a wrong one fails the step. `if`
328
+ **branches** on the same claim ("dismiss the banner if it is there"). `get`
329
+ and `eval` ask a **question** whose answer comes back as data. Those three
330
+ are what turn a list of clicks into something a flow can trust.
331
+
332
+ Around that it carries what a real browser carries: a named `session` so a
333
+ login done once holds for the rest of the run, `state` so it holds for the
334
+ next run too, `cookies` from a secret for sites that sign in without a
335
+ password, six engines (`browser=chromium` — the default — `| chrome |
336
+ firefox | safari | lightpanda | obscura`; the last two are light browsers of
337
+ their own, and what each can do is the table under [Engines](#engines)),
338
+ phone emulation, `block` to drop images and trackers on heavy pages, `proxy` and
339
+ `auth` and `headers` from secrets, `capture` to save the JSON a page fetches
340
+ for itself, and `video`, `trace` or `har` when you need to see what happened.
341
+
342
+ Its twenty `mode` values fall into five groups — **read** the page (`text`,
343
+ `markdown`, `html`, `aria`), take **what it carries** (`meta`, `table`,
344
+ `network`, `capture`, `links`), keep a **file** (`screenshot`, `pdf`), walk
345
+ the **site** (`map`, `crawl`), or **ask it a question** (`console`, `vitals`,
346
+ `a11y`, `diff`, `feed`, `webmcp`, `react`). Every argument, action and mode is listed under
347
+ [The browser, in full](#the-browser-in-full).
348
+
349
+ Beside `mode` (what comes back) and `actions` (what is done first) the tool
350
+ has three more layers, each a different kind of thing: **claims** (`expect`,
351
+ and the `expect` and `if` steps — a statement about the page that fails the
352
+ call when wrong), **identity** (`identity=`, a name for who the browser is),
353
+ and **state** (`state=`, a login that outlives the run). Those are under
354
+ [Claims, identity and state](#claims-identity-and-state).
355
+
356
+ Whatever the job, a page's own failures come back with it — an uncaught error,
357
+ a script the server refused, a request that never landed — one line each on the
358
+ run log. An empty page arrives with its reason.
359
+
360
+ ### Which language the tools work in
361
+
362
+ None of the actions hardcodes a country. `web action=search` asks the engine in the
363
+ agent's `language:` — and for its region when the tag carries one, as
364
+ `en-AU` or `pt-BR` — `web action=browse` reports it as the page's locale, and
365
+ `web action=fetch` sends it as `Accept-Language`. The value cascades from the agent
366
+ to its workspace to the account, and is English when nobody set it; see
367
+ [`language:`](agents#language--the-language-this-agent-works-in). A
368
+ country the tag does not carry comes from
369
+ [`region:`](agents#region--the-country-this-agent-works-for) — `language: en`
370
+ with `region: au` searches as `en` for Australia. One call can differ:
371
+ `web action=search … language=de region=at`, `web action=browse … locale=de-DE`.
372
+
373
+ ### Two kinds of search company
374
+
375
+ Every "search API for AI" is one of two businesses, and the difference decides
376
+ what you can buy, what it costs, and what can break it.
377
+
378
+ **1. They crawl the web and sell access to their own copy.**
379
+
380
+ A bot visits *websites* — millions of them — stores what it finds, and builds
381
+ an index. Your query is answered from **their copy**, not from Google. Google
382
+ is not a party to it and cannot block it; the only people who can refuse a
383
+ crawler are site owners, through `robots.txt`.
384
+
385
+ | who | index | ~$/1,000 | free tier |
386
+ |---|---|---|---|
387
+ | Google | ~400B+ pages | not for sale — Custom Search JSON API retires 1 Jan 2027 | — |
388
+ | Bing | ~100–200B | not for sale — public API shut 11 Aug 2025 | — |
389
+ | **Brave** | ~40B pages, ~100M refreshed a day | **$5–9** | 2,000/month |
390
+ | **Exa** | its own **semantic** index — by meaning, not keywords | **$7** | $20 signup + ~$10/month |
391
+ | **Parallel** | its own closed index | **$1** (Turbo) | **$5/month ≈ 5,000 searches** |
392
+ | Perplexity | its own crawler, sold as an answer | $5–14 | — |
393
+ | Tavily | own crawler **plus** bought-in feeds | ~$7–10 | 1,000/month |
394
+ | You.com | its own index and cache *(self-reported)* | per call | yes |
395
+ | Mojeek | ~8B, fully in-house | cheap | yes |
396
+
397
+ **2. They scrape Google's results page and sell you what it said.**
398
+
399
+ No index of their own. Your query goes to `google.com`, their bot reads that
400
+ results page, and you get the ten links back as JSON.
401
+
402
+ | who | ~$/1,000 |
403
+ |---|---|
404
+ | Serper | ~$1 |
405
+ | DataForSEO | ~$1–3 |
406
+ | SerpAPI | dearer, same idea |
407
+ | Bright Data | volume pricing |
408
+
409
+ **These are not Google partners.** There is no Google SERP partner programme —
410
+ the only official Google search APIs are the Custom Search JSON API (limited,
411
+ retiring) and Search Console, which returns your own site's data and nobody
412
+ else's. Tier two scrapes Google against its Terms of Service, and the proof is
413
+ in what the money buys: thousands of rotating residential IP addresses, captcha
414
+ solving and constant selector repair. **A partner would need none of that.**
415
+
416
+ So the two kinds fail in completely different ways. A crawler can go stale.
417
+ A scraper can get blocked — and the blocking is not a bug to fix, it is the
418
+ other side pushing back.
419
+
420
+ Which to use is not a matter of quality:
421
+
422
+ - **Where does our page rank on Google?** Only Google knows Google's ranking,
423
+ so that answer can only come from tier two. It is why rank-desk uses
424
+ DataForSEO.
425
+ - **Find me good pages about X?** Tier one. You want good sources, not
426
+ Google's exact ordering, and a licensed index is never refused.
427
+
428
+ One more thing the marketing blurs: **searching is almost never live.** Tier
429
+ one answers from a stored copy made hours or months ago. "Real-time" usually
430
+ means a fresh index plus a live fetch of the top few results. Only `web action=fetch`
431
+ and `web action=browse` touch a page as it is right now.
432
+
433
+ ### Searching somewhere else
434
+
435
+ `search` is the only action whose *index* you can buy from
436
+ someone else, and what you are buying is **an index we cannot crawl** — not
437
+ a faster endpoint. Name who answers and the tool the model sees does not
438
+ change: same name, same arguments, same shape back.
439
+
440
+ The call's options hold whoever answers. `count` trims to at most that
441
+ many. `site=` is checked on what comes back, so a result from another
442
+ domain is never shown, even from an API with no domain filter of its own
443
+ (Brave also gets `site:` in the query; Parallel is asked for 20 so enough
444
+ are left after the check). `site=` is a domain: `https://www.Example.com/`
445
+ is read as `example.com`, its subdomains included, and one with a path
446
+ (`example.com/blog`) is refused. `time_range=` goes to every API
447
+ that can filter by date; Parallel, Jina and DataForSEO cannot, and a call
448
+ that asks them for one is refused with that reason rather than answered
449
+ for every date. `engines=`, `categories=` and `page=` are the account's
450
+ own engine's, and an API agent that passes them is told so.
451
+
452
+ ```yaml
453
+ ---
454
+ name: researcher
455
+ tools: [web]
456
+ web:
457
+ search: exa # short form — the key is EXA_API_KEY in the vault
458
+ ---
459
+ ```
460
+
461
+ ```yaml
462
+ web:
463
+ search: # long form — your own vault name
464
+ name: exa
465
+ key: ${MY_EXA_KEY}
466
+ ```
467
+
468
+ The long form is the same two spellings `provider:` takes, and `key:` is a
469
+ reference, never a value: a credential written into the file is refused.
470
+
471
+ Two kinds of name, and they run in different places:
472
+
473
+ | `web.search:` | kind | whose index | where it runs |
474
+ |---|---|---|---|
475
+ | *unset* | ours | the account's own engine | in the run's sandbox |
476
+ | `brave` | search API, your key | Brave — ~40B pages, ~100M refreshed a day; the index Claude searches | **our tool, in the sandbox** |
477
+ | `exa` | search API, your key | Exa — its own semantic index, by meaning | our tool |
478
+ | `parallel` | search API, your key | Parallel — its own closed index; $1 / 1k, 5,000 a month free | our tool |
479
+ | `tavily` | search API, your key | Tavily — own crawler plus bought-in feeds | our tool |
480
+ | `you` | search API, your key | You.com — its own index and cache *(self-reported)* | our tool |
481
+ | `jina` | search API, your key | Jina — each result arrives with its page already read | our tool |
482
+ | `firecrawl` | search API, your key | Firecrawl — open-source core | our tool |
483
+ | `perplexity` | search API, your key | Perplexity's own crawl — its Search API returns results, not an answer | our tool |
484
+ | `linkup` | search API, your key | Linkup — agent-shaped, each result with its content | our tool |
485
+ | `serper` | **SERP scraper**, your key | Google's results page, read for you — ~$1 / 1k | our tool |
486
+ | `serpapi` | SERP scraper, your key | Google's results page — the dearest, 80+ engines | our tool |
487
+ | `dataforseo` | SERP scraper, your key | Google's results page — $0.60 / 1k; what rank-desk uses | our tool |
488
+ | `anthropic` | model provider | Brave, licensed | **Anthropic's servers** — `web_search_20250305` |
489
+ | `zai` | model provider | Zhipu's own, China-weighted | z.ai's endpoint, the *same* server tool |
490
+ | `openrouter` | model provider | native where the model has one, Exa otherwise | OpenRouter, as a plugin |
491
+ | `openai` | model provider | Bing plus OpenAI's own crawl | OpenAI, Responses-shaped |
492
+ | `kimi` | model provider | Moonshot's own, China-weighted | Moonshot, `$web_search` |
493
+ | `deepseek` | — | **none.** `check` refuses the word | — |
494
+ | `bing` · `google` | — | **not for sale.** Bing's API retired 11 Aug 2025; Google's retires Jan 2027 — both survive only as grounding inside their own models. `check` says so | — |
495
+ | `apify` | — | a marketplace, not an endpoint — reach an actor as an http tool file | — |
496
+
497
+ The difference in the last column is the one to hold. A **search API** is
498
+ called by our own tool from the run's sandbox: the call is on the run record
499
+ with its query, it rides the egress proxy, and it works whichever model is
500
+ driving. A **model provider's** search runs on that provider's machines,
501
+ inside its own tool loop — cheaper to set up, but off the record and only
502
+ while that provider is the one answering.
503
+
504
+ Every search API's own response shape is mapped in the tool to the one the
505
+ model already knows:
506
+
507
+ | API | its results live at | one result is | mapped to |
508
+ |---|---|---|---|
509
+ | Brave | `web.results[]` | `title, url, description, extra_snippets[]` | title · url · snippet |
510
+ | Exa | `results[]` | `title, url, highlights[], summary, publishedDate` | title · url · snippet · date |
511
+ | Parallel | `results[]` | `title, url, excerpts[], publish_date` | title · url · snippet · date |
512
+ | Tavily | `results[]` | `title, url, content, score, published_date` | title · url · snippet · date |
513
+ | You.com | `results.web[]` | `title, url, description, snippets[], page_age` | title · url · snippet · date |
514
+ | Jina | `data[]` | `title, url, description, content, publishedTime` | title · url · snippet · date |
515
+ | Firecrawl | `data.web[]` | `title, url, description` | title · url · snippet |
516
+ | Perplexity | `results[]` | `title, url, snippet, date, last_updated` | title · url · snippet · date |
517
+ | Linkup | `results[]` | `name, url, content, type` | title · url · snippet |
518
+ | Serper | `organic[]` | `title, link, snippet, date, position` | title · url · snippet · date |
519
+ | SerpApi | `organic_results[]` | `title, link, snippet, date, position` | title · url · snippet · date |
520
+ | DataForSEO | `tasks[0].result[0].items[]` where `type: organic` | `title, url, description, rank_group` | title · url · snippet |
521
+
522
+ Each was read from its vendor's own reference on 2026-09-16, and Jina was
523
+ called live: `s.jina.ai` refuses without a key, `r.jina.ai` does not.
524
+
525
+ **The keys.** Store each once, under the name the API expects —
526
+ `foldrun secrets set EXA_API_KEY` — and the runtime declares it for every
527
+ step whose agent names that API; nobody writes it twice. The vault names:
528
+ `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY`, `PARALLEL_API_KEY`, `TAVILY_API_KEY`,
529
+ `YOU_API_KEY`, `JINA_API_KEY`, `FIRECRAWL_API_KEY`, `PERPLEXITY_API_KEY`,
530
+ `LINKUP_API_KEY`, `SERPER_API_KEY`, `SERPAPI_API_KEY`. The key goes through
531
+ the egress proxy and is filled in for that API's host and no other — in a
532
+ header, or in the URL for SerpApi, whose key is a query parameter; it is
533
+ never in the sandbox. DataForSEO speaks HTTP basic auth, so its entry holds
534
+ the encoded pair: `printf 'LOGIN:PASSWORD' | base64`, stored as
535
+ `DATAFORSEO_AUTH_BASIC`.
536
+
537
+ The SERP scrapers are the one kind to reach for when the question is about
538
+ Google itself — where a page ranks, what the SERP shows. For finding good
539
+ pages, an index is the better buy and is never refused.
540
+
541
+ That last row of the table is the one worth reading twice. DeepSeek's
542
+ endpoint is Anthropic-shaped, so every shape check passes, and there is
543
+ still no search behind it. Speaking a wire is not the same as running a
544
+ tool on it — which is why `check` refuses it offline rather than letting a
545
+ desk search nothing for a month.
546
+
547
+ ### Fetching through someone else
548
+
549
+ `web action=fetch` is ours by default — free, one HTTP request, on the record —
550
+ and that is enough for most of the web. A fetch API is for the pages ours
551
+ cannot read: one that refuses a plain request, or twenty that would be slow
552
+ one at a time. Same switch, same two spellings:
553
+
554
+ ```yaml
555
+ web:
556
+ fetch: jina # or firecrawl, exa, tavily, parallel, zyte, scrapingbee
557
+ ```
558
+
559
+ | `web.fetch:` | tier | what it gives back | per call | key |
560
+ |---|---|---|---|---|
561
+ | *unset* | ours | the page, at the fidelity asked | 1, or up to 20 with `urls=` | — |
562
+ | `jina` | reader | clean markdown, text or html | 1 | **optional** — 20 a minute without one |
563
+ | `firecrawl` | reader | main-content markdown or html, boilerplate stripped | 1 | `FIRECRAWL_API_KEY` |
564
+ | `exa` | extract | text, from Exa's own cache unless told to fetch fresh — the tool tells it | up to 100 | `EXA_API_KEY` |
565
+ | `tavily` | extract | markdown or text, failures listed beside successes | up to 20 | `TAVILY_API_KEY` |
566
+ | `parallel` | extract | full-page markdown; handles JavaScript pages and PDFs | up to 20 | `PARALLEL_API_KEY` |
567
+ | `zyte` | **unblocker** | the page rendered in a real browser behind a proxy pool; pays per success | 1 | `ZYTE_API_KEY_BASIC` |
568
+ | `scrapingbee` | **unblocker** | the page rendered in a real browser (`render_js`) behind a proxy pool; credits per call | 1 | `SCRAPINGBEE_API_KEY` |
569
+ | `anthropic` | model provider | a small model's reading of the page, on Anthropic's servers | 1 | — |
570
+
571
+ A provider answers `markdown`, `text` and `html`. The modes only a raw read
572
+ can answer — `meta`, `links`, `raw` — are always read directly, and the tool
573
+ says so rather than quietly switching.
574
+
575
+ Two of these need a word. **Zyte** authenticates with HTTP basic auth, and
576
+ the egress proxy fills a placeholder verbatim, so its vault entry holds the
577
+ encoded form: `printf 'KEY:' | base64`, stored as `ZYTE_API_KEY_BASIC`.
578
+ **Exa** serves a fetch from its own index by default; a fetch is expected to
579
+ be live, so the tool asks for the page as it is now (`maxAgeHours: 0`).
580
+
581
+ What each hands back, and what the tool turns it into:
582
+
583
+ | API | shape | the tool reads |
584
+ |---|---|---|
585
+ | Jina Reader | `data.{title, url, content, publishedTime, warning}` | `content`, in the format asked |
586
+ | Firecrawl | `data.{markdown \| html, metadata.{url, statusCode}}` | the format asked |
587
+ | Exa Contents | `results[].{url, title, text}` + `statuses[].{status, source}` | `text`; `source` noted |
588
+ | Tavily Extract | `results[].{url, raw_content}` + `failed_results[].{url, error}` | both arrays, always |
589
+ | Parallel Extract | `results[].{url, title, full_content, excerpts[]}` + `errors[].{url, error_type}` | `full_content`, else the excerpts |
590
+ | Zyte | `browserHtml` (or base64 `httpResponseBody`), `url`, `statusCode` | html, converted here |
591
+
592
+ One cost worth stating plainly for both switches: a *model provider's*
593
+ search or fetch runs on **their** servers, off the run record and outside
594
+ the egress proxy. A *search or fetch API* does not — it is called by our own
595
+ tool, from the sandbox, with your key filled in at the boundary. Prefer the
596
+ second whenever the same index is available both ways.
597
+
598
+ The one to know is `web action=browse`: a real headless browser that opens a page,
599
+ renders its JavaScript, and can then click, fill, press keys, select, scroll,
600
+ screenshot, print to PDF, paste HTML into a rich editor, hand a file to a
601
+ file picker, save a download, step into an iframe or a popup tab, and pull
602
+ structured rows out — all from one `actions` list. Chromium by default;
603
+ `browser: firefox` or `browser: webkit` (Safari's engine) render a page the
604
+ way those do, and `device: "iPhone 15"` serves the mobile layout. `block:
605
+ "image,font,media"` makes a heavy page render in a fraction of the time, and
606
+ `capture: "**/api/*"` saves the JSON the page fetched for itself instead of
607
+ parsing what it drew. `headers` sends extra request headers — a `${NAME}` in
608
+ a value is read from that secret inside the run, the http tools' own
609
+ convention — and `auth` and `proxy` name secrets for HTTP basic auth and a
610
+ proxy. `video: true` records the pages as `.webm`; `trace: true` saves a
611
+ step-by-step replay for trace.playwright.dev, which is what to ask for when a
612
+ selector fails. `mode: markdown` is the read to reach for when the
613
+ structure matters — headings, lists, tables and every link's target kept,
614
+ navigation and footer dropped, at a fraction of what the HTML costs;
615
+ `mode: meta` is the head as an SEO desk reads it (title, description,
616
+ canonical, og, hreflang, every JSON-LD block, the heading outline, images
617
+ without alt); `mode: table` is every table as rows; `mode: network` is the
618
+ page's own status, redirect chain and headers, then every request it
619
+ made — the link check, and how to find the endpoint a `capture` should
620
+ ask for. `mode: aria` reads the accessibility
621
+ tree — often the cleanest read of a complex app — with a number on every
622
+ element, so the next call acts on `"@e4"` instead of a guessed selector. `mode: map` lists every URL on a site
623
+ (sitemap plus links, same host); `mode: crawl` reads the site page by page
624
+ into `outputs/crawl/` with an index, robots.txt honoured, three tabs at a
625
+ time, resuming from where it stopped when called again. A hundred sites is
626
+ a flow fanning out one step per site, not one call — parallelism is the
627
+ flow's job, and each step gets its own browser.
628
+
629
+ An `extract` step is the way to read a directory. One row per match, each
630
+ field a selector inside it, `@attr` for an attribute; the rows are the
631
+ output and the whole set lands in `outputs/extracted.json` uncapped:
632
+
633
+ ```text
634
+ web(action="browse", url="https://example.com/directory", block="image,font",
635
+ actions='[{"extract": ".listing",
636
+ "fields": {"name": "h3", "phone": ".tel", "url": "a@href"}}]')
637
+ ```
638
+
639
+ For a site that signs in without a password
640
+ (magic link, Google), copy the session cookies once from a signed-in browser
641
+ (DevTools → Application → Cookies), store the Cookie header line as a
642
+ secret, declare the secret on the agent and pass its **name** as `cookies`:
643
+
644
+ ```sh
645
+ foldrun secrets set MEDIUM_COOKIES # paste: sid=…; uid=…
646
+ ```
647
+
648
+ ```yaml
649
+ tools: [web]
650
+ secrets: [MEDIUM_COOKIES]
651
+ ```
652
+
653
+ ```text
654
+ web(action="browse", url="https://medium.com/new-story", cookies="MEDIUM_COOKIES",
655
+ wait_for="[data-testid=editorTitleParagraph]",
656
+ actions='[{"fill": "[data-testid=editorTitleParagraph]", "text": "Title"},
657
+ {"paste": "[data-testid=editorParagraphText]", "file": "outputs/article.html"},
658
+ {"wait": 5000}]')
659
+ ```
660
+
661
+ The cookie value is read from the secret's environment variable inside the
662
+ run and appears nowhere in arguments, output or the run record; the last
663
+ stderr line is the page's final URL, which is how a caller learns the id of
664
+ the draft a save just created. That promise is why `js` — an expression
665
+ evaluated in the page, for the case a selector map cannot express — refuses
666
+ to run in the same call as `cookies`, or in a session those cookies seeded:
667
+ `document.cookie` is one expression away. A site that answers with a bot
668
+ challenge gets that page back as the output — in any engine, unless the
669
+ call names a `captcha` secret.
670
+
671
+ **Captcha, on your own account.** `captcha: "CAPSOLVER_KEY"` names a secret
672
+ holding your solver's API key (`captcha_solver` picks `capsolver`, the
673
+ default, `2captcha` or `anticaptcha` — the three speak one protocol). With
674
+ it, a reCAPTCHA v2, hCaptcha or Turnstile widget on the page is read for
675
+ its site key, sent to the solver with the page URL, and the token that
676
+ comes back is written into the widget's response field and handed to its
677
+ callback — what the widget would have done. The form is not submitted: the
678
+ next action does that, as for a person. One stderr line per solve — type,
679
+ solver, seconds — and a site key is solved once per page, so a re-check
680
+ after an action does not bill twice. The platform ships no solver and
681
+ pays for none; a solve is a call from the sandbox to your vendor on your
682
+ key. Not covered, and said so rather than implied: reCAPTCHA v3 (no
683
+ widget, the site scores the session), Cloudflare's full-page managed
684
+ challenge, Arkose, GeeTest — those come back as the page they are. Tested
685
+ against a stand-in solver and stand-in widgets in real Chromium, not yet
686
+ against a real solver account.
687
+
688
+ ### Browsing somewhere else
689
+
690
+ `web action=browse` renders in the account's own browser pod. Name a vendor and the
691
+ same tool — every mode, every action — connects over CDP to a browser on
692
+ their machines instead. For the pages that refuse ours: a residential pool,
693
+ built-in unblocking, a different reputation.
694
+
695
+ ```yaml
696
+ web:
697
+ browse: browserbase # or steel, hyperbrowser, browserless, brightdata, zenrows
698
+ ```
699
+
700
+ | `web.browse:` | what it is | key |
701
+ |---|---|---|
702
+ | *unset* | the account's own pod, whichever engine the block or the call asks for | — |
703
+ | `browserbase` | hosted Chromium with stealth; a session is opened for the call | `BROWSERBASE_API_KEY` |
704
+ | `steel` | hosted Chromium, open-source core; a session per call | `STEEL_API_KEY` |
705
+ | `hyperbrowser` | hosted Chromium with built-in unblocking; a session per call | `HYPERBROWSER_API_KEY` |
706
+ | `browserless` | hosted Chrome, one websocket with the token | `BROWSERLESS_TOKEN` |
707
+ | `brightdata` | Chromium behind a residential proxy pool, port 9222 — for the sites that refuse everything else | `BRIGHTDATA_BROWSER_AUTH` — the zone's whole `user:pass` |
708
+ | `zenrows` | hosted Chromium with residential IPs and fingerprinting, one websocket with the key | `ZENROWS_API_KEY` |
709
+
710
+ Two things differ from the search and fetch switches. **The key is in the
711
+ step**, not at the proxy: CDP is a websocket, which the egress proxy does
712
+ not carry, so the wrapper holds the real value the way it already holds a
713
+ cookie secret, and opens the vendor's session itself. And **`proxy=` and
714
+ `block=` do not reach a vendor's browser** — those are launch options, and
715
+ the vendor launched it; ask the vendor's session for them instead, below.
716
+ The tool says so on the run log when both are present.
717
+
718
+ **What the vendor's session is asked for — `vendor_session:`.** The vendor's own
719
+ options, under one set of names, each mapped onto that vendor's fields as its
720
+ docs name them (read 2026-09-29). It was called `session:` until 2026-10-01,
721
+ which a call's `session=` — a named saved login, a different thing — also is;
722
+ `session:` is still read, and `foldrun check` and the deploy warn with the
723
+ new name. A file that writes both is refused.
724
+
725
+ ```yaml
726
+ web:
727
+ browse:
728
+ via: browserbase
729
+ vendor_session:
730
+ proxy: { country: AU, city: Sydney } # true · { country, state, city } · { own: MY_PROXY_SECRET }
731
+ captcha: true # the vendor solves them
732
+ stealth: true
733
+ region: ap-southeast-1
734
+ timeout: 30m
735
+ keep: client-portal # a saved login, reused run to run
736
+ record: false
737
+ block: [ads] # ads · trackers · cookies (banners)
738
+ options: { browserSettings: { viewport: { width: 1440, height: 900 } } } # the vendor's own fields, as written
739
+ ```
740
+
741
+ | `vendor_session:` | browserbase | steel | hyperbrowser | browserless | brightdata | zenrows |
742
+ |---|---|---|---|---|---|---|
743
+ | `proxy: true` | `proxies` | `useProxy` | `useProxy` | `proxy=residential` | always on | always on |
744
+ | `proxy.country` · `state` · `city` | ✓ ✓ ✓ | ✓ ✓ ✓ | ✓ ✓ ✓ | ✓ — ✓ | ✓ — — (user name) | ✓ — — |
745
+ | `proxy.own` | `external` | `proxyUrl` | `proxyServer` (Enterprise) | `externalProxyServer` (paid) | — | — |
746
+ | `captcha` | `solveCaptchas` (on by default) | `solveCaptcha` | `solveCaptchas` | `solveCaptchas` | `Captcha.setAutoSolve` | — |
747
+ | `stealth` | `verified` (Scale) | humanized input | `useStealth` | the `/stealth` route | built in | built in |
748
+ | `region` | us-west-2 · us-east-1 · eu-central-1 · ap-southeast-1 | — | us · us-central · us-west · us-east · asia-south · europe-west | sfo · lon · ams | — | — |
749
+ | `timeout` | 60s–6h | 15s–24h | 1m–12h | the connection's | — (5 min idle, 60 max) | 1m–15m |
750
+ | `keep` | a context | a profile | a profile | — | — | — |
751
+ | `record` | `recordSession` (on by default) | — | `enableWebRecording` | — | — | — |
752
+ | `block` | ads | ads | ads · trackers · cookies | ads | ads · cookies | — |
753
+ | `options` | into the request | into the request | into the request | query parameters | — | query parameters |
754
+
755
+ A setting the vendor has no option for fails `foldrun check`, naming the
756
+ vendors that do — the same rule as an action. `keep` stores the vendor's
757
+ context or profile id in the workspace's `state/web-browse/`, so the next run
758
+ signs in as the last one left off; it uses the vendor's own browser context
759
+ as it stands, so `user_agent`, `device`, `locale`, `timezone` and
760
+ `identities` beside it are refused. ZenRows' browser refuses a changed user
761
+ agent or device, so those are refused with it. A Steel session is released,
762
+ and a Hyperbrowser one stopped, when the call ends — a Steel session left
763
+ alone runs, and bills, to its timeout.
764
+
765
+ A vendor that cannot be reached is not fatal: the tool says which one
766
+ refused and renders in the step, as it does when the account pod is away.
767
+ Browserless is SSPL-licensed — its cloud is a plain vendor, but the free
768
+ self-hosted path is out for a paid service. Apify is not on this list: it is
769
+ an actor marketplace, not a browser endpoint.
770
+
771
+ ### The browser, in full
772
+
773
+ Every argument, action and mode the gallery `web action=browse` takes. The first seven
774
+ arguments are the original ones, so a call that passed only `url` behaves
775
+ exactly as it always did.
776
+
777
+ **Arguments**
778
+
779
+ | argument | what |
780
+ |---|---|
781
+ | `url` | the page to open — the one required argument |
782
+ | `wait_for` | a CSS selector to wait for before reading or acting |
783
+ | `mode` | how to read the page — the modes below (default `text`) |
784
+ | `actions` | a JSON array of steps — the actions below |
785
+ | `session` | a name; cookies and logins persist across calls in the run |
786
+ | `cookies` | the NAME of a secret holding a site's sign-in cookies |
787
+ | `cookie_domain` | the domain those cookies belong to |
788
+ | `browser` | `chromium` (the default, the open-source build), `chrome` (real Google Chrome; falls back to Chromium where the image lacks it), `firefox`, `safari` (WebKit; `webkit` also works), `lightpanda` (runs the JavaScript, never draws) or `obscura` (as light, draws, cannot intercept requests). What each cannot do is under [Engines](#engines); a call that asks for it is refused by name |
789
+ | `engine` | the same setting, under the name the `web.browse:` block uses; either on a call, both only if they agree |
790
+ | `device` | a device to emulate by Playwright name: `"iPhone 15"`, `"Pixel 7"` |
791
+ | `block` | resource types and host globs not to load: `"image,font,*.doubleclick.net"` |
792
+ | `capture` | a URL glob; every matching response is saved under `outputs/captures/` |
793
+ | `js` | an expression evaluated in the page; refused with `cookies` |
794
+ | `geolocation` | `"lat,lng"` the page will see |
795
+ | `permissions` | permissions granted up front: `geolocation`, `notifications`, `clipboard-read`, `camera`, `microphone` |
796
+ | `locale` | `en-AU` |
797
+ | `timezone` | `Australia/Sydney` |
798
+ | `headers` | a JSON object of request headers; a `${NAME}` in a value is read from that secret; `cookie` is refused |
799
+ | `user_agent` | an explicit user-agent, beating the engine's and the device's |
800
+ | `auth` | the NAME of a secret holding `user:pass` for HTTP basic auth |
801
+ | `captcha` | the NAME of a secret holding your captcha solver's API key; off by default; passes reCAPTCHA v2 / hCaptcha / Turnstile widgets, the next action submits |
802
+ | `captcha_solver` | `capsolver` (default), `2captcha` or `anticaptcha` |
803
+ | `proxy` | the NAME of a secret holding a proxy URL, `http://user:pass@host:port` — every engine honours it for plain and TLS pages, and a missing secret is refused by name. Chromium also sends its own start-up traffic through it — a time check, an update check, an accounts ping, ~10 small requests per launch — which a metered proxy will bill; Firefox and WebKit do not |
804
+ | `video` | `"true"` or a `.webm` path — records every tab |
805
+ | `trace` | `"true"` or a `.zip` path — a Playwright trace for trace.playwright.dev |
806
+ | `dialogs` | `accept` or `dismiss` (default) for alert, confirm and prompt |
807
+ | `dialog_text` | what to answer a `prompt()` with, when accepting |
808
+ | `color_scheme` | `light` or `dark` |
809
+ | `limit` | `map` and `crawl`: the most pages (500 and 50 by default, up to 5000) |
810
+ | `interactive` | with `mode=aria`: `true` lists only the numbered things you can act on, one per line |
811
+ | `expect` | a JSON array of claims checked after the actions, each the shape of an `expect` step; every one is reported, any failure fails the call |
812
+ | `routes` | a JSON array of `mock` steps in place before the first request |
813
+ | `identity` | a name from the agent's `web: {browse: identities:}` map — engine, device, locale, timezone, user agent, proxy, headers, cookies as one word |
814
+ | `state` | a name; the login (cookies and localStorage) is loaded from `workspace/state/browser/<name>.json` first and saved back at the end — across runs. Refused with `cookies` or `storage` from a secret |
815
+ | `har` | `"true"` or a `.har` path — every request and response with bodies, as an HTTP Archive |
816
+ | `live` | `"true"` keeps the page open for the next call in the step; `"end"` closes it |
817
+ | `within` | with `mode=aria` or an annotated screenshot: a selector for the part of the page to number; its numbers are found in it again |
818
+ | `depth` | with `mode=aria`: how many levels of the tree, 1–50 |
819
+ | `compact` | with `mode=aria`: `true` drops empty containers — a generic or group with no name and no text |
820
+ | `delta` | with `mode=aria`: `true` returns only what changed since the last snapshot of the same page in this session, or one line saying nothing did |
821
+ | `annotate` | with `mode=screenshot`: `true` draws a box and its number on every element that can be acted on, and lists them — the numbers are `"@e4"` |
822
+ | `visual` | with `mode=diff`: `true` compares pixels, not words — what share changed, where, and `outputs/diff.png` with the changes in red |
823
+ | `init` | workspace paths of scripts run in every page before its own |
824
+ | `allowed_domains` | the only domains the browser may reach — pages, scripts, fetches, sockets. From the block, a call may narrow it, never widen it |
825
+ | `boundaries` | `true` wraps the page's words in markers with a fresh nonce |
826
+ | `webgpu` | `true` gives the page WebGPU, on a software GPU where there is none |
827
+ | `extensions` | workspace paths of unpacked Chrome extensions; the browser then runs in the step, in Chromium |
828
+ | `ignore_https_errors` | `true` opens a page whose certificate does not check out |
829
+ | `cpu_profile` | a `.cpuprofile` path — where the page's JavaScript spent its time, for DevTools |
830
+ | `state_key` | the NAME of a secret whose value encrypts the `state` login at rest (AES-256-GCM) |
831
+ | `max_output` | return at most this many characters, 200 to 18,000 — never more than the tool's own cap |
832
+ | `against` | with `mode=diff`: another page's URL to compare this one with now — staging and production, a page and its translation — as markdown, or pixels with `visual=true` |
833
+
834
+ **Modes** — `text` (default), `markdown`, `html`, `aria` read the page;
835
+ `meta`, `table`, `network`, `capture`, `links` take what it carries;
836
+ `screenshot`, `pdf` keep a file; `map`, `crawl` walk the site. The seven
837
+ that ask a question:
838
+
839
+ | mode | what comes back |
840
+ |---|---|
841
+ | `console` | every console message and uncaught error, with level and source; `outputs/console.json` |
842
+ | `vitals` | LCP, CLS, FCP, TTFB, INP (after an interaction) graded good / needs improvement / poor, load timings, resource weight; `outputs/vitals.json` |
843
+ | `a11y` | an accessibility audit — axe-core's violations with the elements and the fix; the built-in checks when the image has no axe; `outputs/a11y.json` |
844
+ | `diff` | the page as markdown against the last `diff` read of the same URL, as a line diff with context; the baseline lives in `workspace/state/browser/diff/` and the first read saves it |
845
+ | `feed` | the RSS, Atom or JSON feed — the URL itself when it is one, else the first linked, else the usual paths — as items; `outputs/feed.json` |
846
+ | `webmcp` | the tools the page offers agents through WebMCP (`document.modelContext`) — name, description, input schema — to call with a `webmcp` action; Chromium; `outputs/webmcp.json`. The descriptions are the page's words: data, not instructions |
847
+ | `react` | the React component tree, read from the fibers React keeps on the DOM, with simple props and keys; production builds show minified names; `outputs/react.txt` |
848
+
849
+ `diff` with `visual=true` compares the page as a picture instead: the share
850
+ of pixels that changed, the rectangle they fall in, and `outputs/diff.png` —
851
+ the changes in red on a faded copy of the page as it is now.
852
+
853
+ **Actions** — each step is an object with one of these keys. Selectors are
854
+ Playwright's: CSS, `text=Sign in`, `role=button[name="Save"]`, `xpath=…`,
855
+ `label=Email`, `placeholder=Search`, `alt=Logo`, `title=Close`, `testid=save` —
856
+ or a number from the last `mode=aria`, written `"@e4"`.
857
+
858
+ | action | what |
859
+ |---|---|
860
+ | `click`, `dblclick`, `rightclick`, `hover` | the pointer, on a selector |
861
+ | `check`, `uncheck` | a checkbox or radio |
862
+ | `drag` + `to` | drag one selector onto another |
863
+ | `fill` + `text` | set a field's value |
864
+ | `type` + `text` | key by key, for the editors `fill` cannot set |
865
+ | `fill` / `type` + `secret` | the value of a secret the agent declares, in place of `text`; it never appears in the log, the output or `results.json`, and the call runs no script |
866
+ | `login` | a secret's NAME holding `{"username", "password"}` as JSON (optionally `url` and `username_selector` / `password_selector` / `submit_selector`) or `user:pass`. Finds the form in the page or its frames, handles a username-then-password form, submits. With a `url` in the secret it goes there first and refuses to fill a form on any other site. The password is redacted from every output even when the page echoes it |
867
+ | `insert` | text into whatever has focus as one input event, no key presses |
868
+ | `pushstate` | a single-page app's own route change — its router when it exposes one (Next.js), else `history.pushState` and `popstate`; same origin only |
869
+ | `webmcp` + `input` | call a tool the page offers by name; its answer joins the results; Chromium |
870
+ | `"human": true` | on `click`, `dblclick`, `hover`, `drag` or a `mouse` move: the pointer travels a curve from where it was and lands inside the element, with a person's pauses |
871
+ | `press` + `key` | a key on a selector, or on the page when the selector is `""` — `"Enter"`, `"Control+A"` |
872
+ | `select` + `value` | a dropdown option |
873
+ | `wait` | a selector, or a number of milliseconds |
874
+ | `goto` | navigate to a URL |
875
+ | `scroll` | `"bottom"`, `"top"`, or a number of pixels |
876
+ | `screenshot` (+ `selector`, `full`, `clip`, `quality`, `settle`, `annotate`) | the full page to `outputs/<name>.png`, scrolled once first so lazy images are in it; one element with `selector`; the viewport alone with `"full": false`; a rectangle with `"clip": [x, y, w, h]`; `"settle": false` skips the scroll; `"annotate": true` draws every actionable element's number on it. A `.jpg` path is JPEG at `"quality"` (default 60) — a fraction of the PNG's size for the same read |
877
+ | `pdf` | the page, to `outputs/<name>.pdf` — Chromium only |
878
+ | `paste` + `html` or `file` | a real paste of HTML into a rich editor; headings, links and lists survive |
879
+ | `upload` + `file` | hand a file to a control that opens a file picker |
880
+ | `download` + `to` | from a control or a URL, saved under `outputs/` |
881
+ | `extract` + `fields` (+ `limit`) | one JSON row per match; a field is a selector inside it, `a@href` reads an attribute; the rows are the output and the whole set is written to `outputs/extracted.json` |
882
+ | `frame` | scope the steps after it to an iframe; `""` returns to the page. A selector missing from the page is also looked for inside every frame automatically |
883
+ | `tab` | `1` or `"last"` — switch to a tab a click opened; `"new"` (+ `url`, + `label`) opens one, a label switches to it by name, `"close"` closes the current |
884
+ | `back`, `forward`, `reload` | `true` — history, and the page again |
885
+ | `focus`, `clear`, `highlight` | focus a control; empty a field; outline elements for the next screenshot |
886
+ | `scroll` | also takes a selector: bring it into view |
887
+ | `keyboard` + text, `keydown` / `keyup` + key | type into whatever has focus; hold and release a key |
888
+ | `mouse` (`click`, `dblclick`, `move`, `down`, `up`) + `x`, `y` | the pointer at page pixels, for what no selector names |
889
+ | `wheel` + pixels (+ `dx`) | scroll by wheel; negative is up |
890
+ | `tap`, `swipe` (`up` / `down` / `left` / `right`, + `selector`) | touch: a tap on an element, a swipe's touch events at it or the page |
891
+ | `clipboard` (`write` + `text`, `read`) | the clipboard; a read joins the results |
892
+ | `wait` | also: `"load"`, `"domcontentloaded"`, `"networkidle"`, `"navigation"`; `"url"` + `matches`; `"text"` + `text`; `"response"` / `"request"` + `matches`; `"fn"` + `js`; a selector + `"gone": true`; any with `timeout` |
893
+ | `get` + `what` (`text`, `html`, `value`, `count`, `box`, `visible`, `checked`, `enabled`, `styles` (+ `props`), `attr` + `attr`) | a question; also `"url"`, `"title"`, `"localstorage"` / `"sessionstorage"` + `key`. Answers go to the results — the output when nothing else is — and `outputs/results.json` |
894
+ | `eval` + expression | its value joins the results; the same rules as `js` |
895
+ | `expect` | a claim: a selector (visible) with `text`, `value`, `count`, `gone`, `checked`, `enabled`, or `attr` + `equals` / `contains` / `matches`; or `"url"` / `"title"` / `"text"` + `contains` / `matches` / `equals`. Wrong, the step fails and the rest stop |
896
+ | `if` + `then` / `else` | the shape of `expect`, as a branch: lists of steps, either optional |
897
+ | `mock` + `json` / `body` / `file` / `status` / `headers`, or `abort` | answer for the server on every request matching the glob, from now on |
898
+ | `unmock` | lift a mock by its glob, or `"*"` |
899
+ | `offline` | `true` / `false` |
900
+ | `cookie` + `value` (+ `domain`, `path`), or `delete` | set one; delete one, or all with `"*"` |
901
+ | `localstorage`, `sessionstorage` + `value`, or `delete` | set a key; delete one, or clear with `"*"` |
902
+ | `viewport` `[w, h]` | resize |
903
+ | `dialog` (`accept` / `dismiss`, + `text`) | what later alert / confirm / prompt boxes get |
904
+ | `solve` | `true`: send the captcha widget on the page to the solver now; a selector + `into`: read an image captcha into a field. Needs `captcha=` |
905
+
906
+
907
+ **Numbered elements.** `mode=aria` gives every element a number and an
908
+ action names it as `"@e4"` — the model points at what it read rather than
909
+ writing CSS. The next call loads the page fresh, where Playwright's own
910
+ numbers mean nothing, so what is kept (in `outputs/.browser/refs.json`, per
911
+ `session`) is what the number pointed at: its role and name, and which of
912
+ the matches it was. `"@e4"` is found again by that. When it is gone, or the
913
+ page now has a different count of the same thing — a third "Delete" where
914
+ there were two — the step fails and says which, instead of clicking a
915
+ neighbour. Read the page with `mode=aria` again and use the new numbers.
916
+ A number read on one page is refused on another (a different origin or
917
+ path), with the same advice: read that page with `mode=aria` first.
918
+
919
+ ```
920
+ web(action="browse", url="https://example.com/cart", mode="aria", interactive=true)
921
+ → button "Delete" [ref=e5]
922
+ button "Delete" [ref=e7]
923
+ link "Checkout" [ref=e12] -> /checkout
924
+ web(action="browse", url="https://example.com/cart", actions='[{"click": "@e7"}]')
925
+ → @e7 = button "Delete" (2 of 2)
926
+ ```
927
+
928
+ Four options cut the snapshot down. `within=` numbers one part of the page
929
+ (a form, the main column), and a number made there is found there again.
930
+ `depth=` stops the tree at a level. `compact=true` drops the empty
931
+ containers. `delta=true` returns only what changed since the last snapshot
932
+ of the same page in the same `session` — with `live: true`, that is the way
933
+ to see what one click did without reading the page again.
934
+
935
+ `mode=screenshot annotate=true` puts the same numbers on a picture: a box
936
+ and its number over every element that can be acted on, and the list under
937
+ it. The numbers are saved like `mode=aria`'s, so `"@e4"` works in the next
938
+ call — for an icon button with no name, a canvas, or a layout the tree
939
+ cannot describe.
940
+
941
+ **Modes** — four groups, and no two modes answer the same question.
942
+
943
+ *To read the page.* Four fidelities of the same read; pick by what you
944
+ need from it.
945
+
946
+ | mode | when |
947
+ |---|---|
948
+ | `text` | you want what a person sees, as plain text — the default |
949
+ | `markdown` | the structure matters: headings, lists, tables, emphasis, code, and where each link goes — navigation and chrome dropped; also `outputs/page.md` |
950
+ | `html` | you need selectors for a later `actions` call |
951
+ | `aria` | you need the controls: the accessibility tree, often the cleanest read of a complex app, every element numbered for `"@e4"` |
952
+
953
+ *What the page carries.*
954
+
955
+ | mode | what comes back |
956
+ |---|---|
957
+ | `meta` | its head as JSON: title, description, canonical, robots, og/twitter, hreflang, feeds, JSON-LD, heading outline, word/link/image counts — an image with no `alt` attribute is `imagesWithoutAlt`, one with `alt=""` is `imagesDecorative` (correct markup, not a fault); also `outputs/meta.json` |
958
+ | `table` | every table as JSON rows keyed by its header row, with the heading above it; also `outputs/tables.json` |
959
+ | `network` | its own status, every redirect hop, content-type and headers first (Set-Cookie withheld), then every request it made — status, type, bytes, ms; also `outputs/network.json`. The link check, and how to find what `capture` should ask for |
960
+ | `capture` | the bodies the `capture` glob matched |
961
+ | `links` | every link on the page, text and href |
962
+
963
+ *Files.*
964
+
965
+ | mode | what comes back |
966
+ |---|---|
967
+ | `screenshot` | `outputs/page.png` — the full page, settled first; the output ends with `Read outputs/page.png to see it.` |
968
+ | `pdf` | `outputs/page.pdf` — Chromium only |
969
+
970
+ **Looking at what you captured.** A screenshot is a file, and nothing
971
+ shows it to the agent by itself. An agent with `read` opens it like any
972
+ other file — `Read outputs/page.png` — and sees the page. That is the step
973
+ that turns a screenshot from evidence for a person into something the agent
974
+ can reason about: a layout, a chart, whether a banner covered the content.
975
+ For anything the agent will read back, a `.jpg` path with `"quality": 60`
976
+ costs a fraction of the PNG to look at and reads the same; keep PNG for
977
+ pixel-exact evidence a person will inspect.
978
+
979
+ *The site.*
980
+
981
+ | mode | what comes back |
982
+ |---|---|
983
+ | `map` | every URL on the site: sitemap plus links, same host, to `outputs/map.txt` |
984
+ | `crawl` | the site page by page to `outputs/crawl/` with an index; robots.txt honoured; the same call again continues |
985
+
986
+ Whatever the mode, the page's own failures — an uncaught error, console
987
+ errors, a script the server refused or that never loaded — are one stderr
988
+ line each, so an empty page comes with its reason. A crawl retries a wire
989
+ error, a 5xx or a 429 once before recording it.
990
+
991
+ Not there, on purpose: reading cookies or localStorage back (a `get` of
992
+ storage is refused where a secret seeded it), an `edge` engine (it is
993
+ Chromium), and anything that helps a page not look automated — `"human":
994
+ true` moves the pointer like a person, and changes nothing else about
995
+ the browser.
996
+
997
+ ### Engines
998
+
999
+ Six engines, and this table is the one place that says what each can do.
1000
+ The agent's file names the engine as `engine:` in its `web.browse:` block;
1001
+ a call names it as `browser=` (or `engine=`, the same setting). Both take
1002
+ every spelling below, and `webkit` is accepted for `safari`.
1003
+
1004
+ | | `chromium` | `chrome` | `firefox` | `safari` | `lightpanda` | `obscura` |
1005
+ |---|---|---|---|---|---|---|
1006
+ | what it is | the open-source build — **the default** | real Google Chrome; Chromium where the image lacks it | Firefox | WebKit, Safari's engine | runs the JavaScript, never draws | draws, cannot intercept requests |
1007
+ | read, drive, `aria`, actions | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
1008
+ | `screenshot`, `vitals`, `annotate`, `visual` | ✓ | ✓ | ✓ | ✓ | — | ✓ |
1009
+ | `pdf` (mode or action) | ✓ | ✓ | — | — | — | ✓ (a raster picture of the page) |
1010
+ | `video`, `trace`, `har` | ✓ | ✓ | ✓ | ✓ | — | — |
1011
+ | `device` | ✓ | ✓ | ✓ | ✓ | — | — |
1012
+ | `session` | ✓ | ✓ | ✓ | ✓ | — | — |
1013
+ | `headless: false` | ✓ | ✓ | ✓ | ✓ | — | — |
1014
+ | `proxy` | ✓ | ✓ | ✓ | ✓ | — | — |
1015
+ | `block`, `routes`, `mock`, `captcha`, `allowed_domains` | ✓ | ✓ | ✓ | ✓ | ✓ | — |
1016
+ | `live`, `webgpu`, `extensions`, `webmcp`, `cpu_profile` | ✓ | ✓ | — | — | — | — |
1017
+ | `foldrun login --engine` | ✓ | ✓ (real Chrome when installed, else Chromium — it says which) | ✓ | ✓ | — | — |
1018
+
1019
+ A call that asks an engine for something marked — is refused before a browser opens, in a
1020
+ sentence naming what and why (`video needs a browser that renders;
1021
+ lightpanda reads and drives pages without drawing them — use engine
1022
+ chrome`). `foldrun check`, the deploy gate and the run refuse the same pair
1023
+ in a `web.browse:` block — the block's own `engine:`, or an identity's —
1024
+ in the same words, because both read one table (`BROWSE_ENGINE_LIMITS` in
1025
+ core). What only a call can ask for (a `mode`, a `trace`, mocks) is the
1026
+ tool's to refuse.
1027
+
1028
+ **Obscura will not open a local or internal page.** It refuses loopback,
1029
+ private (RFC 1918) and link-local addresses — `localhost`, `127.0.0.1`,
1030
+ the 10/8, 172.16/12 and 192.168/16 ranges — by design, as its guard against a page
1031
+ steering the browser at the machine's own network. It is Obscura that
1032
+ refuses, at navigation, not the tool before the browser opens; the tool
1033
+ words it as `engine obscura refuses private and loopback addresses
1034
+ (127.0.0.1) — … use engine chrome or chromium for a local or internal page`.
1035
+ A server an agent starts inside its own step is one of those addresses, so
1036
+ test it with `chrome` or `chromium`.
1037
+
1038
+ **What the image actually has.** Chrome, Lightpanda and Obscura are
1039
+ downloads the runner image's build makes, each tried three times (15s,
1040
+ then 30s apart) before the build gives up on it, and Google ships no Linux Chrome
1041
+ for arm64. The build writes down what it got, in
1042
+ `/opt/browser/engines.json`; the tool reads it, so an engine the image lacks
1043
+ is refused with `this runner image was built without …` and the engines it
1044
+ has (`chrome` instead falls back to Chromium and says so). The deploy reads
1045
+ the same file, and `GET /api/version` reports it as `browsers`, with the
1046
+ versions it knows as `browser_versions` — `foldrun version` prints them, and
1047
+ so does Settings. On x86_64 every engine ships, so a build that is missing
1048
+ one **fails** rather than shipping without it, and its first error line
1049
+ names the missing engine (`runner image: MISSING ENGINES on x86_64:
1050
+ chrome`); on another arch the gap is recorded and the image ships.
1051
+
1052
+ ### How this agent's browser presents itself
1053
+
1054
+ Engine, user agent, device, locale and timezone are the browser's identity,
1055
+ not what one call does, so they belong in the file once rather than in every
1056
+ call. `web.browse:` takes a block for them, and the vendor moves to `via:`.
1057
+ What one call does stays in the call — `mode`, `interactive`, and `"@e4"`
1058
+ in its actions — so there is no block key for any of them:
1059
+
1060
+ ```yaml
1061
+ ---
1062
+ name: publisher
1063
+ tools: [web]
1064
+ web:
1065
+ browse:
1066
+ engine: chrome # chromium (default) | chrome | firefox | safari | lightpanda | obscura
1067
+ user_agent: "Mozilla/5.0 (Macintosh; …) Chrome/154.0.0.0 Safari/537.36" # optional — see below
1068
+ cookies: MEDIUM_COOKIES # the vault NAME, never the cookies
1069
+ cookie_domain: .medium.com
1070
+ device: "Pixel 7" # optional: viewport, scale, touch and its own UA
1071
+ locale: en-AU
1072
+ timezone: Australia/Sydney
1073
+ via: browserbase # optional: render on a vendor's machines
1074
+ ---
1075
+ ```
1076
+
1077
+ `headless:` picks whether the browser has a window. Unset or `true` is the
1078
+ usual: the full Chromium build with no window, pages 1366×900. `headless:
1079
+ false` is the same browser **with a window**, 1920×1080, and pages take the
1080
+ window's size instead of an emulated one. It is for a site that renders
1081
+ differently or breaks without a window, and for watching or recording a
1082
+ run; it is slower and heavier, so leave it unset otherwise. There is no
1083
+ per-call argument for it.
1084
+
1085
+ Where there is no screen, a virtual one is supplied: the account's browser
1086
+ pod starts Xvfb (`:99`) at boot, and an in-step browser on Linux starts its
1087
+ own for the call and stops it after. On a Mac, or a Linux desktop, the window
1088
+ opens on your screen. The run log says `browser: with a window (headless:
1089
+ false)` and where. It is not a disguise: the machine, its graphics (software,
1090
+ no GPU), its network and the fact that a program drives the browser are all
1091
+ unchanged.
1092
+
1093
+ `cookies:` is a secret's name, and `check` refuses anything that is not one —
1094
+ a header line pasted into the file is caught while the mistake is still
1095
+ private. The secret is still declared under `secrets:`, as it always was; the
1096
+ block only saves naming it in every call.
1097
+
1098
+ **When the login is not a cookie.** Firebase keeps its record in IndexedDB,
1099
+ MSAL can keep tokens in sessionStorage, and a page like that opens signed out
1100
+ however good the cookie jar is. `storage:` is the sibling of `cookies:` for
1101
+ those sites: a secret holding the signed-in Web Storage and IndexedDB as JSON,
1102
+ and `storage_origin:` beside it, because storage is walled off per origin and
1103
+ `.example.com` cannot name one.
1104
+
1105
+ ```yaml
1106
+ web:
1107
+ browse:
1108
+ storage: INDIEHACKERS_STORAGE
1109
+ storage_origin: https://www.indiehackers.com
1110
+ ```
1111
+
1112
+ ```json
1113
+ { "localStorage": { "user-session": "…" },
1114
+ "sessionStorage": {},
1115
+ "indexedDB": { "firebaseLocalStorageDb": { "firebaseLocalStorage": [ { "fbase_key": "firebase:authUser:…", "value": { … } } ] } } }
1116
+ ```
1117
+
1118
+ It is seeded by a script that runs in the page **before any of the site's own
1119
+ code**, so a framework that reads its session on boot finds it there, and
1120
+ IndexedDB is filled through its own API rather than copied as files. The
1121
+ console command in the browser prints the shape to store. Nothing is ever read
1122
+ back out, and for the same reason `js` refuses to run in a call that carries
1123
+ `storage`, exactly as it refuses one that carries `cookies`: an expression in
1124
+ the page could hand the model the token. A file-level default is dropped when
1125
+ the call opens a different origin, with one line on stderr saying so.
1126
+
1127
+ **A cookie default must name its site.** `cookie_domain:` is required beside
1128
+ `cookies:`, and a call to any other host opens signed out, with one line on
1129
+ stderr saying so. Otherwise an agent that browses widely would hand whatever
1130
+ page it opened the session that belongs to one site. A `cookies=` on the call
1131
+ is the caller's own decision and is used as given.
1132
+
1133
+ `web: {browse: browserbase}` still means exactly what it did, so no file needs
1134
+ changing. A block may sit on the agent, the workspace's `AGENTS.md` or the
1135
+ account's; the nearest one wins and replaces the others whole. **A call
1136
+ argument beats the file** — `browser=`, `user_agent=`, `device=`, `locale=`,
1137
+ `timezone=` — so the block is a default. The exceptions are what the agent
1138
+ may do rather than how it looks: `allowed_domains:` and `deny:` are locks
1139
+ (see [What this agent's browser may do](#what-this-agents-browser-may-do)).
1140
+
1141
+ **A pinned user agent has to keep up with Chrome.** Leave `user_agent:` out
1142
+ and the tool sends a desktop one carrying the version of the Chrome (or
1143
+ Chromium) that is actually running, so it always agrees with the browser.
1144
+ Pin one and it is a string in a file, while Chrome in the runner image is
1145
+ the current stable release, not a pinned one — a new major about every four
1146
+ weeks. The day they part, a site sees `Chrome/153` in the User-Agent header
1147
+ beside `"Google Chrome";v="154"` in the client hints Chrome still sends, and
1148
+ a JavaScript engine that is 154's: exactly the mismatch bot checks look
1149
+ for. So `foldrun check` (when signed in, from the platform's
1150
+ `/api/version`) and the deploy warn when a pinned `user_agent:` — the
1151
+ block's or an identity's — claims another Chrome major than the browser
1152
+ that sends it: on `engine: chrome` the image's Google Chrome, on
1153
+ `chromium` (the default) the image's Playwright Chromium, which is often a
1154
+ major behind stable. The run log says so on the call. Drop the pin, or raise it. Pin one only
1155
+ where a cookie was earned with that exact string (a Cloudflare clearance),
1156
+ and then sign in again when the warning comes.
1157
+
1158
+ Chrome is deliberately not pinned in the image: a pinned Chrome falls behind
1159
+ on security fixes and still needs someone to raise it, and a pinned user
1160
+ agent drifts against it just the same. Following stable, with a default
1161
+ user agent that follows Chrome and a warning for a pin that does not, keeps
1162
+ sites from seeing a mismatch without anyone updating a version by hand.
1163
+
1164
+ `chrome` is **real Google Chrome** (Playwright's `chrome` channel), where the
1165
+ image has it — a truer user-agent and brands, and the proprietary codecs
1166
+ Chromium lacks. `chromium` is the open-source build. They are the same engine;
1167
+ `chrome` just runs the branded browser. Where Chrome is not installed — a
1168
+ laptop without it, or an arch Google does not ship Chrome for — `chrome`
1169
+ falls back to Chromium and says so on stderr. `safari` is **WebKit**, Safari's
1170
+ engine, and `webkit` is the same. A cookie earned in one is presented by the
1171
+ other at your own risk — the user agent is part of what a site checked.
1172
+
1173
+ `version:` picks a specific build of the engine. For Chrome it is a release
1174
+ channel — `stable` (the default), `beta` or `dev`, each a real concurrent
1175
+ Chrome. For any engine it can name a binary installed in the image at
1176
+ `/opt/browser/<engine>/<version>/` (drop an exact build there to pin it).
1177
+ A version that is not installed falls back to the default build and says so
1178
+ on stderr.
1179
+
1180
+ **`live: true` — a page that outlives the call.** Normally each `web action=browse`
1181
+ call is its own short-lived process: it opens a fresh page, and when it ends
1182
+ the page goes with it. A `session` carries the *login* across calls (cookies
1183
+ and localStorage), but not the page — so a multi-step form whose progress
1184
+ lives in the page resets between calls. `live: true` keeps the browser and
1185
+ its open page running beside the step, and the next call reattaches to it
1186
+ over the DevTools protocol and finds the page exactly as it was left:
1187
+
1188
+ ```yaml
1189
+ web:
1190
+ browse:
1191
+ engine: chromium
1192
+ live: true
1193
+ ```
1194
+
1195
+ Then call 1 opens the wizard and clicks Next, call 2 reads step two and fills
1196
+ it, call 3 submits — with the agent reasoning between each. `url` becomes
1197
+ optional: leave it out (or pass the same URL) to act on the page as it
1198
+ stands; a different URL navigates the live page. `session` names which live
1199
+ page (default `live`). `live=end` on a call closes it; one idle for 15
1200
+ minutes closes itself, and the step's sandbox ending closes it too. Live
1201
+ sessions run in the step rather than the account's browser pod, are
1202
+ Chromium-only (`chrome`/`chromium`), and do not support `video`, `har`,
1203
+ `auth` or `device` (they say so and carry on). `live` also works per call. Note Google does not distribute arbitrary old Chrome versions, so
1204
+ pinning an exact old Chrome means supplying that build yourself; the three
1205
+ release channels are what ships.
1206
+
1207
+ Why identity belongs together: a Cloudflare clearance cookie is bound to the
1208
+ user agent that earned it. A skill that repeats the UA in every call is one
1209
+ edit away from a session that stops working and says nothing about why
1210
+ (what happened to Medium on 2026-09-17). `check` refuses an engine that does
1211
+ not exist, a setting that is not text, and an engine paired with something
1212
+ it cannot do (see [Engines](#engines)), in one sentence, before a run.
1213
+
1214
+ ### What this agent's browser may do
1215
+
1216
+ Some settings are not how the browser looks but what the agent may do with
1217
+ it, and those belong where no page can argue with them — the file.
1218
+
1219
+ ```yaml
1220
+ web:
1221
+ browse:
1222
+ allowed_domains: [portal.example.com, "*.example-cdn.com"]
1223
+ deny: [eval, download, upload]
1224
+ boundaries: true
1225
+ init: [scripts/stub-analytics.js]
1226
+ state_key: BROWSER_STATE_KEY
1227
+ ```
1228
+
1229
+ `allowed_domains:` is the only list of places the browser can reach: pages,
1230
+ scripts, images, fetches and sockets to any other host are refused at the
1231
+ network layer, a service worker cannot go around it, and WebRTC is held to
1232
+ proxied transports. `*.example.com` covers the subdomains and the bare
1233
+ domain. Unlike the rest of the block **it is a lock**: a call's
1234
+ `allowed_domains=` can only narrow it, and a call that names a host outside
1235
+ it is refused before a browser opens. `deny:` names actions this agent may
1236
+ never take — `check` refuses a name that is not an action, because a
1237
+ misspelt denial would deny nothing, and `js` stands for the call argument.
1238
+ `eval` also covers script by another name (a `wait` on a function, a
1239
+ clipboard read), and `goto` covers a new tab, a sign-in that opens a URL, and
1240
+ a `download` given a URL — that is a navigation, and `allowed_domains:` is
1241
+ checked for it too.
1242
+ The list holds across redirects: each hop is checked before the browser
1243
+ follows it.
1244
+ `boundaries: true` wraps every reply's page text in markers carrying a nonce
1245
+ the page cannot know, so words a site wrote to look like instructions stay
1246
+ labelled as the site's. It is a label, not a wall.
1247
+
1248
+ `init:` runs workspace scripts in every page before the page's own — a
1249
+ stub for an analytics call, a polyfill. `extensions:` loads unpacked Chrome
1250
+ extensions from the workspace; the browser then runs in the step rather than
1251
+ the account's pod, in Chromium (branded Chrome no longer loads them).
1252
+ `webgpu: true` and `ignore_https_errors: true` do what they say. `state_key:`
1253
+ names a secret whose value encrypts the `state=` login at rest with
1254
+ AES-256-GCM, so a session cookie in `state/` is not a login anyone who can
1255
+ read the workspace can use; declare the secret under `secrets:`. An
1256
+ encrypted state without its key is refused rather than started signed out.
1257
+
1258
+ **A sign-in the model never sees.** A `login` step takes a credential
1259
+ secret — `{"username": "…", "password": "…", "url": "https://portal.example.com/login"}` —
1260
+ finds the form, fills it and submits. With a `url` in the secret it goes
1261
+ there first and will not fill a form on any other origin, so a page cannot
1262
+ talk the agent into typing the password somewhere else. The password is
1263
+ replaced by `[secret NAME]` in every output even when the page prints it
1264
+ back, and a call that signs in runs no script (`eval`, `js`, `get` of a
1265
+ value), because script could read the field. Sign in with `session=` or
1266
+ `state=`, and read in the next call.
1267
+
1268
+ **Watching a vendor's browser.** With `web: {browse: via: browserbase}` (or
1269
+ `steel`, `hyperbrowser`) the run log carries the session's live view URL —
1270
+ `live view: https://…` — where a person can watch the page or take it over
1271
+ for a 2FA prompt, while the call runs. `via: cdp` connects to any browser
1272
+ that serves the DevTools protocol — a Chrome started with
1273
+ `--remote-debugging-port`, a pool of your own — at the address in the
1274
+ `BROWSER_CDP_URL` secret.
1275
+
1276
+ ### Watching it: live, and the recording
1277
+
1278
+ On the platform, the page a step's browser drives is **live on the run
1279
+ page** while the step runs: under the step, a panel shows what the browser
1280
+ shows, with the address it is on, marked `live`, and `last seen` once the
1281
+ call ends. The tool streams it — Chromium and Obscura send a frame when the
1282
+ page changes, Firefox and WebKit a screenshot a second, at most three frames
1283
+ a second either way — through the step's own egress lease, which is what
1284
+ lets the platform put a frame on that run and no other. Frames are held five
1285
+ minutes after the last. Nothing to switch on; `web: {browse: live_view: false}`
1286
+ turns it off for an agent. Lightpanda draws nothing, so it shows nothing.
1287
+
1288
+ **The recording** is `video=true` on a call, or `web: {browse: video: true}` in
1289
+ the block to record every call. Once the run finishes, the run page plays it
1290
+ inline under the step, beside any screenshots the step took, and a video
1291
+ seeks because the archive serves byte ranges.
1292
+
1293
+ **A call that signs in is never watched or kept.** A `login` step or a field
1294
+ filled from a secret turns the live view off for that call, and refuses
1295
+ `trace` and `har` (a trace keeps every field's value, a HAR every request
1296
+ body); a `video` the block asks for is skipped for that one call and says
1297
+ so. Sign in in one call with `session=` or `state=`, and do the rest —
1298
+ watched and recorded — in the next.
1299
+
1300
+ ### When a click cannot reach its element
1301
+
1302
+ Many sites draw their own checkboxes and radios and hide the real control,
1303
+ so it has no size on screen, and a cookie banner fixed over the page covers
1304
+ whatever is under it. A plain click waits out its time and fails on both.
1305
+ `web action=browse` falls back on its own: after a short first try it uses the
1306
+ keyboard, the standard every accessible control supports — Space on a
1307
+ checkbox, radio or switch, Enter on a button or link — then the control's
1308
+ label, and it reads a toggle back rather than trusting the press. The result
1309
+ names what worked, `check #terms (by keyboard (Space) …)`, so nobody has to
1310
+ know the trick.
1311
+
1312
+ Two options on any action:
1313
+
1314
+ - `"timeout": 3000` bounds that action (it used to apply only to `wait`). An
1315
+ element not on the page still gets that long to appear.
1316
+ - `"force": true` sends the click event to the element itself instead of to
1317
+ its place on the screen, past a cover or a hidden input. A toggle is still
1318
+ read back. Use it when the fallback reports failure; it skips what a person
1319
+ could see, so prefer the default.
1320
+
1321
+ ### Proving a write stuck
1322
+
1323
+ A save that reports success and reverts is the worst failure a driven page
1324
+ has: every line of the log says it worked. `confirm=` is the contract that
1325
+ catches it.
1326
+
1327
+ ```
1328
+ web(action="browse", url="…", actions='[…]', confirm="text=Draft saved")
1329
+ ```
1330
+
1331
+ After the actions, the call reloads the page and requires that selector, or
1332
+ `text=…`, to be there in what the **server** returns. If it is not, the call
1333
+ fails, says the change did not stick, and says not to retry — a refused write
1334
+ is a session or a permission, not a flake. It needs `actions:` for the same
1335
+ reason: confirming a page nobody changed proves nothing.
1336
+
1337
+ Use it on every call that writes. Without it, "the tool reported ok" is the
1338
+ only evidence a step has, and on 2026-09-17 that evidence was wrong for hours.
1339
+
1340
+ ### Claims, identity and state
1341
+
1342
+ `confirm=` proves one thing: a selector survived a reload. `expect=` is the
1343
+ general form — a list of claims about the page after the actions, each
1344
+ checked and reported, any failure failing the call:
1345
+
1346
+ ```
1347
+ web(action="browse", url="…", actions='[…]',
1348
+ expect='[{"expect": "text=Draft saved"},
1349
+ {"expect": "url", "contains": "/p/"},
1350
+ {"expect": "label=Title", "value": "Owner Inspections"}]')
1351
+ ```
1352
+
1353
+ A claim is a selector that must be visible, with `text` (contains), `value`,
1354
+ `count`, `gone`, `checked`, `enabled`, or `attr` + `equals` / `contains` /
1355
+ `matches`; or `url`, `title` or `text` (the page's) with `contains`,
1356
+ `matches` or `equals`. It is about the page **as it stands** — put a `wait`
1357
+ first when the page must settle. The same object is an `"expect"` step
1358
+ between actions (wrong, it stops the rest), and an `"if"` step branches on it
1359
+ with `then` and `else` lists, which is how "dismiss the cookie banner if it
1360
+ is there" becomes one step instead of a failed click.
1361
+
1362
+ `get` and `eval` steps ask questions rather than make claims: the answers
1363
+ are the call's results, its output when nothing else claims it and always
1364
+ `outputs/results.json`. An `eval` is script in the page and obeys the same
1365
+ rule as `js` — never beside a secret's cookies or storage.
1366
+
1367
+ **Identity.** Engine, device, locale, timezone, user agent, proxy, headers,
1368
+ geolocation, colour scheme, block list and a cookie secret are who the
1369
+ browser *is*, and a call that sets six of them to be an Australian phone
1370
+ will set five next time. The block names the bundle once and the call says
1371
+ the name:
1372
+
1373
+ ```yaml
1374
+ web:
1375
+ browse:
1376
+ identities:
1377
+ au-mobile: { device: "Pixel 7", locale: en-AU, timezone: Australia/Sydney, proxy: PROXY_AU }
1378
+ au-desktop: { locale: en-AU, timezone: Australia/Sydney }
1379
+ us-desktop: { locale: en-US, timezone: America/New_York, proxy: PROXY_US }
1380
+ ```
1381
+
1382
+ ```
1383
+ web(action="browse", url="…", identity="au-mobile", mode="screenshot")
1384
+ ```
1385
+
1386
+ `check` refuses an identity with an engine that does not exist, a secret
1387
+ that is a value rather than a NAME, a cookie without its domain, or a key
1388
+ that belongs to a call (`mode`, `actions`) rather than to an identity. A
1389
+ call argument still beats the identity, and the identity beats the block's
1390
+ own defaults.
1391
+
1392
+ **State.** `session` keeps a login for one run. `state` keeps it for the
1393
+ next one: the cookies and localStorage the call ends with are written to
1394
+ `workspace/state/browser/<name>.json` and loaded before any later call that
1395
+ names it, in this run or a run next month. It is the agent's own login,
1396
+ made with `actions` from credentials it can see — `state` refuses
1397
+ `cookies=` and `storage=` and a session those seeded, because `state/` is a
1398
+ workspace file every later step can read, and a vault secret written there
1399
+ would stop being one. `mode=diff` keeps its baselines in the same place,
1400
+ under `state/browser/diff/`, for the same reason: they must outlive the run.
1401
+
1402
+ ### Where the browser runs
1403
+
1404
+ Locally — the CLI, a compose install — Chromium launches inside the step,
1405
+ and always did. On a cluster it runs in **the account's own browser pods**:
1406
+ Playwright servers on the runner image (Playwright pinned to an exact
1407
+ version, 1.63.0, in `run-container.ts` — the numbered snapshot needs 1.59
1408
+ or later, and a floating `@1` would move the pods and the tool under a
1409
+ working desk on any rebuild), in the run namespace, the first
1410
+ started the moment a step in the account grants `web action=browse`, another added
1411
+ for every two steps browsing at once (`FOLDRUN_BROWSER_STEPS_PER_POD`, up to
1412
+ `FOLDRUN_BROWSER_MAX_PODS`, four), kept while calls keep coming, and
1413
+ deleted after fifteen minutes unused (`FOLDRUN_BROWSER_IDLE_MS`): a pod
1414
+ beyond what the account currently needs idles out first, the last one
1415
+ goes when the account stops, and nothing is deleted while any of the
1416
+ account's steps is running, so a call in flight is never cut by a pod
1417
+ going away under it.
1418
+ The step is handed its address as `FOLDRUN_BROWSER_WS`; every call connects
1419
+ and gets a browser launched for it alone, with the same flags the in-step
1420
+ launch uses, so two runs never share cookies and two accounts never share a
1421
+ pod. Those flags cross only because the pod's server runs as
1422
+ `playwright run-server --unsafe`: without it Playwright keeps `headless` and
1423
+ `proxy` from a client's launch options and silently drops `args`, which is
1424
+ what happened until 28 Sep 2026 (`--no-sandbox`, `--disable-dev-shm-usage`,
1425
+ `--disable-gpu` and the AutomationControlled switch never reached a pod
1426
+ browser). The pod is the account's own and only its run pods can reach it,
1427
+ so accepting a client's flags there is safe. The pod's seconds and bytes are the account's compute and network on
1428
+ the ledger, as one `browser <pod>` line when it is reaped — the search
1429
+ pod's lifecycle, for the browser. An account that does not browse pays for
1430
+ no browser; nothing account-level is always on.
1431
+
1432
+ Every engine is served the same way: `browser=firefox` and `browser=webkit`
1433
+ are launched in that same pod, so where a page renders never depends on which
1434
+ engine you asked for. What stays in the step is any call whose pod cannot be
1435
+ reached — stderr then says `running <engine> inside the step`, and the call
1436
+ proceeds — except on a slim step, below, which has no browser of its own.
1437
+ A `session` in the pod is the context's storage state — cookies and
1438
+ localStorage, which is what a login is — saved under
1439
+ `outputs/.browser/<name>/` between calls; locally it is a profile directory
1440
+ as before. Uploads, downloads, screenshots, PDFs, video, trace and HAR all
1441
+ cross the wire; `proxy`, `auth`, `headers`, `device`, `block`, `mock`,
1442
+ `state` and `captcha` work as they do in the step. `mode=a11y` injects
1443
+ axe-core from the runner image (`axe-core@4.13.0`, pinned beside
1444
+ Playwright in `run-container.ts`) into the page, so it runs wherever the
1445
+ page renders.
1446
+
1447
+ ### Slim steps and a lost pod
1448
+
1449
+ A step needs the full runner image (browsers, about 1.7 GB to pull) only
1450
+ when a browser runs inside it. When the account's browser pod serves the
1451
+ step, nothing does — so a step whose only reason for the full image is that
1452
+ it browses runs on the **slim** image (about 0.3 GB) and every browse call
1453
+ renders in the pod. The run page shows `slim · browser pod` beside the step;
1454
+ the record says it as `image: {variant: "slim", pod: true}`.
1455
+
1456
+ These always run on the full image, as before:
1457
+
1458
+ - an **outward** step — one granting a tool marked `outward: true` (it
1459
+ sends, posts, buys, publishes);
1460
+ - a [test run](runs#test-runs), a step naming a browser vendor, and a step
1461
+ whose pod the cluster would not give — none has a pod address;
1462
+ - a step whose `web.browse` block asks for something only a browser in the
1463
+ step does: `live: true`, `extensions`, `engine: lightpanda` or `obscura`;
1464
+ - a step that could launch a browser some other way (a script tool or MCP
1465
+ server that drives one, a runtime that installs one).
1466
+
1467
+ A call that picks such an engine itself (`engine=lightpanda` on one call)
1468
+ cannot run on slim; it is treated like a lost pod, below.
1469
+
1470
+ **When the pod goes mid-step** — deleted, drained, reaped, out of memory:
1471
+
1472
+ 1. **Reconnect.** A pod that is stopping usually closes its browser before
1473
+ its connection, so a call in flight meets `Target page, context or
1474
+ browser has been closed` (or `Browser closed`, a closed WebSocket,
1475
+ `ECONNRESET`). On the pod, that is a dropped call, not an ordinary error:
1476
+ the tool asks the call's context once more, and if it no longer answers
1477
+ the pod is treated as gone. (A page the agent itself closed leaves the
1478
+ context answering, so that stays the agent's error.) The tool then
1479
+ reconnects: three tries, 1, 2 and 4 seconds apart. A call that never
1480
+ reached the pod is simply tried again. A call that dropped mid-way and
1481
+ **only read** (navigated, waited, scrolled, screenshotted, extracted) is
1482
+ run once more when the pod answers, and that answer is the answer. A
1483
+ call that may already have acted (it clicked, filled, typed, uploaded,
1484
+ signed in, ran script) is **not** sent again: the reconnect only checks
1485
+ the pod is back, and the call fails saying it may have acted, so the
1486
+ agent looks at the page before repeating it. Each try is a line on the
1487
+ run — `browser pod: reconnect failed (try 1 of 3, at 10:15:15Z)` — the
1488
+ time is when the try happened; the lines reach the run when the call
1489
+ returns. A pod that is shutting down can still answer for a moment, so
1490
+ a reconnect may reach it and the call drop again at once; the run says
1491
+ `reconnected (try 1 of 3, …), but the pod closed again`, then `lost —
1492
+ the call dropped again after the reconnect`. A reconnect counts as
1493
+ **got through** only when the call went on after it, and a pod that was
1494
+ lost in the end is never said to have let one through.
1495
+ 2. **Still gone, and the step has only read** — it re-runs from the start
1496
+ on the full image, where a browser in the step is always there. The run
1497
+ says `browser pod lost; re-ran on full`. The slim go is a try of its own
1498
+ in `tries` — `image: "slim"`, `status: "lost"`, its own cost, tokens and
1499
+ seconds, and its `browserPod` line — followed by the full re-run's row,
1500
+ the next try (`n: 2` after a lost try 1, with `attempt: 1`: the re-run
1501
+ is part of the attempt, not a `retry:`); the step's cost is both.
1502
+ 3. **Still gone, and the step has written anything** — it is not re-run,
1503
+ because doing it again could send or charge twice. It fails with
1504
+ `browser pod died after the step had written <what>`, and `retry:`,
1505
+ `on-fail:` or a person decides.
1506
+
1507
+ Read or write is judged from the calls the step had made, its sub-agents'
1508
+ included. Reads: `Read`/`Glob`/`Grep`, `web` search, fetch, crawl, map,
1509
+ extract and answer, a browse that only looks (navigates, scrolls, waits,
1510
+ screenshots, extracts, hovers), a GET or HEAD to an API, the `search`,
1511
+ `history` and `desks` tools and a consult. Writes: any file written or
1512
+ edited (`state/`, `memory/`, the workspace), Bash, every script tool, an
1513
+ API call that is not a GET, a web action a paid provider answers, `web
1514
+ action=monitor` (it keeps state), a browse that clicks, fills, types,
1515
+ presses, selects, uploads, signs in, accepts a dialog, solves a captcha or
1516
+ runs script, a question to a person, and any other MCP tool. When a call
1517
+ could be either, it counts as a write. A browse call that never reached the
1518
+ pod changed nothing and does not count.
1519
+
1520
+ The run page's step card says what happened in one line — `3 reconnects;
1521
+ browser pod lost; re-ran on full (…)`, or `1 reconnect, which reached the
1522
+ pod but it closed again; …` — and, under it, the tries: `#1 slim · lost ·
1523
+ $0.0040 → #2 full · completed · $0.0060`. A pod that stayed says `2
1524
+ reconnects (2 got through)`. `foldrun report <run>` prints the same two
1525
+ lines, and each row in `tries` keeps its own image and pod line.
1526
+
1527
+ A deleted browser pod stops serving within about two seconds: its server
1528
+ closes every browser on SIGTERM but would otherwise keep accepting new
1529
+ sessions until it is killed, so the pod's grace period is 2 seconds, not
1530
+ Kubernetes' default 30. The next browse call meets a pod that is gone,
1531
+ not one that is half-there. A pod already running when this changed keeps
1532
+ the old grace period until it is replaced.
1533
+ `FOLDRUN_SLIM_BROWSING=0` on the worker puts every browsing step back on
1534
+ the full image ([environment](environment)).
1535
+
1536
+ A [test run](runs#test-runs) is handed no browser pod at all. The policy
1537
+ that denies a test run the internet is on the run pod, and a browser pod it
1538
+ drove would fetch the world on its behalf; refusing the address keeps its
1539
+ browser under that policy, so a `web action=browse` call on a test run fails closed
1540
+ like any other outward call, and the run log says so.
1541
+
1542
+ The pool is one Service and one plain NetworkPolicy pair per account: this
1543
+ account's run pods may reach this account's browsers on their port and nothing else may;
1544
+ the pod itself reaches the internet and DNS and never the cluster, the LAN
1545
+ or the metadata endpoint — the same egress shape as a run pod. Search pods
1546
+ are built from the same module (`account-pod.ts` in the platform), so the
1547
+ rule is written once.
1548
+
1549
+ ## Search — the account's own engine
1550
+
1551
+ `web action=search` is a gallery tool that asks the account's search engine, a
1552
+ SearXNG that federates the public engines and merges what they return. On a
1553
+ cluster it is **the account's own pool of pods**: the first started when a
1554
+ step that grants `web action=search` is about to run, another for every eight
1555
+ steps searching at once (`FOLDRUN_SEARCH_STEPS_PER_POD`, up to
1556
+ `FOLDRUN_SEARCH_MAX_PODS`, three), kept while steps keep using them, and
1557
+ deleted after fifteen minutes unused, surplus first. It asks SearXNG's
1558
+ default engines plus Yandex and Naver: measured from the platform's own
1559
+ address, several of the defaults refuse it, and those two answered every
1560
+ query with relevant results. An account's queries share a process
1561
+ with no one else's, an account that hammers search suspends only its own
1562
+ engines, and the pod's seconds and bytes are that account's compute and
1563
+ network on the ledger. A step that does not grant `web action=search` starts
1564
+ nothing. There is no shared engine behind it any more: should the cluster
1565
+ refuse the account its pod, the step **fails** with one line saying so
1566
+ rather than searching through a process every account once shared. The
1567
+ [browser](#where-the-browser-runs) has the same lifecycle.
1568
+
1569
+ What this does not change: every pod leaves the cluster from the node's
1570
+ address, so the public engines' per-address limits stay shared. Search
1571
+ here is for finding a page to read — a directory, a firm's site, a primary
1572
+ source — not for tracking rankings, which is a paid SERP API's job.
1573
+
1574
+ ### Tuning the account's own engine
1575
+
1576
+ `web.search:` takes a block of SearXNG settings in place of a name — the
1577
+ way `web.browse:` takes the browser's. Each is a default for every call the
1578
+ agent makes:
1579
+
1580
+ ```yaml
1581
+ ---
1582
+ name: researcher
1583
+ tools: [web]
1584
+ web:
1585
+ search:
1586
+ engines: [bing, google cse, duckduckgo] # ask only these
1587
+ exclude_engines: [wikipedia] # never these — a lock no call lifts
1588
+ categories: [general, news] # when no engine list is set
1589
+ safesearch: moderate # off | moderate | strict (0, 1, 2)
1590
+ time_range: month # day | week | month | year, when a call does not say
1591
+ timeout: 5 # seconds SearXNG waits for its engines, 0.5-30
1592
+ plugins: [oa_doi_rewrite] # switch plugins on for this agent
1593
+ exclude_plugins: [tracker_url_remover] # and off
1594
+ doi_resolver: doi.org # where oa_doi_rewrite points a paper's link
1595
+ ---
1596
+ ```
1597
+
1598
+ | Key | What SearXNG is asked | A call may |
1599
+ |---|---|---|
1600
+ | `engines` | only these engines (`engines=`) | replace it with `engines=` or `categories=` |
1601
+ | `exclude_engines` | every engine it would ask, minus these | not lift it |
1602
+ | `categories` | every enabled engine in these (`categories=`) | replace it |
1603
+ | `safesearch` | `safesearch=` 0, 1 or 2 | — |
1604
+ | `time_range` | `time_range=`, the default recency | replace it with `time_range=` |
1605
+ | `timeout` | `timeout_limit=` | — |
1606
+ | `plugins` / `exclude_plugins` | `enabled_plugins=` / `disabled_plugins=` | — |
1607
+ | `doi_resolver` | `doi_resolver=` | — |
1608
+
1609
+ The call also takes `page=` (1-10) to go past the first results.
1610
+
1611
+ **One choice of engines, never two.** The call's `engines=` or
1612
+ `categories=` beats the file's, and at either level a named engine list
1613
+ beats categories. SearXNG given both would ask the named engines *and*
1614
+ every engine in the categories, so `engines: [naver]` would quietly bring
1615
+ the rest back; the tool never sends both.
1616
+
1617
+ **Names are checked against the running engine.** SearXNG drops a name it
1618
+ does not know without a word — a misspelt engine would search everything
1619
+ else and look like it worked. `check` refuses a name that is not shaped
1620
+ like one, a plugin that does not exist, an engine both asked for and
1621
+ excluded, and any of these keys beside `name:` (a search API would ignore
1622
+ every one). The tool then checks engines, categories, plugins and the
1623
+ resolver against the instance's `/config` and refuses a wrong one with the
1624
+ right ones beside it. SearXNG loads only some of its engines: one marked
1625
+ *inactive* in its settings (Startpage and Mojeek on newer images, the
1626
+ key-only APIs) cannot be asked for from a file.
1627
+
1628
+ **Date filters skip engines.** With a `time_range` SearXNG skips every
1629
+ engine that cannot filter by date — Yandex and Wikipedia among them. The
1630
+ tool names the ones it skipped, and refuses a search where that would be
1631
+ all of them.
1632
+
1633
+ **What comes back.** Beside the results the tool prints the engine's
1634
+ answers (a calculation, a hash), infoboxes (a Wikipedia or Wikidata panel),
1635
+ "did you mean" corrections and related searches, and names the engines
1636
+ that did not answer even when others did.
1637
+
1638
+ Not keys, on purpose: SearXNG's page-only preferences — `theme`,
1639
+ `results_on_new_tab`, `image_proxy`, `url_formatting`, `autocomplete` — do
1640
+ nothing to a JSON answer, and a key that does nothing is a question someone
1641
+ asks later.
1642
+
1643
+ ## Script tools and secrets
1644
+
1645
+ On a [test run](runs#test-runs) a script is not given a send-capable secret:
1646
+ the variable is there and reads `TEST_MODE_WITHHELD`, and `FOLDRUN_TEST_MODE=1`
1647
+ is set beside it. A script that sends should check the variable and say what
1648
+ it would have done; one that does not is stopped by the provider's auth error.
1649
+ Mark a tool that sends `outward: true`, and a reader that shares a sender's
1650
+ token `test_mode: allow`.
1651
+
1652
+ **Where a secret is declared.** On the tool, when the tool needs it: a
1653
+ script tool lists the names its program reads under `secrets:` in its own
1654
+ file, and any agent that grants the tool gets them — the same as an API
1655
+ tool, whose `${NAME}` placeholders always travelled with it. On the agent,
1656
+ when the agent uses it directly: a key in an `apis:` header it defines, or a
1657
+ value its own `code` reads. A name in both places is granted once. The run
1658
+ trace lists the secrets a step received and the scope each came from; one
1659
+ that is not in the vault is an error on the step's record, by name.
1660
+
1661
+ A script reads its secrets from its environment. By default that means the
1662
+ real values are in the sandbox for the step, and the run trace says which
1663
+ script asked for them. A script that sends its own requests can instead go
1664
+ through the egress proxy — read `FOLDRUN_EGRESS`, send `${NAME}` in the
1665
+ header — and declare `secrets: proxied` in its tool file; then nothing
1666
+ real is in the sandbox for it. The gallery's `web action=search` does, because it
1667
+ needs no secret at all; the `web action=browse` does not, because a cookie has to be
1668
+ seeded into a real browser. See [Secrets](secrets#where-the-value-actually-is).
1669
+
1670
+ ## Limits — how many calls a step may make
1671
+
1672
+ `limits:` on an agent caps how many times one step may call a tool. A call
1673
+ past a limit is **refused before it runs** — the tool never sees it — and the
1674
+ model is told, in words it can act on:
1675
+
1676
+ ```
1677
+ limit reached: web.search 40 of 40 in this step — work with what you have, or say what more you would need
1678
+ ```
1679
+
1680
+ ```yaml
1681
+ limits:
1682
+ web.search: 40 # one web action — search fetch browse crawl map extract answer monitor
1683
+ web.fetch: 100
1684
+ web: 150 # every web action together
1685
+ crm: 20 # a tool by the name it is granted as
1686
+ calls: 300 # every tool call in the step, any tool
1687
+ ```
1688
+
1689
+ | key | counts |
1690
+ |---|---|
1691
+ | `web.<action>` | one action of the `web` tool; a model provider's own search or fetch (`web: {search: zai}`) counts as `web.search` / `web.fetch` |
1692
+ | `web` | every web action together |
1693
+ | an HTTP tool's name | every call to it — each operation of an `openapi:` tool, and its generic `call_`, all under the one name |
1694
+ | a script tool's name | every run of it (`desk-email` and `desk_email` are the same key) |
1695
+ | an MCP server's name | every tool the server exposes |
1696
+ | `read` · `write` · `code` | Read/Glob/Grep · Write/Edit · Bash. An exact SDK name (`Bash`) works too |
1697
+ | `search` · `history` · `desks` · `ask` | the platform's own groups; a consulted colleague by its name |
1698
+ | `calls` | everything — including a delegation to a sub-agent |
1699
+
1700
+ Values are whole numbers, 1 or more; to withhold a tool, leave it out of
1701
+ `tools:`. How the count works:
1702
+
1703
+ - **Per step.** A retry of the step starts from zero, and so does each
1704
+ instance of an `each:` fan-out — each is its own step. A step's
1705
+ sub-agents count toward the step that delegated: their calls pass the same
1706
+ check.
1707
+ - **A refused call counts toward nothing**, and neither does one another
1708
+ check refused (a path outside the workspace, a tool not granted).
1709
+ - **Nearest wins, per key.** The account's `AGENTS.md`, then the
1710
+ workspace's, then the agent's own, then a flow step's
1711
+ `limits: {web.search: 10}` option. A key set nearer replaces that key;
1712
+ the others stand.
1713
+ - **On the trace.** The step lists its limits when it starts, each refusal
1714
+ as it happens, and at the end what it used: `limits: web.search 40/40, crm 3/20 — 2 calls refused`.
1715
+
1716
+ **With a search API, the cost is capped exactly.** A capped call never
1717
+ reaches the tool, so it never reaches the provider behind it: `web.search: 40`
1718
+ on an agent whose `web: {search: brave}` is at most 40 Brave queries a step,
1719
+ whatever the model tries. Fan-out multiplies it — forty instances of a step
1720
+ with `web.search: 40` may search 1,600 times — so cap the run as well with
1721
+ `budget:` where that matters. A limit counts calls, not what a call asks
1722
+ for: `fetch` with `urls=` of twenty pages is one call.
1723
+
1724
+ `foldrun check` errors on a key that names nothing (and lists the keys that
1725
+ would work) or a value that is not a whole number above zero, and warns on a
1726
+ limit for a tool the agent is not granted. The deploy refuses an unreadable
1727
+ value.
1728
+
1729
+ ## Skills — procedure, not capability
1730
+
1731
+ A skill is a folder with a `SKILL.md`, in the open
1732
+ [Agent Skills](https://agentskills.io) format:
1733
+
1734
+ ```markdown
1735
+ ---
1736
+ name: pacing-check
1737
+ description: Check whether ad spend is pacing to budget. Use when asked if spend is on track.
1738
+ ---
1739
+
1740
+ Run `scripts/pace.py --spent <amount> --budget <amount>` from this skill's
1741
+ folder, then report the verdict in one sentence.
1742
+ ```
1743
+
1744
+ Only names and descriptions sit in context; the agent reads the full file when
1745
+ a task matches. So an agent can carry many skills for a few tokens each — which
1746
+ is the reason to move a procedure out of `agent.md` in the first place.
1747
+
1748
+ Skills are discovered in the agent's own `skills/`, the workspace's, the
1749
+ account library, and the cross-client `.agents/skills/` convention that other
1750
+ tools read and write. A skill written here drops into them, and one they
1751
+ install is visible here.
1752
+
1753
+ **A tool is what an agent may call; a skill is how to do something well.** The
1754
+ test for moving text out of an agent body: is it procedure (not role), does it
1755
+ apply to some runs rather than all, and does more than one agent need it?
1756
+
1757
+ ## Secrets
1758
+
1759
+ Names in `secrets:`, values in the vault. A secret may also be an **OAuth2
1760
+ credential** — the vault stores the refresh recipe (`token_url`, `client_id`,
1761
+ `client_secret`, `refresh_token`) and the host exchanges it for a live access
1762
+ token immediately before every use, cached until near expiry.
1763
+
1764
+ Resolution is nearest-wins across the workspace and account vaults, and the run
1765
+ log says which store each one came from — a workspace quietly falling back to
1766
+ an account credential is the kind of thing you want to see rather than infer.
1767
+
1768
+ ### Connecting a provider
1769
+
1770
+ Two ways in, one rule. An OAuth app has to know its redirect URL in advance,
1771
+ so register these on every app you make for foldrun and every developer on
1772
+ the team is covered:
1773
+
1774
+ | | redirect URL to register |
1775
+ |---|---|
1776
+ | `foldrun connect` from a terminal | `http://localhost:8642/callback` — the same on every machine |
1777
+ | Connect from the dashboard | `<dashboard URL>/api/oauth/callback` — the form shows the exact value |
1778
+
1779
+ ```sh
1780
+ foldrun connect LINKEDIN_OAUTH --provider linkedin --to linkedin-desk
1781
+ ```
1782
+
1783
+ opens the provider's consent screen, catches the redirect on this machine,
1784
+ exchanges the code, and stores the result on the platform you are signed in
1785
+ to (or the local vault with `--local`). A provider that returns a refresh
1786
+ token gives you an auto-refreshing credential; one that does not (GitHub,
1787
+ most LinkedIn apps) has its access token stored as is, and the command says
1788
+ when it expires. Scopes are one space-separated string, as OAuth defines
1789
+ them; each preset starts you with an example. Nothing secret is printed.
1790
+
1791
+ Most providers refuse a plain-http redirect anywhere but `localhost`, which
1792
+ is why the terminal path is the one that works from any laptop against any
1793
+ box, and the dashboard path needs a dashboard with an https address.
1794
+
1795
+ ## Testing one
1796
+
1797
+ The Tool Test button calls a tool with arguments you type, from where a run
1798
+ would call it, and shows the raw result. That is the difference between "the
1799
+ tool is broken" and "the agent used it wrong", and it costs no model call.
1800
+
1801
+ ## The Test button
1802
+
1803
+ Every tool has a Test button, and `POST /api/workspaces/<ws>/tools/<tool>/test`
1804
+ behind it: it calls the tool with typed arguments from where a run would call
1805
+ it, shows the raw result, and costs no model call. A single-file tool that
1806
+ carries its program in a fenced block is materialised for the test exactly as
1807
+ a call materialises it, so the test runs the identical program from the
1808
+ identical directory — for a long time it did not, and every fenced-code tool
1809
+ failed the button while running perfectly in a flow, which is the worst way
1810
+ round for the one place a developer goes to check their work.