@foldrun/core 0.2.0 → 0.4.0

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.
Files changed (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
20
20
  import { z } from "zod";
21
21
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { runInContainer } from "./container.ts";
23
+ import { hostSafeEnv } from "./host-env.ts";
23
24
 
24
25
  // A script's only clock is the `timeout:` its own tool.md declares. There
25
26
  // was a two-minute default and a ten-minute cap here; both were the
@@ -55,6 +56,14 @@ export interface ScriptSpec {
55
56
  * default, `materialised`, is a program that reads values from its
56
57
  * environment and needs them real — a browser seeding cookies. */
57
58
  secrets?: "proxied" | "materialised";
59
+ /** `outward: true` — this program makes something happen outside (sends,
60
+ * posts, orders). On a test run a step that grants it is handed no real
61
+ * secret at all, whatever their names. */
62
+ outward?: boolean;
63
+ /** `test_mode: allow` — a reader that happens to use a send-capable
64
+ * secret's name (a GBP reader on a GBP token): on a test run the step
65
+ * still gets the real values. An `outward` tool in the same step wins. */
66
+ testMode?: "allow";
58
67
  }
59
68
 
60
69
  export function parseScripts(raw: unknown): ScriptSpec[] {
@@ -82,6 +91,8 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
82
91
  args,
83
92
  interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
84
93
  secrets: e.secrets === "proxied" ? "proxied" : undefined,
94
+ outward: e.outward === true ? true : undefined,
95
+ testMode: e.test_mode === "allow" ? "allow" : undefined,
85
96
  timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
86
97
  });
87
98
  }
@@ -128,7 +139,7 @@ function containerInterpreter(abs: string): string {
128
139
  return byExt[path.extname(abs).toLowerCase()] ?? abs;
129
140
  }
130
141
 
131
- function runScript(
142
+ export function runScript(
132
143
  cwd: string,
133
144
  spec: ScriptSpec,
134
145
  values: Record<string, string>,
@@ -229,9 +240,14 @@ function runScript(
229
240
  return;
230
241
  }
231
242
 
243
+ // Not process.env: on a platform that is every tenant's root key and
244
+ // the datastore credentials. The child starts from the allowlisted
245
+ // base and gets what the agent declared, by name — see host-env.ts.
246
+ // In a run container `env` already carries the container's whole
247
+ // environment, which is the boundary, so nothing is lost there.
232
248
  const child = spawn(cmd, args, {
233
249
  cwd,
234
- env: { ...process.env, ...env },
250
+ env: { ...hostSafeEnv(), ...env },
235
251
  timeout: timeoutFor(spec),
236
252
  });
237
253
  let out = "";
@@ -0,0 +1,185 @@
1
+ // Does this credential still work?
2
+ //
3
+ // The vault knows a secret's name, its scope and its shape. It knows nothing
4
+ // about whether the thing on the other end still accepts it, and that is the
5
+ // only question anyone actually has. A key that has quietly stopped working
6
+ // looks identical to a key nobody has used yet, and both look identical to a
7
+ // key that is fine — three very different situations rendered the same way.
8
+ //
9
+ // Two facts, recorded where they are already known and nowhere else:
10
+ //
11
+ // when it was last used, and against which host
12
+ // what the other end said the last time — accepted, or refused
13
+ //
14
+ // The egress proxy sees both for every http tool and every model call,
15
+ // because filling the credential is its whole job. Notifications and the
16
+ // provider check report their own. Nothing new is instrumented, and a secret
17
+ // materialised into a sandbox reports nothing, which the page says plainly
18
+ // rather than guessing.
19
+ //
20
+ // Never the value, never a hash of the value, never the request body. A
21
+ // name, a host, a status and a time.
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import crypto from "node:crypto";
26
+ import { accountDir } from "./store.ts";
27
+
28
+ /** How a credential is identified here. A name alone is not enough: the
29
+ * account and each workspace can hold a secret by the same name, and they
30
+ * are different credentials with different health. */
31
+ export function healthKey(name: string, scope: "account" | "workspace", workspace?: string | null): string {
32
+ return scope === "workspace" && workspace ? `workspace:${workspace}:${name}` : `account:${name}`;
33
+ }
34
+
35
+ export interface SecretUse {
36
+ /** ISO time of the most recent use. */
37
+ at: string;
38
+ /** The host it was sent to, so "which of my four keys is this" has an
39
+ * answer without opening a run. */
40
+ host: string;
41
+ /** The response status, when there was one. */
42
+ status: number | null;
43
+ /** Refused means the credential itself was rejected: 401, 402 or 403.
44
+ * A 500 is the other end failing, which says nothing about the key. */
45
+ outcome: "accepted" | "refused" | "error";
46
+ }
47
+
48
+ export interface SecretRecord {
49
+ last: SecretUse;
50
+ /** Consecutive refusals, newest first — one is a fluke, five is a dead
51
+ * key. Reset by any acceptance. */
52
+ refusals: number;
53
+ /** The last acceptance, kept through a run of refusals so a page can say
54
+ * "worked until Tuesday", which is the sentence that dates the breakage. */
55
+ lastAccepted: string | null;
56
+ }
57
+
58
+ type HealthFile = Record<string, SecretRecord>;
59
+
60
+ const healthFile = (tenant: string) => path.join(accountDir(tenant), "secret-health.json");
61
+
62
+ function read(tenant: string): HealthFile {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(healthFile(tenant), "utf8")) as HealthFile;
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ function write(tenant: string, data: HealthFile): void {
71
+ const file = healthFile(tenant);
72
+ if (!fs.existsSync(path.dirname(file))) return;
73
+ // pid alone is not unique across pods on a shared volume.
74
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
75
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
76
+ fs.renameSync(tmp, file);
77
+ }
78
+
79
+ // Writes are queued and coalesced off the caller's path. The egress proxy
80
+ // notes a use per secret per request, before it streams the response body,
81
+ // and a synchronous read-modify-write of a JSON file there is latency on
82
+ // every proxied call — "never blocks" has to be true, not aspirational.
83
+ // One writer per process; the queue drains in order.
84
+ const pending = new Map<string, { name: string; use: { host: string; status: number | null; at?: string } }[]>();
85
+ let draining = false;
86
+
87
+ function drain(): void {
88
+ if (draining) return;
89
+ draining = true;
90
+ setImmediate(() => {
91
+ try {
92
+ for (const [tenant, uses] of pending) {
93
+ pending.delete(tenant);
94
+ const data = read(tenant);
95
+ for (const { name, use } of uses) apply(data, name, use);
96
+ write(tenant, data);
97
+ }
98
+ } catch {
99
+ // best effort, always
100
+ } finally {
101
+ draining = false;
102
+ if (pending.size) drain();
103
+ }
104
+ });
105
+ }
106
+
107
+ function apply(data: HealthFile, name: string, use: { host: string; status: number | null; at?: string }): void {
108
+ const outcome = outcomeFor(use.status);
109
+ const at = use.at ?? new Date().toISOString();
110
+ const prev = data[name];
111
+ data[name] = {
112
+ last: { at, host: use.host, status: use.status, outcome },
113
+ // Only a refusal counts against a key. An error at the other end is
114
+ // the other end's problem and must not accumulate into a false alarm.
115
+ refusals: outcome === "refused" ? (prev?.refusals ?? 0) + 1 : outcome === "accepted" ? 0 : prev?.refusals ?? 0,
116
+ lastAccepted: outcome === "accepted" ? at : prev?.lastAccepted ?? null,
117
+ };
118
+ }
119
+
120
+ /** 401, 402 and 403 are the credential being refused. Everything else is
121
+ * the request, the model or the provider — none of which is the key's
122
+ * fault, and calling them a credential failure would send people to
123
+ * rotate a key that was never the problem. */
124
+ export function outcomeFor(status: number | null): SecretUse["outcome"] {
125
+ if (status === null) return "error";
126
+ if (status === 401 || status === 402 || status === 403) return "refused";
127
+ if (status >= 200 && status < 400) return "accepted";
128
+ return "error";
129
+ }
130
+
131
+ /**
132
+ * Record one use. `key` is a health key (see healthKey), never a bare name.
133
+ * Never throws and never blocks: the write is queued and happens off the
134
+ * caller's path, because this runs on every proxied request.
135
+ */
136
+ export function noteSecretUse(
137
+ tenant: string,
138
+ key: string,
139
+ use: { host: string; status: number | null; at?: string },
140
+ ): void {
141
+ if (!key) return;
142
+ const list = pending.get(tenant) ?? [];
143
+ list.push({ name: key, use: { ...use, at: use.at ?? new Date().toISOString() } });
144
+ pending.set(tenant, list);
145
+ drain();
146
+ }
147
+
148
+ /** Wait for queued writes to land — for tests, and for a process that is
149
+ * about to exit. */
150
+ export function flushSecretHealth(): Promise<void> {
151
+ return new Promise((resolve) => {
152
+ const check = () => (pending.size === 0 && !draining ? resolve() : setImmediate(check));
153
+ check();
154
+ });
155
+ }
156
+
157
+ /** Everything known about this account's credentials, by name. Names the
158
+ * vault holds but nothing has used are simply absent, which is the honest
159
+ * answer: unused is not the same as broken. */
160
+ export function secretHealth(tenant: string): HealthFile {
161
+ return read(tenant);
162
+ }
163
+
164
+ /** The credentials worth saying something about: refused the last time they
165
+ * were used. Sorted worst first — most consecutive refusals, then oldest
166
+ * last-success, which is the order someone would work through them. */
167
+ export function failingSecrets(tenant: string): { name: string; record: SecretRecord }[] {
168
+ return Object.entries(read(tenant))
169
+ .filter(([, r]) => r.last.outcome === "refused")
170
+ .map(([name, record]) => ({ name, record }))
171
+ .sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
172
+ }
173
+
174
+ /** Drop what is known about a secret — for a delete, and for a rotation,
175
+ * where the old key's refusals say nothing about the new one. */
176
+ export function forgetSecretHealth(tenant: string, name: string): void {
177
+ try {
178
+ const data = read(tenant);
179
+ if (!(name in data)) return;
180
+ delete data[name];
181
+ write(tenant, data);
182
+ } catch {
183
+ // best effort
184
+ }
185
+ }
package/src/secrets.ts CHANGED
@@ -24,25 +24,52 @@ import path from "node:path";
24
24
  import { dataRoot } from "./paths.ts";
25
25
  import { platform } from "./platform.ts";
26
26
 
27
+ /**
28
+ * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
29
+ * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
30
+ * 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
31
+ * four bytes would still "verify" on 32 bits of authentication instead of
32
+ * 128. The tag comes from the record on disk; the record is the thing an
33
+ * attacker with file access would edit. One option makes a short tag an
34
+ * error rather than a weaker check.
35
+ */
36
+ const GCM = { authTagLength: 16 } as const;
37
+
27
38
  // The account's own data key, when the platform holds one; the install key
28
39
  // otherwise. See platform.ts — locally there is no account key.
29
40
  const tenantKey = (tenant: string) => platform.tenantKey(tenant);
30
41
  import crypto from "node:crypto";
31
42
  import { assertSafeName } from "./store.ts";
43
+ import { trimSlashes } from "./paths.ts";
32
44
 
33
45
  const keyFile = () => path.join(dataRoot(), ".secret-key");
34
46
 
35
47
  /** Which store a secret came from. */
36
48
  export type SecretScope = "account" | "workspace";
37
49
 
38
- function masterKey(): Buffer {
50
+ /**
51
+ * The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
52
+ * contents of `.secret-key` under the data root — created here, the first
53
+ * time anything asks, so there is never a moment when a derived token has
54
+ * nothing real to hang off. The vault, hook tokens, approval links and git
55
+ * push secrets all derive from this one value; rotating it invalidates every
56
+ * one of them together. Exported for webhook.ts, which used to fall back to
57
+ * a fixed dev string until the vault happened to create this file — so hook
58
+ * URLs were computable by anyone who read the source, and then changed the
59
+ * first time a secret was stored.
60
+ */
61
+ export function installKeyMaterial(): string {
39
62
  const fromEnv = process.env.FOLDRUN_SECRET_KEY;
40
- if (fromEnv) return crypto.createHash("sha256").update(fromEnv).digest();
63
+ if (fromEnv) return fromEnv;
41
64
  fs.mkdirSync(dataRoot(), { recursive: true });
42
65
  if (!fs.existsSync(keyFile())) {
43
66
  fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
44
67
  }
45
- return crypto.createHash("sha256").update(fs.readFileSync(keyFile(), "utf8")).digest();
68
+ return fs.readFileSync(keyFile(), "utf8");
69
+ }
70
+
71
+ function masterKey(): Buffer {
72
+ return crypto.createHash("sha256").update(installKeyMaterial()).digest();
46
73
  }
47
74
 
48
75
  // `workspace` undefined means the account store.
@@ -135,7 +162,7 @@ export function encryptValue(
135
162
  ): { iv: string; tag: string; data: string; k?: "tenant" } {
136
163
  const { key, mark } = keyFor(tenant);
137
164
  const iv = crypto.randomBytes(12);
138
- const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
165
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
139
166
  const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
140
167
  return {
141
168
  iv: iv.toString("base64"),
@@ -180,6 +207,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
180
207
  "aes-256-gcm",
181
208
  key,
182
209
  Buffer.from(rec.iv, "base64"),
210
+ GCM,
183
211
  );
184
212
  decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
185
213
  return Buffer.concat([
@@ -357,7 +385,7 @@ export function rotateMasterKey(
357
385
  }
358
386
  let plain: string;
359
387
  try {
360
- const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
388
+ const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
361
389
  d.setAuthTag(Buffer.from(rec.tag, "base64"));
362
390
  plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
363
391
  } catch {
@@ -366,7 +394,7 @@ export function rotateMasterKey(
366
394
  continue;
367
395
  }
368
396
  const iv = crypto.randomBytes(12);
369
- const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
397
+ const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
370
398
  const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
371
399
  next[name] = {
372
400
  iv: iv.toString("base64"),
@@ -498,6 +526,9 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
498
526
  */
499
527
  export async function materializeSecrets(
500
528
  env: Record<string, string>,
529
+ /** Whose secrets these are, when the caller knows: lets the platform keep
530
+ * each OAuth connection's health and quote a reconnect link on failure. */
531
+ ctx?: { tenant: string; workspace?: string },
501
532
  ): Promise<Record<string, string>> {
502
533
  const out: Record<string, string> = { ...env };
503
534
  for (const [name, value] of Object.entries(env)) {
@@ -518,8 +549,11 @@ export async function materializeSecrets(
518
549
  const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
519
550
  out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
520
551
  } catch (err) {
521
- throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
552
+ const message = err instanceof Error ? err.message : String(err);
553
+ const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
554
+ throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
522
555
  }
556
+ if (ctx) await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
523
557
  } else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
524
558
  let config: ServiceAccountConfig;
525
559
  try {
@@ -570,7 +604,7 @@ export function setServiceAccountSecret(
570
604
  for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
571
605
  if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
572
606
  }
573
- if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
607
+ if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
574
608
  throw new Error("private_key must be a PEM key (from the service account JSON)");
575
609
  }
576
610
  setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
@@ -692,7 +726,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
692
726
  const hasKey = Boolean(config.private_key?.trim());
693
727
  const hasPassword = Boolean(config.password);
694
728
  if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
695
- if (hasKey && !/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key!)) {
729
+ if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key!)) {
696
730
  throw new Error("private_key must be a PEM/OpenSSH private key");
697
731
  }
698
732
  if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
@@ -737,7 +771,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
737
771
  if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
738
772
  setSecret(
739
773
  tenant, name,
740
- API_PREFIX + JSON.stringify({ ...(base ? { base_url: base.replace(/\/+$/, "") } : {}), headers: config.headers }),
774
+ API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
741
775
  workspace, "api",
742
776
  );
743
777
  }
package/src/starter.ts CHANGED
@@ -319,6 +319,7 @@ timezone: Australia/Sydney
319
319
  model: default # a default for every step here
320
320
  effort: low
321
321
  overlap: skip # skip | queue — what a second run does while one is live
322
+ priority: normal # high | normal | low — where its runs stand in the queue
322
323
  ---
323
324
 
324
325
  1. [[researcher]] — Find three sources on {{topic}}.
@@ -354,7 +355,10 @@ Indented under a step, any of:
354
355
  | \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
355
356
  | \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
356
357
  | \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
358
+ | \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
357
359
  | \`each: items\` | run once per element of the previous step's JSON array |
360
+ | \`parallel: 3\` | how many \`each:\` instances run at once — default all |
361
+ | \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
358
362
 
359
363
  ## Writing an agent
360
364