Code Node Output Interface: emit and output
Status: proposed. Owner: code-nodes / node-sdk. 2026-08-12.
Problem
The Code node’s output contract is a return value: “return an object — its
keys become output handles” (normalizeCodeOutput in
packages/node-sdk/src/code-body.ts). That shape has three costs, and agents
pay all of them:
- Streaming is replay, not streaming. A body with
yieldruns throughgenProcess, but the guest collects every yielded value into an array and the host replays them after the run completes (packages/code-nodes/src/nodes/code-node.ts, theyield_rewrite). A loop that fetches 50 pages delivers nothing downstream until page 50. The rewrite itself is a regex (code.replace(/\byield\b/g, "yield_")), so the body the node runs is not the body the author wrote, and top-levelyieldis not valid JavaScript — real parsers, including our owncode-analysis.ts, must special-case it. - The contract is implicit. Nothing in the body names an output; the
shape of every return path does. The validator must prove each declared
output is set on each return path, and its own error message tells authors
to avoid branching in code and use
nodetool.control.Ifnodes instead — the check is fighting normal code shape. On top of the return-path analysis sit two more inference layers: implicit-return wrapping of the last expression (wrapImplicitReturn) and shape normalization of whatever came back (normalizeCodeOutput). - There is no call an agent can reason about. CodePlanner,
validate_codeand repair loops infer outputs from data shape. A function call —emit("x", value)— is a fact an AST query answers directly.
Guest contract
Outputs leave the body only through two host-bridged calls. The body’s return
value is ignored — return is what it is in JavaScript, control flow, and
carries no output semantics.
await emit(name, value) // stream one value to output handle `name`, now
await output(name, value) // set the final value of output handle `name`
emit(name, value)delivers{ [name]: value }downstream immediately, while the body keeps running. Call it any number of times per handle.output(name, value)records the handle’s final value. It is delivered when the body completes, all final values as one bag — so a downstream node that consumes one value per handle sees exactly one. A secondoutputcall for the same handle throws: a final value that changes is a bug, and a deterministic error beats a silent last-write-wins.- Both are
await-able; awaitingemitapplies backpressure (see Limits).namemust be a string naming a declared output handle; values go through the same marshaling as today (JSON-safe data, media refs frommedia.toImageetc.). returndoes nothing to outputs. Use it to exit early. A body may end without calling either function only when the node declares no outputs.
The symmetry is the point: one verb for “here is an item” and one for “here is the answer”, both explicit, both statically visible, nothing inferred from data shape.
Deleted with the return contract: hasReturnStatement routing,
wrapImplicitReturn (implicit return of the last expression), and
normalizeCodeOutput (shape normalization). A body that today reads
inputs.a + inputs.b becomes await output("output", inputs.a + inputs.b) —
one line longer, zero inference.
Semantics
| Situation | Behavior |
|---|---|
emit("out", v) |
{out: v} streams downstream in call order, live |
output("sum", v) then body ends |
final bag {sum: v} is the last message |
several output calls, distinct handles |
one final bag carrying all of them |
output twice on one handle |
throws in the guest |
return v with any value |
v is discarded; control flow only |
| body ends, declared output neither emitted nor output | validation error before run (see below); at runtime the handle stays empty |
emit/output to an undeclared name |
validation error before run; at runtime the call throws |
| non-serializable value | same marshal rules as today (JSON deep-copy; media via media.to*) |
| error thrown after emits | already-emitted values were delivered; recorded output values are not posted; the node fails |
| timeout mid-stream | same as error: delivered emits stand, finals dropped, node fails |
Host mechanics
emit and output join EXPOSED_BRIDGE_NAMES in
packages/agents/src/js-sandbox.ts as awaitable host calls (the
asyncified path, not the fire-and-forget progress path — values cannot be
rate-limited or dropped).
process() disappears from CodeNode; every body runs through one
genProcess pump:
start runInSandbox(...) → promise P
while P pending or queue non-empty:
yield next bag from queue (each emit pushed one)
await P (failure → throw, finals discarded)
yield the collected output() bag (unless empty)
The queue is a bounded async channel owned by the node invocation. The host
side of emit resolves the guest’s promise only after its bag is accepted by
the channel, so a fast producer awaiting emit blocks until the kernel has
consumed — backpressure with no new kernel concept. output never queues; the
host stores the value and posts the bag after P resolves. A body that calls
emit without await still works; the channel bound (below) caps
unacknowledged values.
Limits
- Channel capacity: 64 pending bags.
emitawaits drain beyond that. - Max emits per run: 10 000 (same spirit as
MAX_PROGRESS_CALLS; exceeding throws in the guest, naming the cap). - Per-value size: bounded by the existing sandbox marshal limits; no new cap.
Validation and analysis
The return-path analysis in code-node-validation.ts — “every declared output
set on every return path” — is deleted, not amended. Its replacement is one
reachability rule over two call names:
- Every declared output must have ≥1 reachable
emitoroutputcall with that literal name. Branching freely is fine; the rule is per-handle existence, not per-path coverage. - A call with a non-literal first argument downgrades that body to a warning (“cannot check output names statically”) instead of failing it.
- A call naming an undeclared handle is an error, symmetric with the existing
undeclared-
inputs.<name>read check. emitandoutputcount as known globals; a bare read of either stops being a ReferenceError candidate.- A top-level
return <value>(non-undefinedargument) is a warning naming the new contract, since under the old one it meant “these are the outputs”.
The analysis lives where it does today (code-analysis.ts /
code-node-validation.ts in node-sdk), shared by the graph validator, the
submit_code planner, and the editor.
Surface parity
run_code/test_code(packages/agents/src/capabilities/code.ts): emitted values land in the existingstreamedarray as{name, value}entries, in order; final values land inoutputs.test_codecases may assert on both.- CodePlanner / code-gen eval: prompts and cases rewritten to the
emit/output contract; the
code-gensuite gains one streaming case. - Editor: the Code node description and the assistant dialog prompt teach the two calls; no UI change — streamed values render through the same messages every streaming node already produces.
- Docs:
docs/javascript-sandbox.md§ Outputs and § Streaming rewritten.
Backwards compatibility and migration
Removing the return contract breaks every existing body, so the old path stays executable for one release behind a per-body probe:
- A body that calls
emitoroutputruns on the new contract; its return value is ignored. - A body that calls neither runs on the legacy path (return bag, implicit
return,
yieldreplay), andvalidate_codeemits a deprecation warning naming the two calls. Saving such a node in the editor surfaces the same warning. - The workflow migration is mechanical and shippable as a codemod on the
stored
codestring:return {a, b}→await output("a", a); await output("b", b); return;,yield x→await emit("output", x). The codemod runs throughvalidate_codeon its own result and refuses any body it cannot prove equivalent, leaving those for hand migration. - After the window,
hasReturnStatement,hasYieldStatement,wrapImplicitReturn,normalizeCodeOutput, theyield_rewrite, and the return-path validator all delete.
Testing
- Unit (
packages/code-nodes): emit ordering; emit+output interleave; finals post as one bag; doubleoutputthrows;returnvalue discarded; backpressure (producer faster than consumer); cap exceeded; error after emit drops finals; undeclared handle throws; legacy body still runs and warns. - Analysis (
packages/node-sdk): reachable-call rule satisfies declared outputs; non-literal name warns; undeclared name errors; top-level valuedreturnwarns. Each new check is proven failable with an inverted fixture before landing. - Codemod: round-trips the shipped example workflows’ Code bodies and the
code-gen eval fixtures; every migrated body passes
validate_codeandtest_codewith unchanged expectations. - End-to-end: a workflow where a Code node emits into a downstream collector
runs under
nodetool debugand shows per-item messages arriving before the node completes.
Out of scope
- CodeAct (
execute_code) — its scripts act on the toolbelt and return one result; this contract is for node code. - New kernel message types — emits and the final bag ride the existing
genProcesssemantics. - Per-output typing of emitted values beyond the handle’s declared type.