Agentiflows
An agentiflow is a goal-directed, budget-bounded fleet run. You state measurable goals, a budget, a pool of agents and workflows, and an authorized scope; a lead agent decides how to reach the goals — dispatching agents, running or generating workflows, and coordinating them in one shared message space — while deterministic code holds the budget and the stop button.
What is an agentiflow
A workflow is structure you wrote in advance. An autoflow picks up work items and runs a pre-written workflow on each. Neither can compose new structure in response to what it finds, coordinate a live fleet, or stop itself when a measurable goal is met. That's what an agentiflow is for: open-ended work like “review this service and verify every real injection flaw”, where the plan has to change as the evidence comes in.
| Who decides the steps | When it stops | |
|---|---|---|
| Workflow | You, in YAML, ahead of time | When the last step finishes |
| Autoflow | You, in YAML; the runtime picks which item to run it on | Per item, when its workflow finishes |
| Agentiflow | A lead agent, round by round, from a fixed pool | Goals met, coverage reached, budget or ceiling hit, or an operator stop |
The design is deliberately split. A deterministic envelope owns the loop: rounds, the budget, the stop conditions and the operator's control queue. Inside each round the lead runs as an ordinary rupu agent with a large turn budget, so the work is fully agentic — but code, never the model, decides when the money is spent and when the run is over. The envelope never takes the lead's word that it is done: every stop condition is computed from evidence on disk — findings, assets and coverage depth recorded through the coverage harness.
How a run works
rupu agentiflow run <name> runs from your current directory, which becomes the run's
workspace: the lead's file and shell tools act there, and units start there. Configuration resolves the way it does
for rupu run (global, then customer, then project layer); the lead agent is loaded, its provider built, and
the run id printed to stderr.
The run is a loop of rounds. At the top of every round the envelope drains the operator's control queue (where a
graceful stop waits), evaluates each stop condition against the evidence the fleet has written so far — findings, assets and coverage
in the run's pooled coverage scope, plus the metered spend — and either stops the run or hands the lead a digest: where
every goal stands, the coverage fraction, the budget stage, a recap of the operator's messages since the last round, and any non-fatal
warnings. Round 0 opens with the mission — each goal's objective, in order — and the authorized scope:
scope.mode and one line per scope.roots entry, its kind
followed by its coordinates (network:host host=10.0.0.5).
The lead then takes up to round.lead_max_turns agentic turns (default 50). It
reads the roster, launches units, joins them, posts to the board and checks goal status. When its turns end, the round ends, and the
envelope evaluates again. The lead's conversation carries over from round to round; a round that errors is recorded and the loop
continues, bounded by the budget and ceilings. Because the check comes before the lead's turn, a run whose goals are already met runs
zero rounds. “Done” is never the lead's say-so: a goal is met only when the evidence on disk satisfies its predicate, and an evaluation
that fails (an unreadable ledger, say) counts as not met and reaches the lead as a warning. The conditions are checked in the
order below; the first match wins.
| Stop reason | Fires when |
|---|---|
goals_met | At least one goal is required, and every required goal's predicate holds over the pooled evidence. |
coverage_reached | The coverage: fraction reaches its reach. |
budget_exhausted:<dimension> | A budget dimension — usd, tokens, rounds or wall_clock — hits its cap. The first to trip is named. |
operator_stop | A graceful stop was requested (rupu agentiflow stop, or the control plane). A hard stop (--now) records the run as failed with operator_stop:now. |
ceiling | The hard round.ceiling on rounds or wall-clock time is reached. |
Writing an agentiflow
An agentiflow is one YAML file. rupu agentiflow run <name> loads
<project>/.rupu/agentiflows/<name>.yaml, falling back to
~/.rupu/agentiflows/<name>.yaml. A project file shadows the global one even when it fails to parse:
a broken project definition is an error, never a silent fallback. Every block rejects unknown keys, and the whole file is validated
against its engagement profiles before anything starts — a goal that could never be met is a load error, not a run that burns its budget.
# .rupu/agentiflows/ledger-review.yaml name: ledger-review description: Coordinated pre-release review of the ledger service. lead: review-lead # must be in pool.agents engagement_profiles: [code] goals: - id: verified-sqli objective: "Find and independently verify two SQL injection flaws." target: findings: { classification: CWE-89 } count_gte: 2 verified: true verify_check: with_poc # confirmed AND an artifact on the report verify_with: finding-verifier # only this pool agent's verdict counts - id: core-file-reviewed objective: "Bring the ledger core module to reviewed depth." required: false # reported, but doesn't block goals_met target: asset: { kind: "code:file", locator: { path: src/ledger/core.rs } } depth_at_least: reviewed coverage: { reach: 0.9, depth: reviewed, kinds: [file] } budget: usd: 20 tokens: 4000000 wall_clock: "2h" rounds: 10 soft_at: 0.8 # converge once any dimension passes 80% scope: authorized: true roots: - { kind: "code:repo", path: "." } pool: agents: [review-lead, code-reviewer, security-reviewer, finding-verifier] workflows: [deep-review] # or: all round: lead_max_turns: 80 ceiling: { rounds: 12, wall_clock: "3h" }
| Field | Required | Meaning |
|---|---|---|
name | yes | The run's definition name, recorded on the run. |
description | no | Used as the lead's mission text when there are no goals or coverage target. |
lead | yes | The agent that runs each round. Must be listed in pool.agents. |
engagement_profiles | yes | The engagement profile set (code, web, network, binary, a composite like pentest, …) that defines the asset kinds, coverage ladders and finding classifications the goals refer to. An unknown profile id fails the load. It's carried to every unit the lead launches. See Engagement profiles. |
goals | one of goals / coverage | Measurable targets. A findings target counts findings (optionally by classification, e.g. CWE-89) and needs count_gte. An asset target names a profile-namespaced kind plus a locator (string coordinates host, url, path, symbol, sha256, commit, param) and needs depth_at_least, a rung of that profile's coverage ladder. required defaults to true; only required goals can end the run as goals_met. |
coverage | one of goals / coverage | An engagement-wide stop: reach in (0, 1], an optional depth rung, and optional kinds to measure. Validated to be reachable. |
budget | no | usd, tokens, wall_clock (Ns/Nm/Nh/Nd), rounds, and soft_at (default 0.8). Every field is optional; an unset one never stops the run. See Budgets. |
scope | yes | authorized: true is mandatory — it's your statement that the fleet may act unattended. Optional roots name root asset kinds of an active profile (code:repo, network:host, web:site) with their coordinates; they are validated and listed, with mode, in the lead’s opening-round prompt as the authorized targets. mode is accepted and stored, not acted on. |
pool | yes | The fixed set of agents and workflows (a list, or all for every workflow in the catalog at launch) the lead may launch. The lead never invents new fleet agents. A lead or unit agent that also declares dispatchableAgents gets the intersection. |
round | no | lead_max_turns per round (default 50, at least 1) and a hard ceiling on rounds / wall_clock. |
messaging | no | false turns the messaging tools (board.*, msg.send) off for the lead and every unit; the operator still reaches them, and the lead keeps its status, roster and launch tools. Default on. See Messaging. |
trigger | no | Accepted and stored, not acted on: nothing schedules an agentiflow. Use cron to call rupu agentiflow run --detach if you need one. |
rupu agentiflow run refuses to launch when, for example, scope.authorized is not
true; there are neither goals nor a coverage target; the lead is not in pool.agents;
a goal names a kind or depth no active profile defines; a verify_with agent is not in the pool; a scope root is
not a root kind; or a budget value is out of range. It warns (but still runs) when nothing except an operator stop can end the run: no
required goal, no coverage target, no enforceable budget cap and no ceiling.
bypass mode,
in the directory you launch from. Scope roots are typed, validated and shown to the lead, but rupu does not enforce scope in code: nothing stops an agent from
reaching beyond them, so put hard constraints in the goal objectives too. Only put agents in the pool that you're
happy to run unattended.
The lead's toolbox
The lead is a normal agent file (Markdown + YAML) — its system prompt is yours to write. It gets the tools its own
tools: lists (none of the built-ins if tools: is absent),
findings.report (plus assets.mark under an engagement profile), and these
fleet tools, always on:
| Group | Tool | What it does |
|---|---|---|
| Units | dispatch { kind: "agent", name, prompt } | Start a pool agent as a unit. Refused for an agent outside pool.agents. Returns a handle at once. |
dispatch { kind: "workflow", name | file, inputs? } | Start a pool workflow, or one workflows.generate wrote, as a unit. Vetted before launch: in the pool, parses, no approval gate, no host:/distribute: placement, runs only pool agents, inputs resolve. A refusal comes back as a tool error the lead can react to. | |
join { handles?, timeout_secs?, until? } | Wait for units' results. Default 300 s, at most 3600 s per call. | |
| Generation | workflows.generate { description } | Author a new workflow, apply the same checks, save it under the run's generated/ directory and return its file; the lead then runs it with dispatch { kind: "workflow", file }. Only offered when a generation provider with an API credential is available; otherwise a warning at launch says why. |
| Message space | board.claim · board.release | Claim a piece of work (a one-hour lease) so two participants don't duplicate it, and give it back. |
board.post · board.read | Shared posts of kind observation, question, answer, vote or note, optionally addressed to one participant or role. | |
msg.send | Message a participant, a role:, the lead, parent, or broadcast. | |
| Steering | board.directive | Write a standing directive every agent unit sees on every turn. Returns an id. |
board.retract { id } | Lift a directive. The retraction is appended to the log; the board is never rewritten. | |
| Status | goal.status · goal.coverage · budget.status | Re-check goals, coverage and spend mid-round with the same evaluators and meter the envelope uses — including, for a verification-gated goal, how many matching findings still await a verdict. |
| Roster | agents.list · agents.get · workflows.list · workflows.get · catalog.search | Browse the agents and workflows it can draw on: what each is for, its tools, its inputs. |
The launch tools are the same catalog dispatch, join and
workflows.generate every agent run has, with the pool as the allowlist. The pre-unification names still
work as aliases and answer in their old shapes: dispatch { agent, prompt } (no kind)
starts an agent unit and returns {handle, participant}, as does run_workflow { workflow, inputs? };
generate_workflow { description, inputs? } writes the workflow and starts it in one call; join { handle }
returns the old single-status shape. A dispatch with no kind and a name
is a subagent: it runs inside the coordinator's process, is not a participant in the space, and its usage is
charged to the flow's ledger.
board.directive and board.retract carry authority over other agents, so a
* grant never offers them; an agent gets them by naming them (or board.*), and the
agentiflow lead has them. Dispatch is non-blocking: the lead can start several units in one turn, keep working, and
join them later. Each lead turn also carries its inbox, the standing directives and a short index of the
pool, so it knows what it can start without spending a turn on agents.list.
Units and the message space
Every unit runs as its own process, started by the same launcher every dispatch goes
through: an agent unit is a rupu run <agent> …, a workflow unit a
rupu workflow run <name> --plain … (or --file <path> for a generated
one). A unit runs at the lead's permission mode (bypass), capped by its own agent's
permissionMode when that is stricter, under the run's engagement profiles. Its findings and assets are pooled
with the lead's under one coverage scope — exactly where the envelope looks when it evaluates the goals. A unit may start children of
its own with dispatch when its tools: grants it (the default grant does); its
allowlist is the pool, so it can't reach an agent or workflow the lead couldn't, and the usual depth limit applies. At most
[dispatch].max_concurrent_children (default 8) units run at once, across rounds; more wait as
pending, and a unit started in one round keeps running into the next.
The lead and its agent units are participants of one message space — the run directory — described in
Messaging. Every run joins it as it starts. The lead's address is
lead; a unit's is its codename below the lead, like lynx#1. Agent units get
board.claim / board.release / board.post /
board.read and msg.send; the lead additionally holds the directive tools.
Workflow units are findings-only: their steps get no board tools, and a workflow unit is a message root of its own with a fresh space.
- Claims.
board.claimtakes a one-hour lease on a piece of work (say, a file or a component) so two units don't duplicate it;board.releasegives it back. A claim journal records who held what and when. - Posts.
board.postappends an observation, question, answer, vote or note — optionally addressed to one participant or role, in which case it also lands in that addressee's inbox.board.readreads them back. - Directives. Standing instructions from the lead, re-shown to the participants they address on every turn until retracted.
- Messages.
msg.senddelivers to a participant, arole:<agent>,parent,lead, orbroadcast(each participant sees a broadcast once). A message that reached nobody says why, and is also an error the model sees.
Nobody spends a turn polling for messages. Before each model call a participant's inbox, the broadcasts it hasn't seen and the directives that apply to it are delivered into its context, so news arrives at the next loop iteration on its own; a tool call in progress is never interrupted. Every message is written to one append-only log and recorded in both sides' transcripts. Messaging is fire-and-forget; there are no blocking waits between agents.
Every unit is recorded in the flow's children ledger, <run dir>/children.jsonl — a
started line per unit (kind, name, run id, codename, participant, process group) and a
finished line when it ends — which the control plane's unit list and the orphan reaper read. When the run
stops for any reason, every unit still running is sent SIGTERM (to its process group, then SIGKILL after a grace) and recorded
abandoned.
Budgets and metering
Spend is metered once per round from the run's ledger (<run dir>/usage.jsonl). The lead and any
in-process sub-agents write it directly; each unit process, and every child it starts, also copies its calls there as rollup rows,
besides keeping its own run ledger. Totals are folded per model and priced through your layered
[pricing] config. Each budget dimension is evaluated as a fraction of its cap:
- Soft — once any set dimension passes
soft_at, the digest tells the lead to converge: wrap up and bank the highest-value work rather than open new lines of inquiry. - Hard — the first dimension to reach its cap stops the run before the lead is consulted again. A zero cap is exhausted immediately, never “unlimited”.
budget.usd is enforced only when the lead's model has a price; if it doesn't, the dollar cap
is dropped with a warning at launch and tokens, wall_clock and
rounds still apply — add a [pricing.<provider>."<model>"] entry to
enforce it. A unit on an unpriced model adds $0 (with a warning the first time) but its tokens still count.
The running spend is written to the run record after every round, so status and the control plane show it live.
Verified goals
Verification is opt-in per goal. With verified: true on a findings target, only findings that carry a
confirmed verdict from a different run than the one that filed them count. Add
verify_with: <agent> to require that verdict come from one named pool agent, and
verify_check: with_poc to also require at least one artifact (a proof of concept) on the finding's report.
verify_with alone turns verification on; verify_check without either is rejected.
Verification applies to findings goals only; an asset goal's depth rung is its own evidence.
Verdicts are written by the findings.verify tool — confirmed,
disputed or inconclusive. It is never granted automatically, not even to a
concerns: agent: write a verifier agent that lists it in tools: and put that agent
in the pool. It refuses a finding filed by the same run, and it needs a finding with a full report — so the agents that file findings
should use findingsProfile: full. The lead dispatches the verifier as a unit; its separate run id is the
independence, and goal.status shows the lead how many matches are still unverified. See
Findings for the report format.
Running and steering
| Command | What it does |
|---|---|
rupu agentiflow run <name> | Validate and run in the foreground until a stop, then print the outcome. --detach starts it in its own process group, returns once the run has started, and prints the run id; a run that fails to start reports why and leaves no run directory. --format json for the result. |
rupu agentiflow list | Every run, newest first: id, name, status, stop reason, rounds, goals met, start time. --format json or csv. |
rupu agentiflow status <id> | One run: status, stop reason, rounds, goals, budget and spend. |
rupu agentiflow attach <id> | Follow the run's event log and the lead's transcript for the round in progress. --no-follow (--once) prints what's recorded and returns. Ctrl-C leaves the run going. |
rupu agentiflow send <id> "<message>" | Message the lead as its operator. It arrives before the lead's next model call, mid-round included, and the next round's prompt recaps it. With --now the message is urgent: it goes first, and the lead's current round ends early at a safe point. |
rupu agentiflow stop <id> | Graceful stop at the next round boundary, units included. --now signals the coordinator and every running unit immediately (SIGKILL after a short grace) and records the run as failed (operator_stop:now). |
rupu agentiflow serve | Reap orphans: a run whose coordinator died is recorded as failed and its units are stopped. Runs until SIGTERM / Ctrl-C. |
Run ids look like af_…; every command accepts the full id, the compact form list prints, or a unique prefix or suffix.
# start in the background and watch it rupu agentiflow run ledger-review --detach rupu agentiflow attach af_01K… # redirect the lead mid-flight; --now ends its round early rupu agentiflow send af_01K… "Skip the CLI wrappers; focus on the query builders." rupu agentiflow send af_01K… "Stop probing the staging host." --now # wind it down rupu agentiflow stop af_01K…
rupu agentiflow send <id> <text> [--now] is shorthand for
rupu message send <id> --to lead [--urgent]. The rupu message family
reaches any participant of the space, not just the lead: send --to <unit> (a participant,
a codename, role:<agent> or broadcast),
rupu message list <id> [--follow] [--kind …] [--json] to read what was said and who saw it, and
rupu message participants <id> for the roster. A run that isn't running, or whose coordinator is
gone, takes no messages: the send fails and says why. See Messaging.
Detaching. The background process's stderr goes to <run dir>/detach.log. The
launching command waits up to 30 seconds for the run to start; if it is still starting after that, the command says so and returns
while the run keeps going. Stopping. A plain stop queues a stop request on the run's
control queue, and the envelope ends the run at the next round boundary as operator_stop.
stop --now sends SIGTERM to the coordinator (and, for a detached run, its whole process group) and to every
unit still running, waits up to 12 seconds, sends SIGKILL to whatever is left, and records the run as failed.
The run directory
Each run lives under ~/.rupu/agentiflows/af_<ULID>/, which is also the run's message space:
agentiflow.json # the run record: status, live spend, stop reason, coordinator pid agentiflow.yaml # the definition it started from events.jsonl # append-only: run_started, one round per finished round, run_stopped usage.jsonl # the run's usage ledger (lead + rollup rows from every unit) lead/transcript.r<N>.jsonl # the lead's transcript for round N log.jsonl # the message log: every message, append-only participants.jsonl # who joined and left the space board/ # posts.jsonl, directives.jsonl, claims/ (+ the claim journal) mailboxes/ # per-participant inboxes + the broadcast log children.jsonl # the children ledger: a started / finished line per unit steering/ # the operator's control queue: a graceful stop waiting for the round boundary generated/ # workflows written by workflows.generate units/<run id>/unit.json # runs from before the children ledger; still read detach.log # stderr of a --detach run
A run that completes normally has status completed whatever the reason (the reason is in
stop_reason); failed means it couldn't start, crashed, was hard-stopped or was
reaped. Units' own runs are ordinary rupu runs: agent transcripts under the project's .rupu/transcripts/
(or ~/.rupu/transcripts/), workflow units under ~/.rupu/runs/<id>/. Pooled
evidence lives in the workspace under .rupu/coverage/<target>/, scoped by the run id, so two runs never
share a scope.
Orphan reaping
A coordinator that is killed outright would otherwise leave its run running and its detached units burning
tokens. The reaper finds running runs whose recorded coordinator pid no longer exists, stops their units the
way stop --now does, and records the run as failed (orphaned: coordinator pid <p> not running).
rupu cp serve runs it on its gate-sweep tick; on hosts that don't run the control plane,
rupu agentiflow serve does the same job. A run with no recorded pid is never reaped.
[agentiflow] serve_enabled = true # whether `rupu agentiflow serve` runs serve_interval_secs = 60 # seconds between its sweeps reaper_enabled = true # the reap itself, in serve and in cp serve
In the control plane
The control plane lists agentiflow runs under Runs → Agentiflows
(status, stop reason, rounds, goals, spend, started) and gives each one a detail page with goals and budget usage in its header. A
running run refreshes every 5 seconds. When a run still reads running but its coordinator process is gone,
the header says so: the run died without finishing and the orphan reaper will close it.
| Tab | Shows |
|---|---|
| Flow | The unfolding map of the run, described below. |
| Assets | Engagement assets (hosts, services, sites, routes, files) with their depth rung. |
| Findings | Findings filed by the lead and its units, in the shared findings table. |
| Messages | The run's message space — every message, each with its delivery and who saw it — with a composer that reaches any participant; the default recipient is the lead, and an urgent send also ends the lead's round early. The send is refused, with the server's reason, once the run has finished or its coordinator process is gone. |
| Transcript | The lead's transcript for the latest round, tailing live. |
| Events | events.jsonl, newest first. |
The Flow tab
The Flow tab draws the run as an unfolding map, in the same visual language as a workflow run's graph. The lead is a spine down the left with one node per round; a round node carries what the lead decided that round, the round's goals and spend, and a link to that round's lead transcript. Each round branches right into the children it launched, drawn as the run graph's own node cards — codename, agent, kind badge, status, tokens and duration, a one-line result, a transcript link — and a child launched by a child hangs one column further right off its parent. A workflow child is framed in place as the workflow it is: its real run graph (steps, fan-outs, panels, gates, actions), built from the same components as the run page; a workflow the lead generated is labelled as written by the lead in that round and links to the definition it wrote. The map grows down (rounds) and to the right (work and depth).
Messages are drawn on the map as routed arcs under the cards, from sender to each receiver and coloured by kind (direct, board post, directive, operator); an operator message enters from outside the canvas, a broadcast or role directive fans out to every resolved recipient, and an undelivered message ends in a dead end marked with its reason. Hovering or selecting an arc, a card or a message row lights both ends and shows the body; the toolbar filters by message kind and participant. The board is a rail on the right edge: claims as held or released with owner and times, posts where they landed, standing directives with their tail (a retracted one ends where it was retracted), each connected to the card that wrote it. A workflow child's own messages and board are read from that child and drawn inside its frame, since a workflow child is a message root of its own.
A time axis with round bands and message ticks runs across the top, with a round navigator, a Replay control that plays the run's growth from the start, a scrubber on the axis, and a live pill while the run is going; a running run grows as rounds and children arrive. The same timeline engine drives a workflow run's page, so both replay, scrub and go live the same way. At real density, rounds whose children are all finished collapse to a summary card, a round with more children than fit shows the first few and a “+N more” card, and past a threshold arcs are drawn on hover or selection rather than all at once; the Messages list beneath the canvas always reaches every message. Nothing on the map is invented: a run recorded before the round, decision or generated-workflow fields existed shows less, and its children are placed by start time and marked as inferred.
API
The API behind it is local to the control plane's own host (?host= is a 501), except the message space, which takes a host:
| Endpoint | Purpose |
|---|---|
GET /api/agentiflows | Run list, newest first. |
GET /api/agentiflows/:id | Record, definition snapshot, budget, events (each round with its started_at, decision, goals and warnings), units, lead transcript paths. |
GET /api/agentiflows/:id/children | Every child at every depth, with parent_run_id, depth, round, generated and an output excerpt. |
GET /api/agentiflows/:id/messages · POST …/messages · GET …/messages/stream | The message space: read it, send as the operator ({to?, body, urgent?}; 409 when the run isn't running), tail it over SSE. |
GET /api/agentiflows/:id/board | The board: claims (held, released, expired) from the claim journal, and directives with their retractions. |
GET /api/agentiflows/:id/generated/:handle | The workflow definition the lead wrote and dispatched as child handle. |
POST /api/agentiflows/:id/steer | Kept as an alias: {message, now?} is POST …/messages {to: "lead", urgent: now}; {stop: true} queues a graceful stop. |
POST /api/agentiflows · POST /api/agentiflows/:id/stop | Launch a run detached (as rupu agentiflow run --detach) and stop one ({now?}). Both need rupu cp serve. |
GET /api/runs/:id/graph | A workflow child's own run graph, fetched when its frame comes into view. |
GET /api/findings?run_id=<id> | The run's findings — the lead's and every unit's, down the whole tree. |
/api/agentiflow-defs lists, reads, writes and validates definition files (global and project layers); see
HTTP API. Over rupu cp serve's MCP
endpoint the same routes are operator tools (agentiflows.*, agentiflow_defs.*),
and wait takes an agentiflow condition that fires when the run writes a line.
rupu agentiflow run and
rupu agentiflow stop (or the API routes above) start and end one. The control plane reads runs on its own host, and
units always run locally.
Limitations
- Local only. The CP reads agentiflow runs on its own host, and units always run locally: workflows with
host:ordistribute:can't be run as units. trigger:is not acted on andscopeis shown, not enforced.- No sub-leads. Units may dispatch children within the pool, but only the lead plans rounds.
- Workflows with approval gates can't run as units — a unit has no terminal to answer the prompt.
- Verification is findings-only. Asset goals can't require verification.
- The CP Assets tab is not scoped to the run; it lists assets from every registered workspace.
- No definition listing or dry-run validation on the CLI;
runvalidates at launch (the API's/api/agentiflow-defs/validatedoes check a file without running it).