| name | manage-flows |
| description | Create and edit pi-flows flows and agents from the main session. Use when the user wants to create a new flow, add or change an agent, or edit an existing flow/agent. Covers agent frontmatter, flow YAML, step types (agent, fork, agent-decision, code, code-decision), model references, the flow_agents/flow_write tools, code-handler generation, write locations, editing an existing flow vs creating one, and fixing validation errors. |
Manage Flows
You are creating and editing pi-flows flows and agents directly in this session. Tools that do the writing (each validates before writing and returns diagnostics on failure):
flow_agents — op: "list" returns the agent catalog; op: "write" validates and writes an agent .md to .pi/flows/agents/<name>.md (filename derived from the agent's frontmatter name).
flow_write — namespace (default custom), name, content. Validates and writes a flow to .pi/flows/flows/<namespace>/<name>/flow.yaml (the flow's own directory), which auto-registers as the /<namespace>:<name> command.
skill_read — read a skill's detail files when you need framework reference while authoring.
These tools derive their write locations from the discovery convention — there is no raw path. Writing to a name that already exists overwrites it (that is how you edit).
The manage-flows tools are off by default. They are active only when flows.editFlow: true is set in .pi/settings.json (project, when trusted) or ~/.pi/agent/settings.json (global), toggled live with /flows:edit-mode <on|off>. If flow_agents/flow_write are not available, tell the user to enable edit mode and (if needed) restart the session.
Goal
Produce a valid, robust, minimal flow (plus any agents it needs) that the engine can run unattended: every step has an explicit type, every input is wired, every fallible step is handled, produced work is verified in a loop, and the outcome is reported. Author iteratively against the validator — the tools validate before writing and never write partial output, so never assume the first write succeeded.
DO
- DO declare an explicit
type: on every step — the parser does not infer it and rejects a step that omits it.
- DO wire
on_error on any step that can recoverably fail — an unhandled soft failure escalates to a hard halt that kills the whole flow.
- DO verify produced work with a decision node that loops back to a fixer, bounded by
max_iterations (see Verify/fix loop).
- DO prefer deterministic
code/code-decision for mechanical checks (parse, validate, run tests/lint); reserve agents for judgment or generation.
- DO give each agent least-privilege
tools and the narrowest access globs; one agent, one role.
- DO wire and reference every declared input (
${{input.NAME}}); keep the agent body and the step inputs: in lockstep.
- DO edit in place when a flow/agent is named — read it first, then write back under the same namespace + name.
DON'T
- DON'T leave a fallible step without
on_error unless halting the flow is genuinely intended.
- DON'T trust a single pass — always add a verify step.
- DON'T repurpose infrastructure agents (
flow-decision, project-context-reader) for unrelated work.
- DON'T create a new flow/command when editing an existing one, and don't rename it unless asked.
- DON'T assume a write succeeded — read each diagnostic's
message/suggestion, fix, and re-write.
How it works (execution model)
Understand the runtime before authoring — the YAML you write is a DAG of steps, not a script.
- Segments & parallelism. The engine splits steps into segments. A contiguous run of plain
agent steps executes as a parallel DAG: on each wave it dispatches every step whose blockedBy is satisfied, up to max_concurrent (default 4). Every non-agent node (fork, agent-decision, code, code-decision) runs as a sequential step between those DAG segments. So independent agents run concurrently; decisions and code nodes are serialization points.
- Agent isolation. Each agent runs in its own session with only its declared
tools + finish, its access globs, and its own resolved model. It cannot see other agents' state. It must call finish(...) to return a structured result; the engine retries a couple of times if it forgets, then records a soft failure.
- Forward-only data flow. There is no shared mutable state. A step reads upstream results through wired
inputs: and ${{result.STEP.field}} templates. A producer must run before a consumer (via blockedBy or routing) — the validator enforces this ordering.
- Routing on outcome. Every node resolves to
success → fall through to the next step in file order, soft → on_error, or hard → halt the whole flow. Decision nodes additionally pick a branch; a branch target pointing at an earlier step is a loop (bounded by max_iterations). Order steps with blockedBy; select forward paths with a fork/code-decision.
- Determinism boundary.
agent nodes are non-deterministic (an LLM decides); code/code-decision nodes are deterministic TypeScript. Put judgment in agents, mechanics in code.
Editing an existing flow vs creating a new one
If a specific flow is named with this invocation — a command like /custom:my-flow, a flow file path, or a flow name — you are EDITING that flow. Locate it, read it, change it, and write it back with flow_write under the same namespace and name. Do not create a new flow or a new command, and do not rename it unless the user asks.
Only create a new flow when no existing flow is named. The same rule applies to agents: an existing agent name = edit that agent in place via flow_agents op: "write".
Workflow
- Determine the target. If a flow/agent is named (see above), it is an edit — read the current file first. Otherwise clarify what the new flow should do; ask if unclear.
- Call
flow_agents with op: "list" to see existing agents and their inputs, outputs, source_type. Reuse built-in/local agents where they fit.
- For each role not already covered, author a purpose-built agent with
flow_agents op: "write". Do not repurpose infrastructure agents (flow-decision, project-context-reader) for unrelated tasks.
- Author the flow with
flow_write. Wire every declared input. Fix any validation diagnostics and retry.
- For any
code/code-decision node, implement its handler (see Code handlers).
- Tell the user the resulting command name (
/<namespace>:<name>).
Principles
Apply these when authoring — they are the difference between a flow that limps and one that is robust.
- Always wire
on_error on fallible nodes. A soft failure with no on_error escalates to a hard halt that kills the entire flow. If a step can recoverably fail, route on_error: to a handler, retry, or fallback step. Only let a node hard-halt when continuing is genuinely pointless.
- Validate work with loops — never trust a single pass. After any step that produces code, edits, or artifacts, add a verify step followed by a decision node that loops back to a fixer until it passes or hits
max_iterations. This is the core pattern (see Verify/fix loop below). Prefer a deterministic code-decision verifier (runs tests/lint, returns a branch) over an agent verifier when the check is mechanical.
- Prefer
code/code-decision for anything mechanical. Schema/JSON validation, presence checks, parsing, running a linter or test command, computing a verdict — these are cheaper, faster, and reproducible as TypeScript handlers. Reserve agents for tasks needing judgment or generation.
- Make contracts explicit and machine-readable. Declare
outputs with type/pattern (e.g. a verdict constrained to ^(pass|fail)$) so a downstream decision node can branch on a guaranteed shape instead of parsing free text.
- Least privilege. Give each agent only the
tools it needs and the narrowest access.read/access.write globs; add bash.deny for destructive commands. The guard blocks everything you do not grant.
- One agent, one role. Author a purpose-built agent per role; do not overload a single agent or repurpose infrastructure agents (
flow-decision, project-context-reader) for unrelated work.
- Wire and reference every input. An unwired declared input is a validation error; a wired input never referenced as
${{input.NAME}} is a warning (the value is silently lost). Keep the agent body and the step inputs: in lockstep.
- Parallelize independent work. Omit
blockedBy between steps that have no real data/order dependency so they run concurrently (up to max_concurrent). Only chain steps when one genuinely consumes another's output.
- Write cooperative code handlers. Respect
ctx.signal (abort/timeout), set a timeout: on long handlers, call ctx.setSummary()/ctx.logger() for card visibility, and return exactly the declared outputs (any JSON type — stored typed). Reserve throw new FlowHardError(msg) for truly unrecoverable conditions; a plain throw is a recoverable soft failure.
- Author iteratively against the validator. The tools validate before writing and return diagnostics on failure — they never write partial output. Write → read each diagnostic's
message/suggestion → fix → re-write. Never assume the first write succeeded.
Verify/fix loop (the canonical robustness pattern)
steps:
- id: implement
agent: implementer
task: Implement ${{task}}
on_error: report
outputs:
- name: changed_paths
type: array
- id: verify
type: code
inputs:
touched: ${{result.implement.changed_paths}}
outputs:
- name: verdict
- name: report
blockedBy: [implement]
- id: gate
type: code-decision
inputs:
verdict: ${{result.verify.verdict}}
branches:
fix: implement
done: report
max_iterations: 3
- id: report
agent: reporter
task: Summarize outcome. Verifier said: ${{result.verify.report}}
The loop body references ${{loop.gate.iteration}} / ${{loop.gate.max}} to tell the agent how many tries remain.
Agent files (.md)
YAML frontmatter + Markdown body (the system prompt).
---
name: code-reviewer
description: Reviews source code for quality and correctness
model: @coding
thinking: medium
tools: read, grep, find
inputs:
- research_context
outputs:
- name: findings
description: Categorized issues found
- name: verdict
description: "pass" or "fail"
type: string
pattern: "^(pass|fail)$"
access:
read:
- "src/**"
write:
- "src/**"
bash:
deny:
- "rm -rf *"
fork_session: false
context_files:
- "AGENTS.md"
card:
label: "Reviewer"
metric: "default"
architect:
use_when: "User wants code reviewed for quality"
produces: "A findings report and a pass/fail verdict"
depends_on: "An implementer must have produced code"
domain: "review"
---
# System Prompt
You are a code reviewer. Task: ${{task}}
Context: ${{input.research_context}}
| Field | Required | Notes |
|---|
name | Yes | Unique. Filename = <name>.md. Referenced as agent: <name> in steps. |
description | Yes | One line. Shown in catalog. |
model | Yes | See Model references. |
tools | Yes | Comma-separated. Guard blocks anything not listed. Standard: read, write, edit, grep, find, ls, bash, ask_user, skill_read. |
thinking | No | off/minimal/low/medium/high/xhigh. Overrides any :level suffix in model. |
skills | No | Comma-separated skill names injected into the prompt. |
inputs | No | Names → ${{input.NAME}} in the prompt. Flow step must wire each one. |
outputs | No | Names (or {name, description, type?, pattern?}). Become finish parameters and ${{result.STEP.NAME}} downstream. type: string|number|boolean and pattern: <regex> validate the emitted value, which is then stored as the declared type (e.g. 92, not "92"); pattern wins over type. |
fork_session | No | true forks the operator's main-session conversation into the agent (falls back to a fresh in-memory session when the main session is not persisted). Default false. |
context_files | No | Paths read at spawn and injected as ## Context: <path> preamble sections. Missing/unreadable files are skipped. AGENTS.md is just one possible path. |
interactive | No | true allows mid-task UI prompts. Default false. |
output | No | Output file hint (display only). |
access | No | read/write glob allowlists; bash.deny command patterns. * = segment, ** = any depth. |
card | No | label, metric (default/files/tests/custom), role. |
architect | No | use_when/produces/depends_on/domain metadata surfaced in flow_agents op: list. |
Model references
The model: field accepts three forms. Prefer @role. Use the other two when a specific model is required regardless of role config, or when the user explicitly asks for a non-role model.
| Form | Example | When |
|---|
@role (preferred) | model: @coding | Default. Resolves via the active role→model map (/roles). Built-in roles: @planning, @coding, @fast, @architect. |
provider/model[:thinking] | model: anthropic/claude-haiku-4-5:high | A specific provider+model is required; optional :thinking suffix sets the thinking level. |
bare model-id | model: claude-haiku-4-5 | A specific model id with no provider qualifier; thinking comes from the thinking: field or none. |
A thinking: field always overrides any :thinking suffix in model.
Flow files (.yaml)
name: my-flow
description: What it does
max_concurrent: 3
task_required: true
task_prompt: "Task:"
steps:
- id: research
agent: code-reviewer
task: Review ${{task}}
name is the frontmatter name; the command name comes from the on-disk location (namespace/name you pass to flow_write). Every step needs a unique id and an explicit type: — the parser does not infer type and rejects any step that omits it. Steps run as a DAG: order is driven by blockedBy plus decision/on_error routing; on success a step falls through to the next step in file order.
Step types
There are five: agent · fork · agent-decision · code · code-decision. Presence checks and loops are expressed with the decision nodes below.
- agent — dispatch an agent.
- id: impl
agent: implementer
task: Implement ${{task}}
blockedBy: [research]
inputs:
ctx: ${{result.research.summary}}
on_error: handler
- fork — user (or
agent: in autonomous mode) picks a branch.
- id: choose
type: fork
question: Which strategy?
options: [Fast, Full]
branches: { Fast: fast-impl, Full: full-impl }
agent: flow-decision
allowCustom: false
multiSelect: false
Branch keys must match options exactly.
- agent-decision — agent calls
finish({ branch }) to choose. Declare ≥2 branches (label → stepId).
- id: should-fix
type: agent-decision
agent: flow-decision
task: "Iter ${{loop.should-fix.iteration}}/${{loop.should-fix.max}}. ${{result.verify.summary}}"
branches:
fix: fixer
done: finalize
max_iterations: 3
A branch whose target is an earlier step re-enters that step (a loop) and MUST declare max_iterations; the engine forces exit at the cap.
- code — run a deterministic TypeScript handler as a node (wired exactly like an agent).
- id: validate
type: code
inputs:
invoice: ${{result.extract.canonical}}
outputs:
- name: valid
- name: nav_record
timeout: 30000
blockedBy: [extract]
on_error: handler
See Code handlers.
- code-decision — a
code node that also routes on a reserved branch output. Declare ≥2 branches.
- id: route
type: code-decision
inputs:
valid: ${{result.validate.valid}}
branches:
approve: finalize
reject: notify
outputs: []
The handler returns { branch: "<label>", ...declaredOutputs }. A missing branch is a soft failure; an off-map branch is a hard failure (halts the flow). Use this for presence/absence checks (inspect a field, return a branch) and for backward-edge loops (same max_iterations rule as agent-decision).
Failure model (all nodes)
Every node resolves to success, soft, or hard. success falls through to the next step in file order; soft routes on_error; hard aborts in-flight steps, skips pending ones, and ends the flow with status error. For agents this is classified structurally: finish(complete) → success, finish(error|blocked) → soft, no-finish with a terminal API error → hard. For code: a plain throw (or contract/coercion/missing-handler/timeout failure) is soft; throw new FlowHardError(msg) is hard.
Template variables
Expanded in task, inputs values, and question. Not validated — a typo silently becomes empty string.
${{task}} — the user task.
${{input.NAME}} — input wired into this step.
${{result.STEP_ID.status|summary|fullOutput|OUTPUTNAME}} — STEP_ID is the step id, not the agent name. Standard fields: status, summary, fullOutput; everything else is a declared output.
${{flow.input.NAME}} — a typed flow-level input (see Typed data below).
${{loop.STEP_ID.iteration|max}} — loop counters (1-based iteration; max = the node's max_iterations).
Wire data between steps via inputs: (producer declares outputs; the consuming step supplies values). A producer must run before a consumer (via blockedBy or routing).
Typed data
- Outputs are stored as real JSON types (string/number/boolean/object/array/null) — not stringified.
- Whole-value reference → typed delivery. When a code-node input value is exactly
${{result.X.NAME}} or ${{flow.input.NAME}} (no surrounding text), the handler receives that value unchanged (object/array/etc.). Embedded in other text, it is interpolated as compact JSON (JIT serialization); the same JIT rule applies inside agent prompts/tasks. Strings pass through verbatim.
- Typed flow inputs. A flow may declare
inputs: in its frontmatter — a map of NAME: { type: string|number|boolean|object|array, required?: true }. A run started with a structured inputs object is validated against it (missing required / wrong type fails the run); values are referenceable as ${{flow.input.NAME}}.
- File-backed data is passed as a path, not injected. There is no
file:// injection. Pass a path (typically a *_path output) and have the consumer read it: an agent via its read tool (give it read + an access.read glob — a *_path input wired into an agent without read is a validation warning), a code node via the filesystem.
Code handlers
A code/code-decision node runs the default export of a .ts module, invoked (input, ctx):
input: Record<string, unknown> — the step's declared inputs. A whole-value reference is delivered as its real type; an embedded reference is a JIT-serialized string (see Typed data).
- return — an object containing exactly the declared
outputs, each holding any JSON-compatible value (string/number/boolean/object/array/null — stored typed, not stringified). A missing or undeclared key is a soft failure. A code-decision also returns branch: "<label>".
ctx: CodeNodeContext — signal (cooperative abort; respect it, and a timeout: aborts it), cwd, logger(msg) (program-log output, surfaced on the node's card), setSummary(text), flowName, stepId, task.
- failure: a plain
throw (or a contract/coercion/timeout/missing-handler failure) is soft (routes on_error); throw new FlowHardError(msg) is hard (halts the flow). Import FlowHardError from @blackbelt-technology/pi-flows.
Where the template generates
Each flow is a self-contained directory — .pi/flows/flows/<namespace>/<name>/flow.yaml — and its code handlers live in that same directory. For every convention-based code/code-decision node (no explicit target:), pi-flows writes an inert reference template next to flow.yaml:
.pi/flows/flows/<namespace>/<name>/<id>.ts.default
where <id> is the step id. It is rewritten on every flow_write (and via /flows:generate <flow>) to stay in sync with the node's inputs/outputs/branches. The real <id>.ts is never overwritten. A node with an explicit target: gets no template — the author owns that file. Handlers resolve relative to the flow's own directory (dirname(flow.source)), so the generated and runtime paths are always identical.
How to write the handler
Copy the template, drop .default, implement the body (in the flow's own directory):
cd .pi/flows/flows/<namespace>/<name>
cp <id>.ts.default <id>.ts
The generated scaffold for a code node (inputs {invoice}, outputs [valid, nav_record]):
import type { CodeNodeContext } from "@blackbelt-technology/pi-flows";
interface Input { invoice: string }
interface Output { valid: string; nav_record: string }
export default async function (input: Input, ctx: CodeNodeContext): Promise<Output> {
return { valid: "", nav_record: "" };
}
For a code-decision node (inputs {valid}, branches {approve, reject}) the scaffold adds a typed Branch union so a wrong label is a compile error:
import type { CodeNodeContext } from "@blackbelt-technology/pi-flows";
type Branch = "approve" | "reject";
interface Input { valid: string }
interface Output {}
export default async function (input: Input, ctx: CodeNodeContext): Promise<{ branch: Branch } & Output> {
return { branch: "approve" };
}
Keep the interface Input/Output blocks in sync with the YAML — if they drift from the declared inputs/outputs, flow_write//flows:generate emit a non-fatal drift warning (runtime shape validation is the backstop). Handlers run in-process via dynamic import (jiti handles .ts).
Write locations (discovery)
| Content | Tool | Lands at |
|---|
| Agent | flow_agents op: write | .pi/flows/agents/<name>.md |
| Flow | flow_write | .pi/flows/flows/<namespace>/<name>/flow.yaml → /<namespace>:<name> |
| Code handler | (you / /flows:generate) | .pi/flows/flows/<namespace>/<name>/<id>.ts (same dir as flow.yaml) |
Each flow is a self-contained directory: flow.yaml plus its co-located handlers. Deleting a flow removes the whole directory. Project-local definitions (.pi/flows/) override package and built-in ones.
Fixing validation errors
Both writing tools validate before writing. On failure they return { written: false, diagnostics: [...] } and write nothing. Read each diagnostic's message and suggestion, fix the content, and call the tool again. Common cases:
- Missing required field (
name, description, model, tools for agents; name, description for flows) → add it.
- "Agent not in catalog" → the flow references an agent that does not exist. Create it with
flow_agents op: write, then retry flow_write.
- Unwired declared input → add the missing key to the step's
inputs: block.
- Bad reference / routing target → every
blockedBy, branches target, and on_error must point at an existing step id; fix the typo.
- Decision node with <2 branches, or a backward branch with no
max_iterations → add the missing branch / max_iterations.
- Reserved output
branch declared as a data output on a code-decision → remove it from outputs (it is the routing key).
- Unknown tool in
tools: → use a valid tool name (see the standard list above) or an extension-registered tool name.
- Invalid YAML → fix indentation/quoting; re-validate.