Spec citation — SURFACE.md §2 rule 6 (lines 72–82)
Agent definitions escalate by composition — and a reusable agent is a flow:
- agent: Review this diff for security issues. # 1. anonymous
agents:
reviewer: { cli: claude, model: claude-sonnet-4-6 } # 2. named — explicit and reusable
The declarative named-agent schema in this slice is exactly { cli, model };
unknown fields fail closed. Defining a richer team reviewer means writing
reviewer.flow.ts (identity + memory + body); other flows compose it with
use: / f.agent(reviewer, task). Persona import is flow composition, not
a special mechanism.
What's shipped
The declarative slice — agents: { reviewer: { cli, model } } in YAML/JSON — is
merged (see SURFACE.md lines 196–201 and the #132-line comment in the doc).
The FlowHeader.agents TS type is in place for that declarative slice.
What this issue delivers
The composition rung of rule 6: a full flow used as an agent by another
flow. reviewer.flow.ts declares identity + memory + body, other flows
import it via a use: header entry, and call it with f.agent(reviewer, { task }).
The parent journal records a nested run using the imported flow's identity, and
the imported flow's memory scope is honored per docs/GATE5-MEMORY-CONTRACT.md.
Non-goal: cross-package/npm imports (only local relative paths), YAML use:
syntax (this slice ships the TS shape only; YAML follows in a later slice),
shared memory across parent+child beyond what GATE5-MEMORY-CONTRACT already
allows.
Scope
FlowHeader.use — surface accepts use?: string[], each an author-side
relative path to a .flow.ts sibling.
packages/surface/src/flow.ts: add use to the allowed keys list,
freezeHeader, assertFlowHeader, ReadonlyFlowHeader, and
assertKnownKeys (currently at
packages/surface/src/flow.ts:152 and 171).
- Duplicates and empty strings fail closed at
assertOptionalStringArray.
- Loader resolves
use: before executor runs.
packages/sdk/src/authored-flow-loader.ts (117 lines) is the natural
landing spot. After it imports the root .flow.ts, it reads header.use
and eagerly imports each entry, resolving paths relative to the root
flow's file URL. Each import is memoized by absolute path so a diamond
use: graph (A uses B, B uses C, D uses B and C) resolves once.
- Refusal codes (thrown as typed errors that the CLI turns into refusals):
use_not_found — the resolved absolute path does not exist.
use_invalid — import threw (syntax error, missing dep) or returned
something that isn't an authored flow handle.
use_cycle — the transitive use: graph contains a cycle. Refuse at
load time; do not run.
- Executor dispatches
f.agent(handle, options).
packages/sdk/src/authored-flow-executor.ts (333 lines) currently
resolves f.agent(name: string, options) against the CLI adapter table.
Extend the executor's agent handler so when the first argument is a
resolved authored-flow handle (from the surface's opaque handle map), it
runs that handle's body as a nested run:
- Journal a
run.spawned for the nested run with the child's identity
from getFlowDefinition(handle).header.identity (falling back to the
flow's name if identity is unset). The nested run inherits the
parent's data-dir and observer key.
f.agent(reviewer, { task }) passes the task string as the child
flow body's input. The child body sees input === task in its
second argument.
- The child's
f.done(reason) becomes the parent step's completion; a
child failure surfaces as the parent step's worker_error with the
child completion reason threaded through.
- Child memory scope: read
getFlowDefinition(handle).header.memory and
forward it as the memory-injection request for the nested run per
docs/GATE5-MEMORY-CONTRACT.md. No shared pool — the child's memory
tokens are charged to the child's steps, itemized, per RFC decision 10.
- Compile-time refusal
use_undeclared.
- If a
f.agent(reviewer, ...) in the body binds a reviewer handle
whose source module is not in header.use, flows check refuses with
use_undeclared before submission. This preserves the "declared
honestly" covenant: every imported flow the body composes must appear
in the header. Detection is at flows check: import the root flow,
walk header.use transitively, build the set of absolute-path handles,
and compare against the set of handles the body actually calls.
(Static detection is imperfect — a variable-passed handle can defeat
it. Refusal fires only for handles that the loader observes returned
from a use:-declared module and NOT in header.use; unknown handles
escape to runtime, where the executor refuses use_undeclared at the
first f.agent(handle, ...) call.)
Acceptance evidence
The PR must include a test-driven checklist demonstrating each of:
Files touched (expected)
packages/surface/src/flow.ts — extend FlowHeader with use?: string[],
update freezeHeader, assertFlowHeader, ReadonlyFlowHeader.
packages/sdk/src/authored-flow-loader.ts — resolve use: graph, memoize
by absolute path, refuse use_not_found / use_invalid / use_cycle.
packages/sdk/src/authored-flow-executor.ts — dispatch
f.agent(handle, opts) to a nested run; forward child identity and memory
scope.
packages/sdk/src/preflight.ts — walk header.use transitively during
flows check; static use_undeclared refusal for statically observed
handles.
packages/sdk/src/failure-kinds.ts — new refusal codes.
testdata/authored/{chief,reviewer,cycle-a,cycle-b,diamond-*}.flow.ts —
fixtures.
packages/sdk/tests/authored-flow.test.ts — extended vitest coverage.
packages/surface/src/*.test.ts — header shape coverage for use.
Non-goals
- Cross-package / npm imports. Only relative paths for this slice.
- YAML
use: — the canonical declarative dialect follows in a later slice.
- Kernel changes. This is a surface + SDK executor change; the nested-run
contract already exists in the journal.
- Sharing memory pools between parent and child (RFC decision 10 keeps
step-scoped memory).
Dependencies / adjacent
docs/GATE5-MEMORY-CONTRACT.md — child memory scope forwarding.
flows#132 — the declarative agents: compiler this slice sits next to
(already merged; do not re-touch its YAML compiler).
- The nested-run journal shape is already exercised by
f.dispatch; reuse
the same code path where possible.
Spec citation — SURFACE.md §2 rule 6 (lines 72–82)
What's shipped
The declarative slice —
agents: { reviewer: { cli, model } }in YAML/JSON — ismerged (see SURFACE.md lines 196–201 and the
#132-line comment in the doc).The
FlowHeader.agentsTS type is in place for that declarative slice.What this issue delivers
The composition rung of rule 6: a full flow used as an agent by another
flow.
reviewer.flow.tsdeclaresidentity + memory + body, other flowsimport it via a
use:header entry, and call it withf.agent(reviewer, { task }).The parent journal records a nested run using the imported flow's identity, and
the imported flow's memory scope is honored per
docs/GATE5-MEMORY-CONTRACT.md.Non-goal: cross-package/npm imports (only local relative paths), YAML
use:syntax (this slice ships the TS shape only; YAML follows in a later slice),
shared memory across parent+child beyond what GATE5-MEMORY-CONTRACT already
allows.
Scope
FlowHeader.use— surface acceptsuse?: string[], each an author-siderelative path to a
.flow.tssibling.packages/surface/src/flow.ts: adduseto the allowed keys list,freezeHeader,assertFlowHeader,ReadonlyFlowHeader, andassertKnownKeys(currently atpackages/surface/src/flow.ts:152and171).assertOptionalStringArray.use:before executor runs.packages/sdk/src/authored-flow-loader.ts(117 lines) is the naturallanding spot. After it imports the root
.flow.ts, it readsheader.useand eagerly imports each entry, resolving paths relative to the root
flow's file URL. Each import is memoized by absolute path so a diamond
use:graph (A uses B, B uses C, D uses B and C) resolves once.use_not_found— the resolved absolute path does not exist.use_invalid— import threw (syntax error, missing dep) or returnedsomething that isn't an authored flow handle.
use_cycle— the transitiveuse:graph contains a cycle. Refuse atload time; do not run.
f.agent(handle, options).packages/sdk/src/authored-flow-executor.ts(333 lines) currentlyresolves
f.agent(name: string, options)against the CLI adapter table.Extend the executor's
agenthandler so when the first argument is aresolved authored-flow handle (from the surface's opaque handle map), it
runs that handle's body as a nested run:
run.spawnedfor the nested run with the child'sidentityfrom
getFlowDefinition(handle).header.identity(falling back to theflow's
nameif identity is unset). The nested run inherits theparent's data-dir and observer key.
f.agent(reviewer, { task })passes thetaskstring as the childflow body's
input. The child body seesinput === taskin itssecond argument.
f.done(reason)becomes the parent step's completion; achild failure surfaces as the parent step's
worker_errorwith thechild completion reason threaded through.
getFlowDefinition(handle).header.memoryandforward it as the memory-injection request for the nested run per
docs/GATE5-MEMORY-CONTRACT.md. No shared pool — the child's memorytokens are charged to the child's steps, itemized, per RFC decision 10.
use_undeclared.f.agent(reviewer, ...)in the body binds areviewerhandlewhose source module is not in
header.use,flows checkrefuses withuse_undeclaredbefore submission. This preserves the "declaredhonestly" covenant: every imported flow the body composes must appear
in the header. Detection is at
flows check: import the root flow,walk
header.usetransitively, build the set of absolute-path handles,and compare against the set of handles the body actually calls.
(Static detection is imperfect — a variable-passed handle can defeat
it. Refusal fires only for handles that the loader observes returned
from a
use:-declared module and NOT inheader.use; unknown handlesescape to runtime, where the executor refuses
use_undeclaredat thefirst
f.agent(handle, ...)call.)Acceptance evidence
The PR must include a test-driven checklist demonstrating each of:
testdata/authored/chief.flow.tsimportsreviewer.flow.tsviause: ['./reviewer.flow.ts'].flows check chief.flow.tsprints the resolved use graph and returnsok: true.flows run chief.flow.ts --input '{...}' --local-agentjournals a nested run whose
run.spawnedheader showsidentity= reviewer'sidentity. The parent step'sstep.completedreferences the child run id.reviewer.flow.tsheader setsmemory: { script: true };the nested run journals a
memory.injectedentry perkernel/MEMORY.md, with the consuming step id set to a child step id(not the parent's).
./reviewer.flow.tsfrom the fixture makesflows checkrefuse withuse_not_found.reviewer.flow.tswhose module throws at import timemakes
flows checkrefuse withuse_invalid.f.agent(otherHandle, ...)where
otherHandlewas imported outsideheader.userefuses withuse_undeclaredat check-time when statically visible, or atruntime otherwise.
loads once each per absolute path. Cycle fixture (X uses Y, Y uses X)
refuses
use_cycleat load.Files touched (expected)
packages/surface/src/flow.ts— extendFlowHeaderwithuse?: string[],update
freezeHeader,assertFlowHeader,ReadonlyFlowHeader.packages/sdk/src/authored-flow-loader.ts— resolveuse:graph, memoizeby absolute path, refuse
use_not_found/use_invalid/use_cycle.packages/sdk/src/authored-flow-executor.ts— dispatchf.agent(handle, opts)to a nested run; forward child identity and memoryscope.
packages/sdk/src/preflight.ts— walkheader.usetransitively duringflows check; staticuse_undeclaredrefusal for statically observedhandles.
packages/sdk/src/failure-kinds.ts— new refusal codes.testdata/authored/{chief,reviewer,cycle-a,cycle-b,diamond-*}.flow.ts—fixtures.
packages/sdk/tests/authored-flow.test.ts— extended vitest coverage.packages/surface/src/*.test.ts— header shape coverage foruse.Non-goals
use:— the canonical declarative dialect follows in a later slice.contract already exists in the journal.
step-scoped memory).
Dependencies / adjacent
docs/GATE5-MEMORY-CONTRACT.md— child memory scope forwarding.flows#132— the declarativeagents:compiler this slice sits next to(already merged; do not re-touch its YAML compiler).
f.dispatch; reusethe same code path where possible.