foldrun 1.0.0 → 1.1.1

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/bin/foldrun.mjs CHANGED
@@ -107,6 +107,7 @@ const HELP = `foldrun — agents are just folders
107
107
  foldrun agent run <name> run one agent once on the platform (--task "…", --to, --wait, --test)
108
108
  foldrun agent link <agent> add to its team, in its frontmatter: --subagent <worker>, --consult <agent> or --can-ask (--description for a worker with none)
109
109
  foldrun agent unlink <agent> take one away: --subagent <name>, --consult <name> or --can-ask
110
+ foldrun agent network <agent> the sites its sandbox may reach; set the level (open, trusted, none, inherit) and --add / --remove <site> (repeatable)
110
111
  foldrun agent import <workspace>/<agent> copy an agent you already have in another workspace into this one — its agent.md, own skills and scripts, not its memory (--as <name>, --workspace <name>; --to <ws> on the platform)
111
112
  foldrun agent import --list what could be imported into a deployed workspace: every other workspace you can read, and its agents (--to, --json)
112
113
  foldrun flow add <flow> <pattern> one canvas block as a markdown edit: chain, parallel, router, fan-out, loop, approval, ask, wait, rescue, subflow — diff shown, checked, --dry-run
@@ -122,6 +123,8 @@ const HELP = `foldrun — agents are just folders
122
123
  foldrun flow copy-step <flow> --step <n|agent>[,<n>…] the steps' markdown on stdout, as the canvas's Copy
123
124
  foldrun flow paste <flow> step markdown from stdin or --file in as new groups (--after <g>, default last) or beside one (--group <g>) — diff, checked, --dry-run
124
125
  foldrun flow draft "<what it should do>" Draft with AI on the platform (--to; --flow <name> redrafts one): shows the files and the check, saves on yes (--yes, --dry-run)
126
+ foldrun assistant ["<message>"] the dashboard's assistant (also: foldrun ai): answers from the foldrun docs and the deployed workspace's files, writes agents, flows, tools and skills as diffs with the check, saves on yes (--to, --yes, --dry-run, --json); every reply is kept — --conversation <id> carries one on; no message in a terminal is a conversation (/save, /files, /drop, /new, /quit)
127
+ foldrun assistant history your kept conversations, newest first (--q <text> searches them, --workspace, --json); show <id> prints one, rm <id> deletes it
125
128
  foldrun tool test <name> exercise one tool alone, no model and no run (key=value args, --to)
126
129
  foldrun hook ls the hooks in this workspace and the account library: on, tool, description — invalid ones with the reason (--json); --to <ws>: the DEPLOYED workspace's, who turns each on, and what they denied, rewrote or failed lately (--runs <n>, --all)
127
130
  foldrun hook add <name> --on <event> a hook folder: hook.md and a run.mjs that allows everything, the contract in its comments (--tool <pattern>, --library, --to)
@@ -184,7 +187,8 @@ const HELP = `foldrun — agents are just folders
184
187
  foldrun restore <ws> --to <commit|time|age> put a workspace's source back as it was then — shows the change, asks for the name typed back (--dry-run: the diff only, --yes)
185
188
  foldrun backups how the account is backed up (encrypted?), when it last was, the snapshots it is in — also request --what runs|state|storage|everything --at "<when>" [--to <ws>] [--note]
186
189
  foldrun backups requests every restore asked of the platform team, and where each stands (--json)
187
- foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm, status; --kind file|ssh|api|service-account|m2m for the other shapes
190
+ foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm, status; --kind file|ssh|api|service-account|m2m for the other shapes; --site <domain> (repeatable) binds it as it is stored
191
+ foldrun secrets sites NAME [site…] bind a secret to sites: the egress proxy fills it for those alone and it never enters a sandbox; no sites lists them, --clear unbinds
188
192
  foldrun secrets clients the OAuth clients saved on the platform — also add <name> (--provider or --authorize-url/--token-url, --client-id, --scopes), rm <name>
189
193
  foldrun connect NAME OAuth sign-in from the terminal, stored as an auto-refreshing secret — --client <saved> runs it from a saved client through the platform
190
194
  foldrun deploy [dir] push the whole account, or deploy <workspace> for one of them
@@ -288,12 +292,16 @@ Options
288
292
  --step <n> approve, reject: decide only that step; default is every step that is waiting
289
293
  --payload <json|text|@f> approve: release a step waiting on wait: event with this body, recorded as you
290
294
  --note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal; billing credit: the ledger line's note
291
- --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, history restore, restore <ws>, import, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
295
+ --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, assistant, history restore, restore <ws>, import, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
292
296
  --json report: the raw run record instead of the report; observe, usage, api version, changelog, find, preferences, workspace vocabulary, flags, workspaces, workspaces show, agent ls, flow ls, agent import --list, backups requests, team, hook ls: the raw document; version: {cli, core, platform}
293
297
  --events <a,b> account set notify: failed, awaiting-approval, completed
294
298
  --failed webhooks deliveries: only the ones that gave up
295
299
  --limit <n> runs, billing, history, changelog: how many rows
296
300
  --value "<text>" secrets set: skip the prompt (careful with shell history)
301
+ --site <domain> secrets set, secrets sites: a site the secret is bound to — the egress proxy fills it for those alone (repeatable, or a,b)
302
+ --clear secrets sites: unbind it
303
+ --add <site> agent network: add a site to its network: list (repeatable)
304
+ --remove <site> agent network: take a site off its network: list (repeatable)
297
305
  --out <file> api spec: where to write the OpenAPI document (default: stdout)
298
306
  --file <path> source put, library put, flow paste: the local file to send (default: stdin); secrets set --kind file|service-account: the file it holds; storage get, report get, report live, account export, export, billing statement: where to write it (- for stdout)
299
307
  --message "<why>" source put: recorded on the file's revision
@@ -331,7 +339,7 @@ Platform options (deploy, invoke, secrets, logs, keys)
331
339
  --local deploy: into the installation on this machine, even when signed in
332
340
  --commit <sha> deploy: record which commit this is
333
341
  --overwrite import: replace files the workspace already has with different text (refused otherwise)
334
- --dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, history restore, agent link, agent unlink: show the diff, write nothing; restore <ws>: the diff only, restore nothing; import: the preview only, write nothing; stop, rerun (bulk): list what matches, touch nothing
342
+ --dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, assistant, history restore, agent link, agent unlink: show the diff, write nothing; restore <ws>: the diff only, restore nothing; import: the preview only, write nothing; stop, rerun (bulk): list what matches, touch nothing
335
343
  --no-runtimes deploy: do not wait for the workspace's environments to be built
336
344
  --force deploy: deploy even while runs are in flight; pull, storage get: overwrite local files
337
345
  --yes deploy: allow deleting files this folder no longer has (asked otherwise; required with no terminal)
@@ -352,9 +360,9 @@ if (!command || command === "--help" || command === "-h") {
352
360
  // `--value` as the account's argument and stored an empty secret; `--force
353
361
  // ./dir` swallowed the directory. A flag followed by another flag is also
354
362
  // boolean, so an unlisted switch at least does not eat its neighbour.
355
- const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library"]);
363
+ const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear"]);
356
364
  // Flags said more than once collect into a list: `--tag a --tag b`.
357
- const REPEATABLE = new Set(["tag", "expect", "header"]);
365
+ const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove"]);
358
366
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
359
367
  // a span: `--wait 30m`, `--wait event`. Only a value that reads as one is
360
368
  // taken, so `invoke x --wait` and friends are as they were.
package/docs/agents.md CHANGED
@@ -65,6 +65,7 @@ the point of the week; a step that weighs two arguments and picks one is not.
65
65
  | `mcpServers` | an MCP server declared inline |
66
66
  | `secrets` | vault entries its tools may use — names only, never values |
67
67
  | `limits` | the most calls one step may make — per tool, per web action, or in all: `limits: {web.search: 40, crm: 20, calls: 300}`. A call past a limit is refused, never run ([Tools](tools#limits--how-many-calls-a-step-may-make)) |
68
+ | `network` | which sites its sandbox may reach: `open`, `trusted` or `none`, or a list of sites added to the trusted list — `network: [api.hubapi.com]`. Unset, an agent that may fetch or browse the web is `open` and every other `trusted`. Also in a workspace's or the account's `AGENTS.md` ([Network](network)) |
68
69
  | `permissionMode` | `plan` makes the run read-only, whatever else was granted |
69
70
  | `web` | the `web` tool's settings: `actions:` — which of its eight it may use (all when absent) — and who does each, `web: {search: brave, fetch: jina, browse: browserbase, crawl: firecrawl}`, with the key named in the vault. Unset is foldrun's own. `search:` also takes a block of the own engine's settings — engines, categories, safesearch, plugins ([Tools](tools#tuning-the-accounts-own-engine)); `browse:` a block of the browser's ([below](#webbrowse--which-browser-and-how-it-presents-itself)), and `browse:` alone cascades from `AGENTS.md`. The providers and their keys: [Tools](tools#reaching-the-web). A per-action key outside the block is not read — `check` errors on it |
70
71
 
package/docs/api.md CHANGED
@@ -65,7 +65,7 @@ api workspaces/$WS/flows/publish/run -X POST # start a flow
65
65
 
66
66
  | Route | Methods | |
67
67
  |---|---|---|
68
- | `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`) |
68
+ | `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`). A POST to a name that exists deploys over it; add `ifAbsent: true` to create only — a name that exists is then `409` and nothing changes, decided atomically (the assistant's Create workspace and `foldrun assistant` send it) |
69
69
  | `/api/workspaces/<ws>` | GET, PATCH, DELETE | read; rename or re-describe (`name`, `description`), or set its defaults (`timezone`, `budget`, `notify` — null clears one, edited in AGENTS.md in place); delete |
70
70
  | `/api/workspaces/<ws>/vocabulary` | GET | which document kinds this workspace admits — its `docs` list is what `[[ ]]` resolves: knowledge, memory, `state/` and `storage/` files, plus the folders themselves |
71
71
 
@@ -145,12 +145,35 @@ Left out, `test` is decided by the platform:
145
145
  a preview workspace's runs are tests unless the flow says `live: true`;
146
146
  everywhere else a run is live.
147
147
 
148
+ ## Assistant
149
+
150
+ | Route | Methods | |
151
+ |---|---|---|
152
+ | `/api/workspaces/<ws>/assistant` | POST | one reply from the assistant, which answers from the foldrun docs and this workspace's files only and writes agents, flows, tools and skills: `{ messages: [{ role: "user"\|"assistant", content }], files?: [{ path, content }], page?, conversation? }` — the conversation so far (the last message the person's, at most 8,000 characters), the files it proposed that are not saved yet, and the dashboard page being looked at. Returns `{ ok, reply, files: [{ path, content, before, sha256 }], issues: [{ where, message, level }], sources: [{ doc, section, url }], supply, workspace, usage, chargedUsd }` — `files` is every unsaved proposal as it now stands, checked; `workspace` is `{ name, purpose }` when it proposes a new workspace (free and kebab-case when proposed; create it with `POST /api/workspaces { name, template: true }`, then talk to the assistant there), else `null`; `usage` is `{ inputTokens, outputTokens, costUsd }` across every model call of the reply; `chargedUsd` what the account was charged for it on the platform's models (the same price as a run's tokens), `null` on its own provider; `conversation` is the id it was kept under — send it back as `conversation` to carry on (without one, a new conversation starts). **Writes nothing**; save with the source PUT (`ifMatch` the `sha256`, `null` for a new file). With `Accept: text/event-stream` it streams `status` (what it is reading), `text` (the reply as it is written), then `done` with the same body, or `error`. Needs `workspace:write`; 60 replies an hour per person or key, and on the platform's models 300 a day per account (`429`); at most 200 messages of 60,000 characters each (`413`). Closing the stream does not stop the reply: it finishes (at most $2, 30 tool calls, five minutes) and is charged; a Chat-Completions `provider:` is `422`. On the platform's models the balance is checked first (`402`) and each reply is one ledger line, flow `ai-assistant` |
153
+ | `/api/assistant/conversations?workspace=&q=&limit=` | GET | the asker's kept assistant conversations — a person's own, or a key's own — newest first, across the workspaces they may see: `{ conversations: [{ id, workspace, title, updatedAt, messages, unsaved }] }`. `q` searches the titles and every message; `workspace` narrows to one; `limit` 1–200 (50) |
154
+ | `/api/assistant/conversations/<id>` | GET, DELETE | one kept conversation, whole: `{ id, workspace, title, messages: [{ role, content, meta? }], files, createdAt, updatedAt }` (`meta` is what the dashboard draws under a reply: sources, tokens, charge, a proposed workspace); DELETE removes it. Anyone else's id is `404`, the same as none |
155
+
156
+ It may propose only `flows/<name>.md`, `agents/<name>/agent.md`,
157
+ `skills/<name>/SKILL.md` (and files under the skill's `scripts/`,
158
+ `references/` or `assets/`), and `tools/<name>/tool.md` with its program, or
159
+ `tools/<name>.md`. It reads the workspace's source and the account library,
160
+ never `state/`, `storage/`, runs or secrets. A proposal is checked with the
161
+ deploy's checks and the flow lint; a frontmatter key the docs never name, and
162
+ a value shaped like a credential, are errors.
163
+
164
+ A reply is not computed by the web: it is queued, and the platform's
165
+ assistant workers answer it — a pool of their own that scales with the
166
+ queue, so a burst of questions never slows the dashboard or takes a run's
167
+ turn. At most three replies of one account run at once. Each reply is kept
168
+ as a conversation private to whoever asked; the caller still sends the
169
+ history with each message.
170
+
148
171
  ## Agents and evals
149
172
 
150
173
  | Route | Methods | |
151
174
  |---|---|---|
152
175
  | `/api/workspaces/<ws>/agents` | GET, POST | list; create (`name`) |
153
- | `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }`. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
176
+ | `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }`, or `{ network }` — the sites its sandbox may reach: `"open"`, `"trusted"`, `"none"`, a list of sites, or `{ level, allow, defaults }`; `null` removes the line so it inherits ([Network](network)). A `network` nobody could read is a 400 that says why. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
154
177
  | `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
155
178
  | `/api/workspaces/<ws>/agents/import` | GET, POST | copy an agent in from another workspace of the account. GET lists what could be imported (every other workspace you can read, with its agents). POST `{ from, agent, as? }` copies `agents/<agent>/` — its agent.md and own skills/, scripts/, knowledge/; never memory/, outputs/, state/ — and answers `{ name, files, warnings }`; `warnings` names tools, skills, scripts and secrets the copy uses that this workspace lacks. 409 on a name clash (pass `as`), 404 for an unknown workspace or agent, 403 when `from` is outside your key's scope. Idempotent |
156
179
  | `/api/workspaces/<ws>/export` | GET | this workspace, one flow or one agent as a `.zip` — `?kind=flow&name=<flow>` (the flow, every flow it runs as a step, every agent those steps and their colleagues name, and the workspace tools, skills and scripts they grant), `?kind=agent&name=<agent>` (its folder and what it grants), or neither for the whole authored tree. Never memory/, state/, outputs/, storage, runs or a secret's value; `foldrun-package.json` inside says what it is. Any role that can read the workspace |
@@ -293,7 +316,7 @@ its header and requires a URL it can GET for itself.
293
316
 
294
317
  | Route | Methods | |
295
318
  |---|---|---|
296
- | `/api/secrets?workspace=` | GET, PUT, DELETE | the vault: list which secrets exist (never a value); set one (`name` plus one shape, below); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent |
319
+ | `/api/secrets?workspace=` | GET, PUT, PATCH, DELETE | the vault: list which secrets exist (never a value), each with the `sites` it is bound to; set one (`name` plus one shape, below, and optionally `sites`); bind one to sites with PATCH `{ name, sites }` — `sites` a list like `["api.example.com", "*.example.com"]`, `[]` unbinds, the value untouched, 404 for a secret that is not there, 400 for a site with a scheme, path or port and for a file, ssh or api secret ([bound to sites](secrets#bound-to-sites)); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent; PATCH and a PUT with `sites` are audited as `secret.sites` / `secret.set` |
297
320
 
298
321
  `PUT` takes `name` plus exactly one shape: `value` (a string), `file` (a PEM,
299
322
  a cert), `oauth2`, `service_account`, `ssh`, or `api`. They go to the vault;
@@ -0,0 +1,139 @@
1
+ # Assistant
2
+
3
+ The assistant is a panel on every page of the dashboard. Ask it how
4
+ foldrun works, or describe what you want built. It writes agents, flows,
5
+ tools and skills for a workspace and shows each one as a diff, checked, for
6
+ you to save.
7
+
8
+ Open it with **Ask AI** at the bottom right. **Draft with AI** on the Flows
9
+ page opens it too, with "Draft a flow that" ready to finish.
10
+
11
+ Inside a workspace it works in that workspace. Anywhere else, such as the
12
+ account home, Billing or the docs, it works in the one chosen in its header;
13
+ your browser remembers the choice. Each workspace keeps its own conversation.
14
+
15
+ ## What it knows
16
+
17
+ Only two things:
18
+
19
+ - **foldrun's docs**, these pages. It searches them and reads the sections
20
+ it needs before it answers, and it cites what it used as
21
+ `[doc › section]`, which links to the heading. Under each reply,
22
+ **Read N doc sections** lists them.
23
+ - **This workspace's files**: its `AGENTS.md`, agents, flows, skills,
24
+ tools, hooks, knowledge, evals and scripts, plus the account library and
25
+ the account's `AGENTS.md`. It never reads `state/`, `storage/`, runs or
26
+ secrets.
27
+
28
+ It has no web access, no shell and no other tools. If the docs do not cover
29
+ something, it says so; it does not guess. Anything that is not about
30
+ building on foldrun gets a one-line refusal. Text inside your files is
31
+ treated as data, never as instructions to it.
32
+
33
+ ## What it writes
34
+
35
+ Only these paths:
36
+
37
+ | Kind | Path |
38
+ |---|---|
39
+ | a flow | `flows/<name>.md` |
40
+ | an agent | `agents/<name>/agent.md` |
41
+ | a skill | `skills/<name>/SKILL.md`, and files under the skill's `scripts/`, `references/` or `assets/` |
42
+ | a tool | `tools/<name>/tool.md` with its program beside it, or `tools/<name>.md` |
43
+
44
+ When something else needs to change, such as `AGENTS.md`, a secret or a
45
+ setting, it tells you what and where, and you make that change.
46
+
47
+ ## New workspaces
48
+
49
+ When you ask for a new workspace, or describe a separate desk or pipeline,
50
+ it proposes one: a name and what it is for, with a **Create workspace**
51
+ button. Creating it needs `workspace:manage` (admin or owner). The workspace
52
+ starts blank, with only its `AGENTS.md`. The dashboard then opens it, and the
53
+ conversation carries on there: the assistant writes the new workspace's
54
+ agents, flows, tools and skills for you to save.
55
+
56
+ It never proposes a name that is taken. An account with no workspace yet
57
+ gets a box to name its first one.
58
+
59
+ Every file it proposes is checked as it writes it, and the model sees the
60
+ result and fixes errors before it finishes:
61
+
62
+ - the [deploy's own checks](deploying) and the flow lint, over the workspace
63
+ as it would be with the file in it;
64
+ - a frontmatter key that appears nowhere in these docs is an error, because
65
+ foldrun would not read it;
66
+ - a value shaped like a credential (an API key, a token, a private key) is
67
+ an error. Secrets go in the vault; see [Secrets](secrets);
68
+ - a skill needs `name:` (its folder) and `description:`;
69
+ - a script tool's `run:` program must be proposed or already exist.
70
+
71
+ Anything still wrong after that is shown beside the file, not hidden.
72
+
73
+ ## Saving
74
+
75
+ Nothing is written until you press **Save** on a file, or **Save all**. Each
76
+ save goes through the same write as the editor: one revision per file, with
77
+ the note "written with the assistant: …", which you can restore from
78
+ [History](backups-and-restore). If a file changed after it was proposed, the
79
+ save is refused and nothing is overwritten; ask the assistant to propose it
80
+ again.
81
+
82
+ Saving is not deploying. On the platform, the saved file is the workspace's
83
+ source, the same as an edit in the dashboard.
84
+
85
+ ## History
86
+
87
+ Every reply is kept. **History** in the panel's header lists your
88
+ conversations, newest first and grouped by day, across every workspace you
89
+ can see; type to search them, by title or by anything said in them. Open one
90
+ to carry it on: the files it proposed and you did not save come back with
91
+ it, and the panel moves to its workspace. **New chat** starts another.
92
+
93
+ Conversations are yours alone. Nobody else in the account, owners included,
94
+ can list or read them; an API key's conversations belong to that key. Each
95
+ person keeps their newest 500.
96
+
97
+ ## Where it runs
98
+
99
+ Not in the dashboard. Each reply is queued and answered by the platform's
100
+ assistant workers, a pool of its own that grows with the queue, so a burst
101
+ of questions never slows the dashboard or takes a turn from your runs. At
102
+ most three replies of one account run at once; more wait their turn, and the
103
+ panel says so. The model only ever reads through the assistant's own tools,
104
+ on the server, scoped to your account and workspace; it runs no code and has
105
+ no network or shell of its own.
106
+
107
+ ## Who can use it, and what it costs
108
+
109
+ You need to be able to edit the workspace (`workspace:write`: editor,
110
+ admin or owner). Viewers do not see the button enabled.
111
+
112
+ Each reply is one model call that may use several tools, on the same models
113
+ your runs use: the workspace's or account's own [provider](providers) or
114
+ key when there is one, otherwise the platform's. On the platform's models
115
+ the balance is checked first, and the reply is charged at the same price as
116
+ a run's tokens, as one line on [usage](budgets-and-billing), flow
117
+ `ai-assistant`. On your own provider or key, that provider bills you and
118
+ foldrun charges nothing.
119
+
120
+ Under every reply is what it used: tokens in (cache reads included) and out,
121
+ and what it was charged, or "on your own model key". One reply is capped at
122
+ $2, 30 tool calls and five minutes. Each person or key can send 60 replies an
123
+ hour, and when the platform's models answer, an account can send 300 a day.
124
+
125
+ **Stop** stops what you see, not the reply: it finishes on the server within
126
+ those caps and is charged, because what a reply cost is only known when it
127
+ ends. Its answer is in **History**.
128
+
129
+ ## From a terminal, and the API
130
+
131
+ `foldrun assistant "<message>" --to <workspace>` (or `foldrun ai`) asks
132
+ once, prints the reply, what it read and every proposed file as a diff, and
133
+ saves on yes. With no message, in a terminal, it is a conversation.
134
+ `foldrun assistant history` lists your kept conversations and
135
+ `--conversation <id>` carries one on. See [CLI](cli).
136
+
137
+ The route is `POST /api/workspaces/<ws>/assistant`. It writes nothing and
138
+ returns the proposals for you to save with the source PUT. See
139
+ [API](api#assistant).
package/docs/cli.md CHANGED
@@ -47,6 +47,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
47
47
  | `foldrun agent import <workspace>/<agent>` | copy an agent you already have in another workspace into this one: its `agent.md` and own `skills/`, `scripts/`, `knowledge/` — never `memory/`, `outputs/` or `state/`. Into the workspace you stand in, or `--workspace <name>`; `--as <name>` imports it under another name (and rewrites `name:`), which a name clash requires. `--to <workspace>` does it on the platform (`POST /api/workspaces/<ws>/agents/import`) and lists the tools, skills, scripts and secrets the copy uses that the workspace lacks. See [Importing an agent](workspaces#importing-an-agent-you-already-have) |
48
48
  | `foldrun agent import --list` | what could be imported into a deployed workspace: every other workspace this key may read, and each one's agents with their descriptions (`GET …/agents/import`). Imports nothing. `--to <workspace>`, `--json` |
49
49
  | `foldrun agent unlink <agent>` | take one away: `--subagent <name>`, `--consult <name>` or `--can-ask`. An emptied list is written `key: []`, not deleted. In this folder, refused the same way, nothing written, when the file changed since it was read |
50
+ | `foldrun agent network <agent> [open\|trusted\|none\|inherit]` | which sites the agent's sandbox may reach ([Network](network)). Alone, prints what a run resolves — the level, where it came from, the sites added. With a level and/or `--add <site>` / `--remove <site>` (repeatable), rewrites the one `network:` line of its `agent.md` — the edit the dashboard's Network card makes — prints the diff and the new resolution; `inherit` drops the agent's own level, and a line with nothing left to say is removed. `--dry-run` writes nothing; `--to <workspace>` edits the deployed copy through `PATCH /api/workspaces/<ws>/agents/<name>` |
50
51
  | `foldrun flow add <flow> <pattern>` | one palette block from the [flow canvas](flows#editing-on-the-canvas), as the same markdown edit the canvas writes (core's `flow-patterns`): `chain` (alias `step`: `--agent <name>`, `--instruction "…"`, `--after <n>` or `--before <n>`, default last), `parallel` (`--agent`, `--group <n>`), `router` (`--agent <triage>`, `--cases BUG=bugs,DOCS=docs`, `--else <agent>`, `--after`), `fan-out` (`--step`, `--each lines\|items\|"rows of <path>"`, `--max <n>`), `loop` (alias `evaluator`: `--step`, `--loop <n>` default 3, `--until <MARKER>` default APPROVED, `--judge "<claim>"` adds `verify: judge:`), `approval` (`--step`; the `!` marker, `approve: true` on a `?` step), `ask` (`--step`, `--question "…"`), `wait` (`--step`, `--wait 30m\|event`), `rescue` (`--step`, `--on-fail <agent>`), `subflow` (`--flow <other>`, `--after`). `--step` is the step's place in the file (1 = the first step line) or the agent it runs; `--off` takes a step pattern back off. Prints the diff, runs the rules `check` runs over the result and refuses — writing nothing — an edit that would add an error; one that was there before does not block it. `--dry-run` shows the diff and writes nothing. In this folder by default; `--to <workspace>` edits the deployed copy through the canvas's own route (`PATCH …/flows/<flow>` with `{ edit }`), and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
51
52
  | `foldrun flow rm-step <flow> --step <n\|agent>` | delete one step, as the canvas's × does (core's `removeStep`): the step line and its indented options go, the groups after an emptied one renumber, prose and the agent's file are untouched. Says what else changes first (`case:`/`when:` steps that will route on the step before, the renumbering), prints the diff, refuses one `foldrun check` would call a new error, then asks — no terminal needs `--yes`. `--step` is the step's place in the file (1 = the first step line) or the agent it runs. `--dry-run` writes nothing; `--to <workspace>` sends the canvas's `PATCH …/flows/<flow>` with `{ edit: { op: "remove", step } }`, and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
52
53
  | `foldrun flow dup-step <flow> --step <n\|agent>` | duplicate one step, as the canvas's Duplicate (Cmd/Ctrl+D) does (core's `duplicateStep`): a copy of the step line — its `?`/`!` marker and every indented option — goes directly under it with the same group number, so the copy runs in parallel with the original; nothing else moves. Prints the diff and refuses one `foldrun check` would call a new error; additive, so it does not ask. `--dry-run` writes nothing; `--to <workspace>` sends `PATCH …/flows/<flow>` with `{ edit: { op: "duplicate", step } }`, and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
@@ -60,6 +61,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
60
61
  | `foldrun flow copy-step <flow> --step <n\|agent>[,<n>…]` | the canvas's Copy: the steps' markdown — line, marker and every option (core's `stepSource`) — on stdout. Reads only |
61
62
  | `foldrun flow paste <flow>` | the canvas's Paste: step markdown from stdin or `--file <path>` goes in as new groups after `--after <g>` (default last), or beside group `--group <g>`, renumbered to fit (core's `pasteSteps`) — `foldrun flow copy-step a --step 2 \| foldrun flow paste b`. Diff, checked, `--dry-run`; `--to` sends `PATCH { edit: { op: "paste" } }` |
62
63
  | `foldrun flow draft "<what it should do>"` | **Draft with AI**, on the platform (`--to <workspace>`; `--flow <name>` redrafts an existing flow): `POST …/flows/draft` has a model write the flow and any agent files it needs, checks and repairs once, and the CLI prints the notes, every file as a diff and what the check still finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; on a pipe without `--yes` it saves nothing and exits 1, though the draft call was made and charged; each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. One model call, charged like a step |
64
+ | `foldrun assistant ["<message>"]` | the dashboard's **assistant**, from a terminal (also `foldrun ai`; `--to <workspace>`): `POST …/assistant` answers from the foldrun docs and the deployed workspace's files only, and writes agents, flows, tools and skills as proposals — the CLI prints the reply, the doc sections it read, every proposed file as a diff and what the check finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; on a pipe without `--yes` it saves nothing and exits 1 (a plain question with no files exits 0); each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. It prints the tokens the reply used and what it was charged. A proposed new workspace is created blank on yes (`--yes` on a pipe; never over one that exists); in a conversation the CLI then moves into it and carries on. Every reply is kept; `--conversation <id>` carries one on (its messages and unsaved files come back, and its workspace unless `--to` says another). With no message, in a terminal, it is a conversation: `/save`, `/files`, `/drop <path>`, `/new`, `/quit`. `--json` prints the platform's answer. Each reply is one model call, charged like a step. See [Assistant](assistant) |
65
+ | `foldrun assistant history` | your kept assistant conversations, newest first (`GET /api/assistant/conversations`): id, workspace, when, how many messages and unsaved files, and the first question. `--q <text>` searches them, `--workspace <name>` narrows to one, `--json` prints the answer. `foldrun assistant show <id>` prints one whole; `foldrun assistant rm <id>` deletes it (asks; `--yes`) |
63
66
  | `foldrun tool test <name>` | exercise one tool, alone — no model, no run, no flow. The real thing runs: the HTTP request against the declared `base:`, the script with the workspace's secrets in its environment, the MCP handshake. Prints the transport, whether it exited cleanly, how long it took, everything the tool printed, and any secret it needed by NAME and never by value. Its arguments are `key=value` pairs, one per argument the tool declares, and `--path` is an http tool's probe path. It waits five minutes by default, because a tool that crawls takes as long as it takes — `--timeout <seconds>` waits longer, and a test given up on says so rather than claiming the tool failed |
64
67
  | `foldrun hook ls` | the hooks in this workspace (`hooks/<name>/`) and the account library (`library/hooks/<name>/`): each one's `on:`, `tool:` and description, read the way a run reads them — a folder that is not a valid hook is listed with the reason and the command exits 1. A workspace hook shadows a library one of the same name. `--json` for a script. `--to <workspace>` lists the DEPLOYED workspace's hooks instead (`GET /api/workspaces/<ws>/hooks`, the dashboard's Hooks panel): who turns each on, and the denies, rewrites and failures they wrote into recent runs — `--runs <n>` reads further back (default 50, up to 500), `--all` shows every decision |
65
68
  | `foldrun hook add <name> --on <event>` | a hook folder: `hook.md` (`on:` one of `tool.call`, `tool.result`, `step.start`, `step.end`; `tool:` from `--tool <pattern>` — `Bash`, `Write\|Edit`, `mcp__*` — on the tool events only) and a self-contained `run.mjs` that allows everything, with the whole contract in its comments: the event on stdin, one JSON answer on stdout, exit 2 to deny with stderr as the reason. `--library` writes it in the account library for every workspace; `--to <workspace>` picks one in an account. An agent turns it on with `hooks: [name]`; `hooks:` in a workspace's or the account's AGENTS.md turns it on for every agent under it |
@@ -82,7 +85,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
82
85
  | `foldrun stop <run-id>` | kill a run in flight: the queued job is dropped and the sandbox the current step is spending in is destroyed. It prints which step is running and what has been spent, then asks you to type the run id back unless `--yes`. Finished steps keep their results, and costs already incurred stay on the bill. A run that has already finished is said so, not stopped. With no run id and a filter — `--status running,queued`, `--since 2h`, `--until 1h` (started before then: a span ago, or a date), `--flow <name>` — it stops every run that matches (`POST …/runs/bulk`), in `--to <workspace>` or across the account: it lists what matches first, asks, and then stops exactly the runs it listed, so one that starts in between is not swept up. `--dry-run` lists and touches nothing. The route refuses more than 100 at once |
83
86
  | `foldrun answer <run-id> "…"` | answer the question an agent is asking mid-step (`tools: [ask]`). `--option <n>` picks one of the choices it offered; `--question <id>` one of several. The agent is waiting in its sandbox and carries on at once. `foldrun approvals` lists what is being asked |
84
87
  | `foldrun message <run-id> "…"` | say something to a running agent. It reaches the model after its next tool call, as context — it changes none of the step's tools. `--step <n>` picks one when a parallel group has several running. Platform only |
85
- | `foldrun secrets set NAME` | store a secret — also `ls`, `rm`, and `status` (OAuth grant health) |
88
+ | `foldrun secrets set NAME` | store a secret — also `ls`, `rm`, and `status` (OAuth grant health). `--site <domain>` (repeatable, or `a,b`) binds it as it is stored |
89
+ | `foldrun secrets sites NAME [site…]` | bind a secret to the sites it is for: the egress proxy fills it in requests to those sites alone, and it never enters a sandbox ([bound to sites](secrets#bound-to-sites)). No sites prints what it is bound to; `--clear` unbinds. On the platform, or this machine's vault with `--local` |
86
90
  | `foldrun secrets set NAME --kind <k>` | the other shapes the Connections form stores: `file` (`--file <path>`), `ssh` (`--host`, `--user`, `--port`, `--key-file <path>` or a prompted password), `api` (`--base-url`, `--header "Name: value"` repeatable), `service-account` (`--file <key.json>`, `--scopes`), `m2m` (a client-credentials app: `--token-url`, `--client-id`, `--scopes`, the secret prompted). On the platform, or this machine's vault with `--local` |
87
91
  | `foldrun secrets clients` | the OAuth clients saved on the platform — what a consent runs from without typing the client again; the client secret is never shown. `clients add <name> --provider google\|github\|microsoft\|linkedin` (or `--authorize-url` / `--token-url`) `--client-id … --scopes …` saves one (secret prompted), `clients rm <name>` removes it |
88
92
  | `foldrun connect NAME` | OAuth sign-in from the terminal, stored as an auto-refreshing secret — `--provider google\|github\|microsoft\|linkedin`; reconnecting a secret whose client is saved asks for nothing (`--new-client` to enter one) |
@@ -192,12 +196,14 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
192
196
  | `--step <n>` | `approve`, `reject`: decide one gate, numbered as `report` prints it. Without it, every step of that run which is waiting |
193
197
  | `--payload <json\|text\|@file>` | `approve`: release a step waiting on `wait: event` with this body (`POST …/approve` `{ payload }`). JSON is sent as JSON, anything else as text; `@file` reads a file |
194
198
  | `--note "<text>"` | `approve`: guidance the approved step reads in its prompt — "approve, but skip the Sydney batch". `reject`: the reason, recorded on the trace. `billing credit`: the ledger line's note |
195
- | `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `history restore`, `restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
196
- | `--json` | `report`: the raw run record instead of the report. `observe`, `usage`, `workspaces`, `workspaces show`, `agent ls`, `flow ls`, `agent import --list`, `backups requests`, `status --platform --history`, `team`: the raw document |
199
+ | `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `assistant`, `history restore`, `restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
200
+ | `--json` | `report`: the raw run record instead of the report. `observe`, `usage`, `workspaces`, `workspaces show`, `agent ls`, `flow ls`, `agent import --list`, `assistant`, `backups requests`, `status --platform --history`, `team`: the raw document |
197
201
  | `--events <a,b>` | `account set notify`: which events are notified on — `failed`, `awaiting-approval`, `completed` |
198
202
  | `--watch` | `invoke`: follow the run's trace here as it happens; the exit code is the run's |
199
203
  | `--print` | `open`: print the URL, do not open it |
200
204
  | `--value "<text>"` | `secrets set`: skip the prompt — careful with shell history |
205
+ | `--site <domain>`, `--clear` | `secrets set`, `secrets sites`: a site the secret is bound to (repeatable); `--clear` unbinds |
206
+ | `--add <site>`, `--remove <site>` | `agent network`: a site to add to, or take off, the agent's `network:` list (repeatable) |
201
207
  | `--account` | `secrets`: account scope rather than this workspace's — the workspace is the folder's name (`--workspace`), local or on the platform alike |
202
208
  | `--wait` | `invoke`, `agent run`: hold on and print the result. Asked in 25-second pieces, so a flow that runs for many minutes is never cut by whatever sits in front of the platform; a run parked on a person prints its page and exits 2 |
203
209
  | `--from <n>` | `invoke`, `flow run`, `rerun`: start at step n; earlier steps are recorded as skipped. A bulk `rerun` defaults to 1. (One flag, two meanings: a template on `init`, a step everywhere else) |
@@ -224,7 +230,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
224
230
  | `--timeout <s>` | how many seconds one request to the platform may take — or `FOLDRUN_TIMEOUT`. Default 30, and 300 for `tool test`, where the tool itself is the thing being waited on |
225
231
  | `--local` | `deploy`, `secrets`, `logs`: the installation on this machine, even when signed in |
226
232
  | `--quiet` | leave out the dim "acting as …" line a platform command prints on stderr |
227
- | `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `history restore`, `agent link`/`unlink`: show the diff, write nothing. `restore`: the diff only, restore nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
233
+ | `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `assistant`, `history restore`, `agent link`/`unlink`, `agent network`: show the diff, write nothing. `restore`: the diff only, restore nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
228
234
  | `--out <file>` | `api spec`: where to write the OpenAPI document (default stdout) |
229
235
  | `--file <path>` | `source put`: the file to send. `storage get`, `report get`, `account export`, `billing statement --csv`: where to write — `-` for stdout |
230
236
 
package/docs/flows.md CHANGED
@@ -112,7 +112,7 @@ begins.
112
112
  | `ask:` | the same gate carrying a question; the typed answer reaches the prompt (a plain approval with no note reaches it as "approved with no note") |
113
113
  | `preview:` | what the gate shows: paths under `storage/` (a `workspace/storage/` prefix is accepted), comma-separated, globs allowed — `draft/*.mdx, draft/images/*.webp`; at most 12 files |
114
114
  | `wait:` | `3d` (`s`/`m`/`h`/`d`, capped at 30 days) or `event` — the run page shows the event URL and can release it with a payload, recorded as released by you (approve with `payload`) |
115
- | `on-fail:` | another agent takes the step over, with the failure as context. The rescuer owes what the step owed: its `verify:` and its `output:` |
115
+ | `on-fail:` | another agent takes the step over, with the failure as context — and, for a gated step, the person's approval and answer, which it acts within. The rescuer owes what the step owed: its `verify:`, its `output:`, and a `loop:` step's `until:` marker (a loop that runs out of cycles goes to its rescuer too). A `verify:` that accepts `BLOCKED` never fails, so it never reaches the rescuer |
116
116
  | `delegate:` | the step's agent picks who runs next, from this set |
117
117
 
118
118
  ### What is retried without a `retry:`
@@ -0,0 +1,158 @@
1
+ # Network
2
+
3
+ Every step runs in a sandbox of its own, and `network:` says which sites that
4
+ sandbox may reach. An agent that reads the open web may reach any public
5
+ site; every other agent reaches a list of trusted sites plus whatever it
6
+ declares. Least access by default, widened on purpose — the same default
7
+ Anthropic's own cloud sandboxes start from.
8
+
9
+ ```yaml
10
+ ---
11
+ name: crm-sync
12
+ tools: [crm_sync]
13
+ network: [api.hubapi.com] # the trusted list, plus this
14
+ ---
15
+ ```
16
+
17
+ ## The three levels
18
+
19
+ | level | the sandbox may reach |
20
+ |---|---|
21
+ | `open` | any public site |
22
+ | `trusted` | the trusted list, the sites you add, and what the step declares |
23
+ | `none` | only the sites you add and what the step declares |
24
+
25
+ **What the step declares** is always allowed, at every level: the hosts of
26
+ its `apis:`, its remote MCP servers, its web providers (`web: {search: brave}`),
27
+ and the sites its [secrets are bound to](secrets#bound-to-sites). You never list
28
+ those twice.
29
+
30
+ No level is ever a way into private space: the cluster, the LAN and the
31
+ cloud metadata endpoint are refused whatever `network:` says.
32
+
33
+ ## The default
34
+
35
+ Unset, the level follows what the agent does:
36
+
37
+ - an agent with the `web` tool that may **fetch or browse** is `open` — a search
38
+ result can name any site, and a list would break it;
39
+ - every other agent is `trusted`.
40
+
41
+ An agent whose `web:` allows only `search` (`web: {actions: [search]}`) is
42
+ `trusted`: it reads results, not pages.
43
+
44
+ ## The trusted list
45
+
46
+ Package registries, source hosts and cloud SDKs — the domains Anthropic's
47
+ Claude Code cloud environments allow at their default *Trusted* level, plus
48
+ the Debian mirrors the runner image installs `runtime: system:` packages
49
+ from. Among them:
50
+
51
+ | | |
52
+ |---|---|
53
+ | Anthropic | api.anthropic.com, claude.ai, docs.claude.com, … |
54
+ | Source | github.com, raw.githubusercontent.com, gitlab.com, bitbucket.org, … |
55
+ | Packages | registry.npmjs.org, pypi.org, files.pythonhosted.org, crates.io, rubygems.org, proxy.golang.org, Maven, NuGet, … |
56
+ | Containers | registry-1.docker.io, ghcr.io, gcr.io, public.ecr.aws, … |
57
+ | Cloud | \*.googleapis.com, \*.amazonaws.com, \*.microsoftonline.com, … |
58
+ | Linux | deb.debian.org, security.debian.org, archive.ubuntu.com, … |
59
+
60
+ The whole list is `TRUSTED_SITES` in `@foldrun/core/network`; `foldrun agent
61
+ network <agent>` says how many sites it holds. It includes wide wildcards
62
+ such as `*.amazonaws.com` because SDKs need them — which means a bucket
63
+ anyone can create is reachable too. An agent handling something sensitive
64
+ can drop the list: `defaults: false`.
65
+
66
+ ## Writing it
67
+
68
+ ```yaml
69
+ network: trusted # a level
70
+ network: [api.example.com, "*.example.org"] # add sites; the level stays
71
+ network:
72
+ level: none
73
+ allow: [api.example.com]
74
+ defaults: false # trusted without the trusted list
75
+ ```
76
+
77
+ A site is a domain or `*.domain` — no scheme, no path, no port; every port
78
+ of the host is covered. `*.example.com` covers `api.example.com`,
79
+ `a.b.example.com` and `example.com` itself. Quote a wildcard in YAML.
80
+
81
+ It is read on the agent, in the workspace's `AGENTS.md` and in the account's
82
+ `AGENTS.md`. The level and `defaults` are nearest-wins — the agent's, else the
83
+ workspace's, else the account's. The `allow` lists add up across all three,
84
+ so a site every agent needs is written once, in the account's `AGENTS.md`.
85
+
86
+ `foldrun check` and every deploy refuse a value they cannot read — a level
87
+ that is not one, a site with `https://` or a path — because it would leave
88
+ the agent on the level above it, wider or narrower than you wrote.
89
+
90
+ ## Changing it
91
+
92
+ On the agent's page in the dashboard, the **Network** card shows the level a
93
+ run resolves, where it came from, and the sites on it; **Change** sets the
94
+ level and the sites to add. From the terminal:
95
+
96
+ ```sh
97
+ foldrun agent network crm-sync # what a run resolves
98
+ foldrun agent network crm-sync --add api.hubapi.com # add a site
99
+ foldrun agent network crm-sync none --remove a.com # a level, and take one off
100
+ foldrun agent network crm-sync inherit # back to the workspace's or the default
101
+ ```
102
+
103
+ Both write the one `network:` line of `agent.md` and nothing else. Over the
104
+ API it is `PATCH /api/workspaces/<ws>/agents/<agent>` with `{ "network": … }`
105
+ (`null` inherits).
106
+
107
+ ## How it is held
108
+
109
+ Every program in the sandbox reaches the internet through the **egress
110
+ proxy**: the pod's `HTTPS_PROXY` and `HTTP_PROXY` name it, with the step's
111
+ own lease as the password, and `NODE_USE_ENV_PROXY=1` turns that on for Node.
112
+ curl, Python's `requests` and `httpx`, pip, npm, git, apt and the Agent SDK
113
+ all read those variables. The proxy holds the step's list and checks every
114
+ connection against it:
115
+
116
+ - a site **off the list** is refused — `403`, saying which site and what to
117
+ add — and the refusal is a line in the run trace;
118
+ - a site **on the list** is a tunnel: the bytes pass through and the proxy
119
+ never reads them;
120
+ - a site a **secret is bound to** is intercepted, so the secret can be filled
121
+ in on the way out ([Secrets](secrets#bound-to-sites)).
122
+
123
+ On a cluster the pod's network policy backs this up. A step whose level is
124
+ `trusted` or `none` runs in a pod that may reach the proxy and DNS and
125
+ nothing else, so a program that ignores the proxy variables finds no route
126
+ instead of going round the list; it cannot reach the account's browser or
127
+ search pods either, which are the world fetched on a run's behalf.
128
+
129
+ The trace opens with the step's network:
130
+
131
+ ```
132
+ network: trusted (default) — the trusted list (207 sites); added: api.hubapi.com
133
+ egress: CONNECT pypi.org:443 × 3 (840ms total)
134
+ network: example.com is not a site this step may reach (network: trusted). Add it to the agent's network: list — network: [example.com] — or set network: open. Refused.
135
+ ```
136
+
137
+ ### Report-only, while an install learns
138
+
139
+ An install enforces the list when its worker has `FOLDRUN_NETWORK_ENFORCE=1`.
140
+ Until then the proxy only **reports**: a site off the list is allowed, and
141
+ the trace names it once per step —
142
+
143
+ ```
144
+ network: api.example.com would be refused (network: trusted) — allowed, because this install only reports. Add it to the agent's network: list to keep it reachable.
145
+ ```
146
+
147
+ — so the sites your agents really use can be added before anything is
148
+ refused. The trace line `network: … — report only on this install` says
149
+ which mode a step ran in.
150
+
151
+ ### Where it is not held
152
+
153
+ `foldrun run` on your own machine has no sandbox, and a compose install with
154
+ no egress proxy (no `FOLDRUN_EGRESS_URL`) has no forward door: there,
155
+ `network:` is checked and resolved but not enforced, and the trace says
156
+ `not enforced here`. On the single-container tier the proxy variables are
157
+ set and the proxy holds the list, but the container's network is not walled
158
+ in, so a program that ignores the variables still goes direct.
package/docs/secrets.md CHANGED
@@ -12,6 +12,7 @@ git — which is what makes a workspace safe to commit.
12
12
  ```sh
13
13
  foldrun secrets set STRIPE_KEY # this workspace's; prompted, never echoed
14
14
  foldrun secrets set STRIPE_KEY --account # the account's, shared by every workspace
15
+ foldrun secrets set STRIPE_KEY --site api.stripe.com # bound: never in a sandbox (below)
15
16
  foldrun secrets ls
16
17
  ```
17
18
 
@@ -112,6 +113,59 @@ land on any worker behind the Service and still find its lease. Without
112
113
  a proxy configured (the CLI, a compose install without
113
114
  `FOLDRUN_EGRESS_URL`) a step materialises everything, as it always did.
114
115
 
116
+ ## Bound to sites
117
+
118
+ A secret can be **bound** to the sites it is for. A bound secret never
119
+ enters a sandbox, whatever the step grants — Bash, a script tool, a browser.
120
+ The sandbox holds `${NAME}`; the [egress proxy](network#how-it-is-held), which
121
+ every program's HTTPS goes through, fills it in requests to those sites and
122
+ no other. This is how Anthropic's cloud sandboxes attach a network secret,
123
+ and it closes the gap above: a step with Bash used to get every declared
124
+ secret's real value.
125
+
126
+ ```sh
127
+ foldrun secrets sites DATAFORSEO_PASSWORD api.dataforseo.com
128
+ foldrun secrets sites DATAFORSEO_PASSWORD # what it is bound to
129
+ foldrun secrets sites DATAFORSEO_PASSWORD --clear # unbind it
130
+ ```
131
+
132
+ Or **Bind to sites** under the secret in Settings → Secrets, or over the API:
133
+
134
+ ```sh
135
+ PATCH /api/secrets { "name": "DATAFORSEO_PASSWORD", "sites": ["api.dataforseo.com"] }
136
+ PUT /api/secrets { "name": "…", "value": "…", "sites": ["api.dataforseo.com"] } # set and bind at once
137
+ ```
138
+
139
+ What a program in the sandbox writes is unchanged; it sends the placeholder
140
+ where the value would go:
141
+
142
+ ```sh
143
+ curl -H "Authorization: Bearer $HUBSPOT_TOKEN" https://api.hubapi.com/crm/v3/objects/contacts
144
+ curl -u "$DATAFORSEO_LOGIN:$DATAFORSEO_PASSWORD" https://api.dataforseo.com/v3/serp/...
145
+ ```
146
+
147
+ The proxy fills `${NAME}` in the request's URL and headers, and inside a
148
+ `Basic` Authorization header (which `curl -u` sends base64'd). To do that
149
+ for a bound site it ends the TLS connection itself, with a certificate from
150
+ the install's **egress CA**, which the sandbox trusts: the pod's start puts
151
+ that CA in a bundle and points `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`,
152
+ `CURL_CA_BUNDLE`, `PIP_CERT`, `GIT_SSL_CAINFO` and `NODE_EXTRA_CA_CERTS` at
153
+ it. Only bound sites are intercepted; every other site is a tunnel the
154
+ proxy cannot read. The CA is derived from the install's key, so every
155
+ worker presents the same one, and rotating the key rotates it.
156
+
157
+ A bound site is always reachable, at every [network](network) level.
158
+
159
+ Leave a secret unbound when the program needs the value itself rather than
160
+ in a request: one that signs requests with it (an HMAC, AWS SigV4), puts it
161
+ in a request body, or uses it for something other than HTTP. A file, an ssh
162
+ key or an `api` header set cannot be bound — they are unpacked inside the
163
+ sandbox. The run trace says which way each secret went:
164
+
165
+ ```
166
+ secrets: GA_KEY materialised in the sandbox for bash; DATAFORSEO_PASSWORD bound to sites — filled by the egress proxy, never in the sandbox; api tools and the model key go through the egress proxy
167
+ ```
168
+
115
169
  ## Connected accounts
116
170
 
117
171
  Some third parties are connected rather than pasted — Google, GitHub,
package/docs/security.md CHANGED
@@ -19,9 +19,14 @@ machine.
19
19
 
20
20
  ## What the network allows
21
21
 
22
- A run pod may reach the public internet, DNS, the egress proxy where its
23
- credentials are filled in, and its own account's search and browser pods —
24
- and nothing else. The policy denies every private range (RFC1918) and the
22
+ A run pod may reach DNS, the egress proxy where its credentials are filled
23
+ in, and — for an agent whose [network](network) is `open` — the public
24
+ internet and its own account's search and browser pods; nothing else. An
25
+ agent that is `trusted` or `none` (the default for one that does not read
26
+ the web) reaches the internet only through the egress proxy, which holds
27
+ its list of sites per connection; on an install that enforces it
28
+ (`FOLDRUN_NETWORK_ENFORCE=1`) its pod has no other route, and no way to the
29
+ account's browser or search pods. The policy denies every private range (RFC1918) and the
25
30
  cloud metadata endpoint — the address that, on a cloud host, hands out the
26
31
  machine's own credentials. It cannot reach the platform's API, its
27
32
  database, its object store, or another account's pods. Workspace files
@@ -65,10 +70,12 @@ On a cluster the value does not enter the sandbox either. The worker keeps
65
70
  it and the step sends its API and model requests through a per-step
66
71
  **egress proxy** with `${NAME}` placeholders; the proxy fills each one for
67
72
  the host it was granted to and forwards. A compromised script in the pod
68
- reads its environment and finds the name, not the key. The one exception
69
- is a script tool that needs the value in process (a browser seeding
70
- cookies), which materialises the step's secrets into the sandbox and says
71
- so in the trace — see [Secrets](secrets).
73
+ reads its environment and finds the name, not the key. The exception is a
74
+ step whose script tools or Bash need the value in process (a browser seeding
75
+ cookies), which materialises its secrets into the sandbox and says so in the
76
+ trace — unless the secret is [bound to sites](secrets#bound-to-sites): a bound
77
+ secret never enters a sandbox, because the proxy fills it for those sites
78
+ whatever program sent the request.
72
79
 
73
80
  At rest, each account has its own encryption key; the account keys are
74
81
  wrapped by one root key the platform holds. A stolen copy of an account's
package/docs/tools.md CHANGED
@@ -123,6 +123,12 @@ a `name @ url` is refused, with a line in the log.
123
123
  never seen the program, and say when to use the tool in `description` — that
124
124
  sentence is what decides whether it gets called at all.
125
125
 
126
+ The program gets each argument the agent fills in as a long flag,
127
+ `--<name> <value>`: the tool above runs as
128
+ `run.py --query "SELECT …" --files sales.csv`. An argument left out or empty
129
+ is not passed, so give optional ones a default in the program. What it
130
+ prints, stdout and stderr together, is what the agent reads back.
131
+
126
132
  ## MCP — someone else's tools
127
133
 
128
134
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "dependencies": {
14
- "@foldrun/core": "^1.0.0"
14
+ "@foldrun/core": "^1.1.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
24
- "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts",
24
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts",
25
25
  "release": "node scripts/release.mjs",
26
26
  "typecheck": "tsc -p tsconfig.json",
27
27
  "docs:sync": "node scripts/sync-docs.mjs",
@@ -49,6 +49,6 @@
49
49
  "devDependencies": {
50
50
  "@types/node": "^26.6.4",
51
51
  "gray-matter": "^4.0.3",
52
- "typescript": "^5.9.3"
52
+ "typescript": "^6.0.3"
53
53
  }
54
54
  }
package/src/commands.mjs CHANGED
@@ -889,6 +889,10 @@ async function checkProblems(workspace, flags = {}) {
889
889
  const { readAgentsMd } = await core();
890
890
  const { readLimits } = await import("@foldrun/core/limits");
891
891
  for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
892
+ // `network:` here is every agent's default: a value nobody can read
893
+ // leaves them on the account's, or the platform's.
894
+ const net = await import("@foldrun/core/network").catch(() => null);
895
+ for (const w of net ? net.networkProblems(readAgentsMd(workspace)?.data?.network) : []) note("error", "AGENTS.md", w);
892
896
  }
893
897
 
894
898
  const { unopenedFrontmatter } = /** @type {any} */ (await core());
@@ -912,6 +916,9 @@ async function checkProblems(workspace, flags = {}) {
912
916
  // written into the file. The run would say so in its trail and carry
913
917
  // on; the deploy refuses it; this is where a person hears it first.
914
918
  for (const w of a.webProblems ?? []) note("error", `agents/${a.name}`, w);
919
+ // A network: value that cannot be read leaves the agent on the level
920
+ // above it — the deploy refuses it, so check says so first.
921
+ for (const w of a.networkProblems ?? []) note("error", `agents/${a.name}`, w);
915
922
  // What works but should change: the old `session:` key, and a pinned
916
923
  // user_agent whose Chrome is not the runner image's (the platform's
917
924
  // /api/version says which Chrome; FOLDRUN_RUNNER_ENGINES on a server).
@@ -2798,6 +2805,19 @@ async function readStorage(page, origin) {
2798
2805
  .catch(() => empty);
2799
2806
  }
2800
2807
 
2808
+ /** Bind on this machine's vault — a core from before site binding says so. */
2809
+ async function localSetSites(name, sites, scope) {
2810
+ const k = await core();
2811
+ if (typeof k.setSecretSites !== "function") throw new Error("binding a secret to sites needs @foldrun/core 1.1 or later — update foldrun");
2812
+ return k.setSecretSites("default", name, sites, scope);
2813
+ }
2814
+
2815
+ /** `--site a.com --site "*.b.org"` or `--site a.com,b.org`, as one list. */
2816
+ function siteFlags(flags) {
2817
+ const raw = flags.site === undefined ? [] : Array.isArray(flags.site) ? flags.site : [flags.site];
2818
+ return raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
2819
+ }
2820
+
2801
2821
  async function secretsCmd(positional, flags, layout) {
2802
2822
  // --local: this machine's store even when signed in, the same escape
2803
2823
  // hatch deploy and keys have. Without it, a signed-in laptop sent every
@@ -2828,7 +2848,7 @@ async function secretsCmd(positional, flags, layout) {
2828
2848
  console.log();
2829
2849
  for (const s of entries) {
2830
2850
  console.log(
2831
- ` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}`)}`,
2851
+ ` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}${s.sites?.length ? ` · sent only to ${s.sites.join(", ")}` : ""}`)}`,
2832
2852
  );
2833
2853
  }
2834
2854
  console.log();
@@ -2863,6 +2883,31 @@ async function secretsCmd(positional, flags, layout) {
2863
2883
 
2864
2884
  if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
2865
2885
 
2886
+ // A secret bound to sites never enters a sandbox: steps hold `${NAME}` and
2887
+ // the egress proxy fills it in requests to those sites alone.
2888
+ if (verb === "sites") {
2889
+ const given = [...positional.slice(2), ...siteFlags(flags)];
2890
+ if (!given.length && flags.clear !== true) {
2891
+ const entries = url
2892
+ ? (await remoteCall(url, flags, `/api/secrets${scope ? `?workspace=${encodeURIComponent(scope)}` : ""}`)).secrets
2893
+ : (await core()).listSecrets("default", scope);
2894
+ const hit = entries.find((s) => s.name === name);
2895
+ if (!hit) throw new Error(`no secret ${name}${scope ? ` in workspace ${scope}` : " in the account"}`);
2896
+ console.log(hit.sites?.length
2897
+ ? `\n ${c.bold(name)} is sent only to ${hit.sites.join(", ")} — on the platform it never enters a sandbox\n`
2898
+ : `\n ${c.bold(name)} is not bound to sites — a step with a script or Bash gets its real value. Bind it: foldrun secrets sites ${name} api.example.com\n`);
2899
+ return 0;
2900
+ }
2901
+ const sites = flags.clear === true ? [] : given;
2902
+ const stored = url
2903
+ ? (await remoteCall(url, flags, "/api/secrets", { method: "PATCH", body: JSON.stringify({ name, sites, workspace: scope }) })).sites
2904
+ : await localSetSites(name, sites, scope);
2905
+ console.log(stored.length
2906
+ ? `\n ${c.green("✓")} ${name} is sent only to ${stored.join(", ")} — on the platform the egress proxy fills it, and it never enters a sandbox\n`
2907
+ : `\n ${c.green("✓")} ${name} unbound — a step with a script or Bash gets its real value again\n`);
2908
+ return 0;
2909
+ }
2910
+
2866
2911
  if (verb === "set") {
2867
2912
  // --kind: the other shapes the Connections form stores — a file, an SSH
2868
2913
  // login, API headers, a service account's key, a client-credentials app.
@@ -2898,15 +2943,18 @@ async function secretsCmd(positional, flags, layout) {
2898
2943
  const value =
2899
2944
  typeof flags.value === "string" ? flags.value : await promptHidden(` value for ${name}: `);
2900
2945
  if (!value) throw new Error("empty value — nothing stored");
2946
+ const sites = siteFlags(flags);
2901
2947
  if (url) {
2902
2948
  await remoteCall(url, flags, "/api/secrets", {
2903
2949
  method: "PUT",
2904
- body: JSON.stringify({ name, value, workspace: scope }),
2950
+ body: JSON.stringify({ name, value, workspace: scope, ...(sites.length ? { sites } : {}) }),
2905
2951
  });
2906
2952
  } else {
2907
- (await core()).setSecret("default", name, value, scope);
2953
+ const k = await core();
2954
+ k.setSecret("default", name, value, scope);
2955
+ if (sites.length) await localSetSites(name, sites, scope);
2908
2956
  }
2909
- console.log(`\n ${c.green("✓")} ${name} stored — declare it in agent.md under \`secrets:\` to use it\n`);
2957
+ console.log(`\n ${c.green("✓")} ${name} stored${sites.length ? ` and sent only to ${sites.join(", ")}` : ""} — declare it in agent.md under \`secrets:\` to use it\n`);
2910
2958
  return 0;
2911
2959
  }
2912
2960
 
@@ -7243,6 +7291,84 @@ async function agentLinkCmd(positional, flags, layout) {
7243
7291
  return 0;
7244
7292
  }
7245
7293
 
7294
+ /**
7295
+ * `foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]…
7296
+ * [--remove <site>]…` — which sites the agent's sandbox may reach, its
7297
+ * `network:` line, edited the way the dashboard's Network card edits it
7298
+ * (core network.ts setNetworkSetting: that one line, nothing else). With no
7299
+ * level and no site it prints what a run resolves.
7300
+ */
7301
+ async function agentNetworkCmd(positional, flags, layout) {
7302
+ const name = positional[1];
7303
+ const word = positional[2];
7304
+ const usage = "`foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]… [--remove <site>]…`";
7305
+ if (!name) throw new Error(usage);
7306
+ if (word !== undefined && !["open", "trusted", "none", "inherit"].includes(word)) throw new Error(`${usage} — "${word}" is not a level`);
7307
+ const net = await import("@foldrun/core/network").catch(() => null);
7308
+ if (!net) throw new Error("agent network needs @foldrun/core 1.1 or later — update foldrun");
7309
+ const desk = await openDesk(flags, layout, `agent network ${name}`);
7310
+ const rel = `agents/${name}/agent.md`;
7311
+ if (!desk.files.has(rel)) {
7312
+ const agents = [...desk.files.keys()].map((r) => /^agents\/([^/]+)\/agent\.md$/.exec(r)?.[1]).filter(Boolean);
7313
+ throw new Error(`no agent "${name}" in ${desk.label} — it has ${agents.join(", ") || "none"}`);
7314
+ }
7315
+ const resolve = (files) =>
7316
+ inTree(desk, files, async (dir) => {
7317
+ const { listAgents, readFrontmatter } = await core();
7318
+ return {
7319
+ info: listAgents("default", "workspace").find((a) => a.name === name),
7320
+ own: net.readNetwork(readFrontmatter(path.join(dir, rel)).network).setting,
7321
+ };
7322
+ });
7323
+ const show = (info) => {
7324
+ const p = info?.network;
7325
+ if (!p) return;
7326
+ console.log(` ${net.networkLine(p, true)}`);
7327
+ if (p.level !== "open") console.log(` ${c.dim("a run also allows its MCP servers, its web providers and the sites its secrets are bound to")}`);
7328
+ };
7329
+ const raw = desk.files.get(rel);
7330
+ const now = await resolve(desk.files);
7331
+ const list = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim().toLowerCase()).filter(Boolean);
7332
+ const adds = list(flags.add);
7333
+ const removes = list(flags.remove);
7334
+ if (word === undefined && !adds.length && !removes.length) {
7335
+ console.log(`\n ${c.bold(name)} ${c.dim(`· ${desk.label}`)}`);
7336
+ show(now.info);
7337
+ console.log();
7338
+ return 0;
7339
+ }
7340
+ const own = now.own ?? { allow: [] };
7341
+ const level = word === "inherit" ? undefined : word ?? own.level;
7342
+ const allow = [...new Set([...own.allow, ...adds])].filter((x) => !removes.includes(x));
7343
+ const setting = level || allow.length || own.defaults === false
7344
+ ? { ...(level ? { level } : {}), allow, ...(own.defaults === false ? { defaults: false } : {}) }
7345
+ : null;
7346
+ const problems = setting ? net.readNetwork(setting).problems : [];
7347
+ if (problems.length) throw new Error(problems.join(" "));
7348
+ const next = net.setNetworkSetting(raw, setting);
7349
+ if (next === raw) {
7350
+ console.log(`\n ${c.dim(`${name}'s network: already says that — nothing to change`)}\n`);
7351
+ return 0;
7352
+ }
7353
+ console.log(`\n ${c.bold(name)} ${c.dim(`· network · ${desk.label}`)}\n`);
7354
+ await printDiff(rel, raw, next);
7355
+ if (flags["dry-run"] === true) {
7356
+ console.log(`\n ${c.dim("--dry-run: nothing written")}\n`);
7357
+ return 0;
7358
+ }
7359
+ if (desk.platform) {
7360
+ await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(name)}`, { method: "PATCH", body: JSON.stringify({ network: setting }) });
7361
+ } else {
7362
+ assertUnchanged(desk, rel, raw);
7363
+ writeIfUnchanged(desk, rel, raw, next);
7364
+ }
7365
+ const after = await resolve(new Map([...desk.files, [rel, next]]));
7366
+ console.log();
7367
+ show(after.info);
7368
+ console.log(`\n ${c.green("✓")} ${rel} ${c.dim(desk.platform ? `in ${desk.ws} — a revision on the platform` : "— `foldrun deploy` ships it")}\n`);
7369
+ return 0;
7370
+ }
7371
+
7246
7372
  /**
7247
7373
  * `foldrun flow show <flow>` — the canvas, in a terminal: the trigger, one
7248
7374
  * block per group, each step's chips, the team under its agent, and what
@@ -8855,6 +8981,295 @@ async function flowDraftCmd(positional, flags, layout) {
8855
8981
  return 0;
8856
8982
  }
8857
8983
 
8984
+ /** `foldrun assistant history` — your kept conversations, newest first
8985
+ * (`--q` searches them, `--workspace` narrows to one, `--json`). */
8986
+ async function assistantHistoryCmd(url, flags) {
8987
+ const q = new URLSearchParams();
8988
+ if (typeof flags.q === "string" && flags.q.trim()) q.set("q", flags.q.trim());
8989
+ if (typeof flags.workspace === "string") q.set("workspace", flags.workspace);
8990
+ if (typeof flags.limit === "string") q.set("limit", flags.limit);
8991
+ const d = await remoteCall(url, flags, `/api/assistant/conversations${q.size ? `?${q}` : ""}`);
8992
+ const list = d.conversations ?? [];
8993
+ if (flags.json) {
8994
+ console.log(JSON.stringify(d, null, 2));
8995
+ return 0;
8996
+ }
8997
+ if (!list.length) {
8998
+ console.log(`\n ${c.dim(flags.q ? "nothing matches" : "no conversations yet")}\n`);
8999
+ return 0;
9000
+ }
9001
+ console.log();
9002
+ for (const x of list) {
9003
+ console.log(` ${c.bold(x.id)} ${x.workspace} ${c.dim(`${ago(x.updatedAt)} ago · ${x.messages} message${x.messages === 1 ? "" : "s"}${x.unsaved ? ` · ${x.unsaved} unsaved` : ""}`)}`);
9004
+ console.log(` ${x.title}`);
9005
+ }
9006
+ console.log(`\n ${c.dim("foldrun assistant show <id> · foldrun assistant --conversation <id> \"…\" · foldrun assistant rm <id>")}\n`);
9007
+ return 0;
9008
+ }
9009
+
9010
+ /** `foldrun assistant show <id>` — one kept conversation, whole. */
9011
+ async function assistantShowCmd(url, flags, id) {
9012
+ const d = await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`);
9013
+ if (flags.json) {
9014
+ console.log(JSON.stringify(d, null, 2));
9015
+ return 0;
9016
+ }
9017
+ console.log(`\n ${c.bold(d.title)} ${c.dim(`${d.workspace} · ${ago(d.updatedAt)} ago`)}\n`);
9018
+ for (const m of d.messages ?? []) {
9019
+ console.log(` ${m.role === "user" ? c.bold("you ›") : c.dim("assistant ›")}`);
9020
+ console.log(`${String(m.content).split("\n").map((l) => ` ${l}`).join("\n")}\n`);
9021
+ }
9022
+ if ((d.files ?? []).length) console.log(` ${c.dim(`${d.files.length} proposed file${d.files.length === 1 ? "" : "s"} not saved: ${d.files.map((f) => f.path).join(", ")}`)}\n`);
9023
+ return 0;
9024
+ }
9025
+
9026
+ /** `foldrun assistant rm <id>` — delete one kept conversation (asks; --yes). */
9027
+ async function assistantRmCmd(url, flags, id) {
9028
+ if (!(await sureToDelete(flags, "assistant rm", `Delete the conversation ${id}`))) return 0;
9029
+ await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`, { method: "DELETE" });
9030
+ console.log(` ${c.green("✓")} deleted ${id}\n`);
9031
+ return 0;
9032
+ }
9033
+
9034
+ /**
9035
+ * `foldrun assistant ["<message>"] --to <ws>` (also `foldrun ai`) — the
9036
+ * dashboard's assistant from a terminal: it answers from foldrun's docs and
9037
+ * the deployed workspace's files only, and writes agents, flows, tools and
9038
+ * skills as proposals (POST …/assistant). With a message it answers once;
9039
+ * with none, in a terminal, it is a conversation (/save, /files, /drop
9040
+ * <path>, /new, /quit). Proposals are shown as diffs with what the check
9041
+ * finds and saved only on yes (--yes; --dry-run never), each through
9042
+ * `PUT …/source` with ifMatch, so a file that changed meanwhile is refused.
9043
+ * --json prints the platform's answer as it is.
9044
+ */
9045
+ async function assistantCmd(positional, flags, layout) {
9046
+ const url = platformFor(flags, "assistant");
9047
+ // The kept conversations: `history`, `show <id>`, `rm <id>`.
9048
+ const sub = positional[0];
9049
+ if (sub === "history" && positional.length === 1) return assistantHistoryCmd(url, flags);
9050
+ if ((sub === "show" || sub === "rm") && positional.length === 2 && /^c_[a-z0-9]{8,40}$/.test(positional[1])) {
9051
+ return sub === "show" ? assistantShowCmd(url, flags, positional[1]) : assistantRmCmd(url, flags, positional[1]);
9052
+ }
9053
+ // A kept conversation knows its workspace; --to may still say another.
9054
+ let ws = typeof flags.conversation === "string" && typeof flags.to !== "string" ? "" : platformWorkspace(flags, layout, "assistant");
9055
+ const first = positional.join(" ").trim();
9056
+ /** A new workspace the last reply proposed.
9057
+ * @type {{ name: string, purpose: string } | null} */
9058
+ let proposedWs = null;
9059
+ if (!first && !process.stdin.isTTY) {
9060
+ throw new Error('`foldrun assistant "<question or what to build>" --to <workspace>` — with no message it needs a terminal to talk in');
9061
+ }
9062
+ /** @type {{ role: "user" | "assistant", content: string }[]} */
9063
+ let messages = [];
9064
+ /** @type {{ path: string, content: string, before: string | null, sha256: string | null }[]} */
9065
+ let files = [];
9066
+ /** @type {{ where: string, message: string, level: string }[]} */
9067
+ let issues = [];
9068
+ /** The kept conversation this one continues; null until the first reply. */
9069
+ let conversation = typeof flags.conversation === "string" ? flags.conversation : null;
9070
+ if (conversation) {
9071
+ // Carry a kept one on: its messages and its unsaved files come back.
9072
+ const kept = await remoteCall(url, flags, `/api/assistant/conversations/${enc(conversation)}`);
9073
+ messages = (kept.messages ?? []).map((m) => ({ role: m.role, content: m.content }));
9074
+ files = kept.files ?? [];
9075
+ if (!ws) ws = kept.workspace;
9076
+ if (!flags.json) console.log(`\n ${c.dim(`carrying on "${kept.title}" in ${kept.workspace} — ${messages.length} message${messages.length === 1 ? "" : "s"}`)}`);
9077
+ }
9078
+
9079
+ const showFiles = async () => {
9080
+ for (const f of files) {
9081
+ console.log(` ${c.dim(f.before === null ? "new" : "replaces")}`);
9082
+ await printDiff(f.path, f.before ?? "", f.content);
9083
+ console.log();
9084
+ }
9085
+ for (const i of issues) console.log(` ${i.level === "error" ? c.red("error") : c.amber(" warn")} ${c.bold(i.where)} ${i.message}`);
9086
+ if (issues.length) console.log();
9087
+ };
9088
+
9089
+ const ask = async (text) => {
9090
+ messages.push({ role: "user", content: text });
9091
+ if (!flags.json) console.log(`\n ${c.dim("reading the docs and the workspace — one reply, charged like a step…")}`);
9092
+ let d;
9093
+ try {
9094
+ d = await remoteCall(
9095
+ url,
9096
+ flags,
9097
+ `/api/workspaces/${enc(ws)}/assistant`,
9098
+ { method: "POST", body: JSON.stringify({ messages, files: files.map(({ path: p, content }) => ({ path: p, content })), ...(conversation ? { conversation } : {}) }) },
9099
+ { seconds: Math.max(timeoutSeconds(flags), 300) },
9100
+ );
9101
+ } catch (err) {
9102
+ messages.pop();
9103
+ throw err;
9104
+ }
9105
+ messages.push({ role: "assistant", content: d.reply ?? "" });
9106
+ files = d.files ?? [];
9107
+ issues = d.issues ?? [];
9108
+ proposedWs = d.workspace ?? null;
9109
+ if (typeof d.conversation === "string" && d.conversation) conversation = d.conversation;
9110
+ if (flags.json) {
9111
+ console.log(JSON.stringify(d, null, 2));
9112
+ return;
9113
+ }
9114
+ console.log(`\n${(d.reply ?? "").split("\n").map((l) => ` ${l}`).join("\n")}\n`);
9115
+ if ((d.sources ?? []).length) console.log(` ${c.dim(`read: ${d.sources.map((s) => `${s.doc} › ${s.section}`).join(" · ")}`)}\n`);
9116
+ if (conversation) console.log(` ${c.dim(`kept as ${conversation} — carry on with --conversation ${conversation}`)}\n`);
9117
+ if (d.usage) {
9118
+ const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
9119
+ const paid = d.supply === "provider" ? "on your own model key" : typeof d.chargedUsd === "number" ? `$${d.chargedUsd.toFixed(d.chargedUsd > 0 && d.chargedUsd < 0.01 ? 4 : 2)} charged` : "";
9120
+ console.log(` ${c.dim(`${k(d.usage.inputTokens)} tokens in · ${k(d.usage.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
9121
+ }
9122
+ if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
9123
+ await showFiles();
9124
+ };
9125
+
9126
+ /** What the last reply proposed — read through a call, because ask()
9127
+ * sets it and a type checker narrowing the variable cannot see that. */
9128
+ const proposal = () => proposedWs;
9129
+
9130
+ /** Create the proposed workspace, blank, as the dashboard does — after a
9131
+ * yes, and never over one that exists (the same POST deploys over it).
9132
+ * @param {{ name: string, purpose: string }} p */
9133
+ const createProposed = async (p) => {
9134
+ const name = p.name;
9135
+ if (!(await confirmed(flags, "assistant", `Create the workspace ${name}? [y/N] `))) {
9136
+ console.log(`\n ${c.dim("not created")}\n`);
9137
+ return false;
9138
+ }
9139
+ // ifAbsent: the server refuses a name that exists (409) rather than
9140
+ // deploying over it — atomically, where a list-then-create could race.
9141
+ try {
9142
+ await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ name, template: true, ifAbsent: true }) });
9143
+ } catch (err) {
9144
+ if (err instanceof HttpError && err.status === 409) {
9145
+ console.log(` ${c.red("✗")} a workspace called ${name} exists — nothing created\n`);
9146
+ return false;
9147
+ }
9148
+ throw err;
9149
+ }
9150
+ console.log(` ${c.green("✓")} created ${name}\n`);
9151
+ proposedWs = null;
9152
+ return name;
9153
+ };
9154
+
9155
+ const save = async (note) => {
9156
+ if (!files.length) {
9157
+ console.log(` ${c.dim("nothing proposed to save")}\n`);
9158
+ return true;
9159
+ }
9160
+ if (!(await confirmed(flags, "assistant", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
9161
+ console.log(`\n ${c.dim("nothing saved")}\n`);
9162
+ return true;
9163
+ }
9164
+ const message = `written with the assistant: ${note.replace(/\s+/g, " ").slice(0, 80)}`;
9165
+ for (const f of [...files]) {
9166
+ try {
9167
+ await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, { method: "PUT", body: JSON.stringify({ path: f.path, content: f.content, message, ifMatch: f.sha256 ?? null }) });
9168
+ console.log(` ${c.green("✓")} ${f.path}`);
9169
+ files = files.filter((x) => x.path !== f.path);
9170
+ issues = issues.filter((i) => !(i.where === f.path || i.where.startsWith(`${f.path}:`)));
9171
+ } catch (err) {
9172
+ console.log(` ${c.red("✗")} ${f.path} — ${err instanceof Error ? err.message : err}`);
9173
+ return false;
9174
+ }
9175
+ }
9176
+ console.log(`\n ${c.dim(`each is a revision — \`foldrun history --to ${ws}\` · \`foldrun check --to ${ws}\``)}\n`);
9177
+ return true;
9178
+ };
9179
+
9180
+ if (first) {
9181
+ await ask(first);
9182
+ const p = proposal();
9183
+ if (p && !flags.json) {
9184
+ if (flags["dry-run"] === true) {
9185
+ console.log(` ${c.dim("--dry-run: no workspace created")}\n`);
9186
+ return 0;
9187
+ }
9188
+ if (flags.yes !== true && !process.stdin.isTTY) {
9189
+ console.log(` ${c.dim(`not a terminal to ask — no workspace created; run it again with --yes, or create it with \`foldrun new ${p.name}\` and deploy`)}\n`);
9190
+ return 1;
9191
+ }
9192
+ const made = await createProposed(p);
9193
+ if (made) console.log(` ${c.dim(`carry on there: foldrun assistant --to ${made} "<what it should do>"`)}\n`);
9194
+ return made ? 0 : 1;
9195
+ }
9196
+ if (!files.length || flags.json) return 0;
9197
+ if (flags["dry-run"] === true) {
9198
+ console.log(` ${c.dim("--dry-run: nothing saved")}\n`);
9199
+ return 0;
9200
+ }
9201
+ if (flags.yes !== true && !process.stdin.isTTY) {
9202
+ console.log(` ${c.dim(`the reply was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`}, or save them with \`foldrun source put\``)}\n`);
9203
+ return 1;
9204
+ }
9205
+ return (await save(first)) ? 0 : 1;
9206
+ }
9207
+
9208
+ const { createInterface } = await import("node:readline/promises");
9209
+ console.log(`\n ${c.bold("foldrun assistant")} ${c.dim(`· ${ws} · answers from the foldrun docs; nothing is saved until you /save`)}`);
9210
+ console.log(` ${c.dim("/save /files /drop <path> /new /quit")}\n`);
9211
+ for (;;) {
9212
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9213
+ let line;
9214
+ try {
9215
+ line = (await rl.question(`${c.bold("you ›")} `)).trim();
9216
+ } catch {
9217
+ line = "/quit"; // the input closed (Ctrl-D)
9218
+ } finally {
9219
+ rl.close();
9220
+ }
9221
+ if (!line) continue;
9222
+ if (line === "/quit" || line === "/exit") {
9223
+ if (files.length) console.log(`\n ${c.dim(`${files.length} proposed file${files.length === 1 ? "" : "s"} not saved`)}`);
9224
+ console.log();
9225
+ return 0;
9226
+ }
9227
+ if (line === "/new") {
9228
+ messages = [];
9229
+ files = [];
9230
+ issues = [];
9231
+ conversation = null;
9232
+ console.log(` ${c.dim("a new conversation")}\n`);
9233
+ continue;
9234
+ }
9235
+ if (line === "/files") {
9236
+ if (files.length) await showFiles();
9237
+ else console.log(` ${c.dim("nothing proposed")}\n`);
9238
+ continue;
9239
+ }
9240
+ if (line.startsWith("/drop")) {
9241
+ const target = line.slice(5).trim();
9242
+ const before = files.length;
9243
+ files = files.filter((f) => f.path !== target);
9244
+ issues = issues.filter((i) => !(i.where === target || i.where.startsWith(`${target}:`)));
9245
+ console.log(` ${c.dim(files.length < before ? `dropped ${target}` : `${target || "(no path)"} is not proposed`)}\n`);
9246
+ continue;
9247
+ }
9248
+ if (line === "/save") {
9249
+ await save(messages.find((m) => m.role === "user")?.content ?? "");
9250
+ continue;
9251
+ }
9252
+ try {
9253
+ await ask(line);
9254
+ const p = proposal();
9255
+ if (p) {
9256
+ const made = await createProposed(p);
9257
+ if (made) {
9258
+ // The conversation moves into the new workspace and carries on.
9259
+ ws = made;
9260
+ files = [];
9261
+ issues = [];
9262
+ messages.push({ role: "assistant", content: `Created the workspace \`${made}\`.` });
9263
+ console.log(` ${c.dim(`now working in ${made}`)}`);
9264
+ await ask(`The workspace "${made}" now exists and you are working in it. Carry on with what I asked for: write its agents, flows, tools and skills.`);
9265
+ }
9266
+ }
9267
+ } catch (err) {
9268
+ console.log(`\n ${c.red("✗")} ${err instanceof Error ? err.message : err}\n`);
9269
+ }
9270
+ }
9271
+ }
9272
+
8858
9273
  /**
8859
9274
  * `foldrun history restore <path> --id <revision> --to <ws>` — the History
8860
9275
  * drawer's "Restore this version": the file is rewritten with that
@@ -9769,11 +10184,15 @@ export async function run(command, positional, flags, workspace, layout) {
9769
10184
  // `new` scaffolds one here; `run` runs one there. The noun is the same
9770
10185
  // thing in both cases, which is why they share a command.
9771
10186
  if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
10187
+ if (positional[0] === "network") return agentNetworkCmd(positional, flags, layout);
9772
10188
  if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
9773
10189
  if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
9774
10190
  return positional[0] === "run"
9775
10191
  ? agentRunCmd(positional, flags, layout)
9776
10192
  : scaffoldCmd("agents", positional, flags, layout);
10193
+ case "assistant":
10194
+ case "ai":
10195
+ return assistantCmd(positional, flags, layout);
9777
10196
  case "flow":
9778
10197
  // `new` scaffolds a file; `add`, `rm-step` and `show` are the canvas; `run` is
9779
10198
  // `invoke`, spelled the way `agent run` is; `rotate-hook` a new URL.
package/src/guide.mjs CHANGED
@@ -30,7 +30,7 @@ export function agentRulesBlock() {
30
30
 
31
31
  # Read foldrun's docs before you edit this folder
32
32
 
33
- This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling.
33
+ This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
34
34
 
35
35
  This block is for coding tools. It is written and re-added by the foldrun CLI, and the runtime strips it, so the agents that run here never see it; the rest of this file is the account's own context, which they do read. Committing the block with your work keeps the tree clean.
36
36