foldrun 0.5.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/README.md +98 -15
- package/bin/foldrun.mjs +331 -47
- package/docs/agents.md +483 -0
- package/docs/api-usage.md +156 -0
- package/docs/api.md +435 -0
- package/docs/backups-and-restore.md +171 -0
- package/docs/budgets-and-billing.md +362 -0
- package/docs/cli.md +428 -0
- package/docs/coding-agents.md +224 -0
- package/docs/deploying.md +77 -0
- package/docs/evals.md +90 -0
- package/docs/faq.md +53 -0
- package/docs/flows.md +670 -0
- package/docs/getting-started.md +141 -0
- package/docs/glossary.md +78 -0
- package/docs/harness.md +128 -0
- package/docs/how-it-works.md +255 -0
- package/docs/introduction.md +47 -0
- package/docs/knowledge-and-memory.md +85 -0
- package/docs/notifications.md +320 -0
- package/docs/providers.md +241 -0
- package/docs/runs.md +238 -0
- package/docs/scheduling.md +103 -0
- package/docs/secrets.md +163 -0
- package/docs/security.md +167 -0
- package/docs/self-hosting.md +65 -0
- package/docs/status.md +73 -0
- package/docs/storage-and-sharing.md +118 -0
- package/docs/team-and-access.md +146 -0
- package/docs/tools.md +1810 -0
- package/docs/troubleshooting.md +155 -0
- package/docs/versions.md +79 -0
- package/docs/workspaces.md +160 -0
- package/package.json +15 -5
- package/src/commands.mjs +6984 -331
- package/src/credentials.mjs +3 -0
- package/src/guide.mjs +172 -0
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.
|