A script child is JavaScript the agent wrote, running in a deterministic QuickJS realm whose every effect is journaled. It is the orchestration engine: scripts orchestrate, microVM code computes. A script is spawned by passing script to delegate; it becomes a run of kind: 'workflow' with no session of its own.
Implementation: agents/src/workflow-host.ts.
Determinism is enforced, not requested
The realm removes the two things that would break replay (workflow-host.ts:332-333):
Math.random = function () { throw new Error('Math.random is not available in workflows; take randomness as input') }
Date = function () { throw new Error('Date is not available in workflows; use await ctx.now()') }There is no fetch, no setTimeout, no require/import, no process, no globalThis. lintWorkflowSource (:129-158) rejects those patterns at submission time, so the failure is a clear message at authoring rather than a mystery at runtime. TextEncoder/TextDecoder are provided (:268-331) as hand-rolled UTF-8 implementations — pure functions, safe to replay — because a real agent workflow once died on a byte-size check without them.
Resource bounds: source ≤ 256 KiB (WORKFLOW_SOURCE_MAX_BYTES), memory 64 MiB, stack 1 MiB, and a 2-second fuel slice (DEFAULT_FUEL_MS) between awaits — exceed it and the run fails fuel-exhausted. Stack frames read as workflow.js:LINE because each eval unit is given that filename (:451), which is what makes the error inspector able to excerpt the offending source line.
The ctx API
Every method is defined in the prelude (workflow-host.ts:205-267); each one that matters is journaled.
Method | What it does |
|---|---|
| This run's input and id |
| Invoke a callable; |
| Spawn a child; unwraps its output, throws |
| Concurrent fan-out |
| A named unit of work; journals start and end phases |
| Publish the checklist |
| |
| Park until an event or signal arrives |
| Hand off to a successor; never resolves |
| The clock, as a journaled effect |
| A journaled log line |
| Fire-and-forget UI progress — deliberately not journaled |
The journal and content-keyed replay
Every effect writes to run_journal ((run_id, seq) primary key, CBOR entries). Entry kinds: call, result, timer, fired, wait, event, now, log, step, plan.
Resuming a script means running the source again from the top. What makes that safe is that each effect computes a deterministic content key — effectKey(op, args) (:411-434), e.g. tool|${tool}|${JSON.stringify(input)} or sleep|${ms} — and journal entries are filed under that key rather than under arrival order. On replay, a matching group is consumed FIFO per key and its recorded result returned without re-executing.
Keying by content rather than sequence is not incidental: ctx.parallel continuations can complete in a different order live than on replay, and an order-indexed journal would mismatch. There is a test for exactly this (workflow-host.test.ts:353-390).
Narration rides outside the key. The {description} on ctx.call is attached to the journal entry but excluded from effectKey (:568-587), so relabeling a call for readability never invalidates a replay. That is what the run card's Activity drawer renders.
Bounds: 5,000 entries or 8 MiB per run (WORKFLOW_JOURNAL_MAX_ENTRIES, WORKFLOW_JOURNAL_MAX_BYTES), after which the run fails journal-cap — the signal to use continueAsNew.
One honest caveat
A ctx.call interrupted mid-flight — the process dies after the tool ran but before its result was journaled — re-executes on resume. Effects are at-least-once, not exactly-once, and agents/docs/security.md records this as a known gap rather than a solved problem. Signals and event deliveries, by contrast, are exactly-once, because they commit in a single transaction (SignalRun).
Related
Delegation — how a script child is spawned
Runs — the row a script executes as
Time and Parking — what a script does while it waits
Execution — the other engine
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime