Custom Flows
Two ways to build long-running, multi-path agent workflows — plan-mode JSON flows and programmatic Step compositions.
A flow is a state machine of agent work composed from the framework's primitives — schedule, inParallel, spawn, conditional, runCode, callModel, invokeTool. Flows are how the CLI's daemon does background work (autopilot, validator, health, reconcile), and they're how you can extend the CLI with your own background or planned workflows.
There are two ways to author a flow:
- Plan-mode workflow documents — the LLM emits a JSON
WorkflowDocumentdescribing the plan during plan mode; the runtime hydrates each node into a concreteStep. Good for "I want the agent to design the flow, then execute it." - Programmatic Step compositions — TypeScript code in a plugin or in your config. Good for long-running infrastructure (daemons, reactive workers, periodic jobs) you control directly.
If you're new to the primitives, read Operators first.
Plan-mode workflow documents
When the agent is in plan mode (/plan or /mode plan), it authors the plan as a WorkflowDocument — the same format the JSON Workflow Runtime executes, validated against the published schema at https://noetic.tools/schema/noetic-workflow.schema.json. The plan layer stores the reviewed tree via plan/setPlanTree plus any named workflows via plan/setWorkflow, and the tree references them with subflow nodes so the part a human approves stays small.
All the runtime's node kinds are available — callModel, invokeTool, sequence, inParallel, conditional, loop, spawn, withContext, schedule, subflow, and the coding-agent kinds (claude-code, codex, opencode, pi). See the node-kind reference for each shape. Hosts can narrow the allowed set with PlanConfig.allowedNodeKinds.
Example
A plan that researches in parallel, summarises, then runs a separately-defined verification workflow:
{
"version": 1,
"root": {
"kind": "sequence",
"id": "audit",
"steps": [
{
"kind": "inParallel",
"id": "audit.gather",
"mode": "all",
"paths": [
{ "kind": "invokeTool", "id": "audit.deps", "toolName": "agent", "args": { "subagent_type": "explore", "prompt": "List runtime dependencies in package.json with their versions." } },
{ "kind": "invokeTool", "id": "audit.tests", "toolName": "agent", "args": { "subagent_type": "explore", "prompt": "Find all test files and report coverage gaps." } }
]
},
{
"kind": "callModel",
"id": "audit.summarise",
"instructions": "You received exploration reports. Write a one-page audit summary with risk callouts."
},
{ "kind": "subflow", "id": "audit.verify", "ref": "verify" }
]
}
}with verify defined via plan/setWorkflow as its own document. Every subflow ref must name a defined workflow before the plan can exit to execution.
Tool & preset resolution
toolsentries oncallModelnodes andtoolNameoninvokeToolnodes are matched against the harness's live tool registry. Unknown names error at hydration.- Teammate presets are spawned through the
agenttool (aninvokeToolnode withsubagent_typeargs): built-in skills withagent-type, plugin-contributedsubagentPresets, and skills discovered from the project / user / plugin sources.
Authoring presets for plan-mode
Two ways to register a preset:
- As a skill — set
agent-type: <name>in frontmatter. The skill body is the system prompt;allowed-toolsis the tool pool. - As a plugin contribution — implement
subagentPresetson a plugin.
Once registered, the LLM can reference the preset from agent tool calls in the plan.
Programmatic flows
For long-running infrastructure, write the flow in TypeScript. The CLI's task daemon does this — the schedule / inParallel / spawn / conditional / runCode builders compose into a tree that runs forever (or until the harness shuts down).
The shape
import type { ContextData, Step } from '@noetic-tools/core';
import { inParallel, runCode, schedule, scratchpad, spawn } from '@noetic-tools/core';
export function buildMyFlow(deps: MyFlowDeps): Step<ContextData, void, void> {
const tick = schedule<ContextData, void, void>({
id: 'my-flow.tick',
step: runCode<ContextData, void, void>({
id: 'my-flow.iter',
execute: async (_in, ctx) => {
// do work; ctx.send(channel, value), ctx.tryRecv(channel), etc.
},
}),
interval: 30_000,
onError: 'continue',
});
return spawn<ContextData, void, void>({
id: 'my-flow',
child: tick,
context: [scratchpad({ scope: 'thread', schema: MyStateSchema })],
});
}The harness drives the returned Step via harness.run(...) (sync) or harness.detachedSpawn(...) (background, no parent block). Context-layer lifecycle, abort, observability, and cost tracking come for free.
Channels and inbox
schedule runs on an interval but can also wake immediately on a channel send. The validator flow uses this:
schedule({
id: 'validator.schedule',
step: validatorIterationStep,
interval: 30_000,
inbox: validatorRequestChan, // send → tick now, don't wait 30s
onError: 'continue',
});inbox is essential for queue-driven workers — interactive sub-agent requests don't have to wait an interval before running.
Worked example: a slimmed-down validator
The CLI's real validator drains a channel of ValidatorRequests, runs tests for each requested feature, persists the run, and emits an outcome. Stripped to the bones:
import type { ContextData, Step } from '@noetic-tools/core';
import { runCode, schedule } from '@noetic-tools/core';
import { validatorRequestChan, validatorOutcomeChan } from './channels.js';
interface ValidatorDeps {
runTests: (featureId: string) => Promise<{ status: 'pass' | 'fail'; result: unknown }>;
}
function buildIteration(deps: ValidatorDeps): Step<ContextData, void, void> {
return runCode<ContextData, void, void>({
id: 'validator.iteration',
execute: async (_in, ctx) => {
while (true) {
const req = ctx.tryRecv(validatorRequestChan);
if (req === null) return; // queue drained
const outcome = await deps.runTests(req.featureId);
await ctx.send(validatorOutcomeChan, {
taskId: req.taskId,
featureId: req.featureId,
status: outcome.status,
result: outcome.result,
});
}
},
});
}
export function buildValidatorSchedule(deps: ValidatorDeps) {
return schedule<ContextData, void, void>({
id: 'validator.schedule',
step: buildIteration(deps),
interval: 30_000,
inbox: validatorRequestChan,
onError: 'continue',
});
}This is the same shape as the production flow at packages/cli/src/tasks/runtime/hierarchy/validator-flow.ts, just with the lifecycle persistence and per-task lookup omitted.
Composing several flows in a daemon
The CLI's daemon roots all four periodic flows in a inParallel({ mode: 'all' }) wrapped in a spawn:
import { schedule, inParallel, spawn, scratchpad } from '@noetic-tools/core';
const daemonInner = inParallel<ContextData, void, void>({
id: 'tasks.daemon-parallel',
mode: 'all',
paths: () => [
buildAutopilotSchedule(deps.autopilot),
buildValidatorSchedule(deps.validator),
buildHealthSchedule(deps.health),
buildReconcileSchedule(deps.reconcile),
buildEventsBridgeSchedule(deps.eventsBridge),
],
merge: () => undefined,
});
export const daemonFlow = spawn<ContextData, void, void>({
id: 'tasks.daemon',
child: daemonInner,
context: [
createSteeringFileLayer(),
scratchpad({ scope: 'thread', schema: DaemonStateSchema }),
],
});inParallel({ mode: 'all' }) runs all five schedules in parallel; the spawn isolates them in their own context scope (steering files, scratchpad state) so they don't pollute the user's interactive session.
Wiring a custom flow into the CLI
Today, programmatic flows live alongside the CLI's built-in ones — there is no flows: [] array in noetic.config.ts. Two ways to surface yours:
- Plugin sub-agent preset — register a preset via
subagentPresetson a plugin. The plan-mode JSON flow can spawn it. - Plugin command — register a slash command via
commandsthat callsharness.run(myFlow, ...)(orharness.detachedSpawnfor background).
If you need to author a daemon-style background flow, drive it from plugin initialize using the callModel and runtime APIs, and make sure to clean up in dispose.
Where to read more
- Steps —
runCode,callModel,invokeTool. - Operators —
conditional,inParallel,spawn,loop,schedule. - Channels —
ctx.send/ctx.tryRecv/inbox. - Loop & Until — compose ReAct-style agents from
loop+callModel+until.*. - Runtime —
harness.run,harness.detachedSpawn, abort, span creation.
Plugins
Extend the noetic CLI with custom tools, context layers, skills, slash commands, footer UI, sub-agent presets, reminder triggers, and language servers.
noetic tasks
One concept, one verb namespace, one slash command — the unified task system replaces both the legacy worktree-modal `tasks` and the legacy strategic `mission` flows.