NOETIC
Code Agent CLI

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:

  1. Plan-mode workflow documents — the LLM emits a JSON WorkflowDocument describing the plan during plan mode; the runtime hydrates each node into a concrete Step. Good for "I want the agent to design the flow, then execute it."
  2. 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

  • tools entries on callModel nodes and toolName on invokeTool nodes are matched against the harness's live tool registry. Unknown names error at hydration.
  • Teammate presets are spawned through the agent tool (an invokeTool node with subagent_type args): built-in skills with agent-type, plugin-contributed subagentPresets, and skills discovered from the project / user / plugin sources.

Authoring presets for plan-mode

Two ways to register a preset:

  1. As a skill — set agent-type: <name> in frontmatter. The skill body is the system prompt; allowed-tools is the tool pool.
  2. As a plugin contribution — implement subagentPresets on 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:

  1. Plugin sub-agent preset — register a preset via subagentPresets on a plugin. The plan-mode JSON flow can spawn it.
  2. Plugin command — register a slash command via commands that calls harness.run(myFlow, ...) (or harness.detachedSpawn for 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

  • StepsrunCode, callModel, invokeTool.
  • Operatorsconditional, inParallel, spawn, loop, schedule.
  • Channelsctx.send / ctx.tryRecv / inbox.
  • Loop & Until — compose ReAct-style agents from loop + callModel + until.*.
  • Runtimeharness.run, harness.detachedSpawn, abort, span creation.

On this page