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 stepsWhen it stops
WorkflowYou, in YAML, ahead of timeWhen the last step finishes
AutoflowYou, in YAML; the runtime picks which item to run it onPer item, when its workflow finishes
AgentiflowA lead agent, round by round, from a fixed poolGoals 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.

operator send / stop / CP envelope rounds · budget · goal / coverage stops digest each round lead agent plans, delegates, joins message space board · claims · directives · inboxes agent unit workflow unit generated workflow dispatch · join · workflows.generate — each unit is its own process
The envelope drives rounds; the lead and its units talk through one file-backed message space in the run directory. The operator reaches any of them from the CLI or the control plane.

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 reasonFires when
goals_metAt least one goal is required, and every required goal's predicate holds over the pooled evidence.
coverage_reachedThe 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_stopA graceful stop was requested (rupu agentiflow stop, or the control plane). A hard stop (--now) records the run as failed with operator_stop:now.
ceilingThe 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" }
FieldRequiredMeaning
nameyesThe run's definition name, recorded on the run.
descriptionnoUsed as the lead's mission text when there are no goals or coverage target.
leadyesThe agent that runs each round. Must be listed in pool.agents.
engagement_profilesyesThe 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.
goalsone of goals / coverageMeasurable 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.
coverageone of goals / coverageAn engagement-wide stop: reach in (0, 1], an optional depth rung, and optional kinds to measure. Validated to be reachable.
budgetnousd, 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.
scopeyesauthorized: 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.
poolyesThe 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.
roundnolead_max_turns per round (default 50, at least 1) and a hard ceiling on rounds / wall_clock.
messagingnofalse 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.
triggernoAccepted 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.

Agentiflows run unattended. The lead runs without approval prompts and every unit starts in 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:

GroupToolWhat it does
Unitsdispatch { 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.
Generationworkflows.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 spaceboard.claim · board.releaseClaim a piece of work (a one-hour lease) so two participants don't duplicate it, and give it back.
board.post · board.readShared posts of kind observation, question, answer, vote or note, optionally addressed to one participant or role.
msg.sendMessage a participant, a role:, the lead, parent, or broadcast.
Steeringboard.directiveWrite 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.
Statusgoal.status · goal.coverage · budget.statusRe-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.
Rosteragents.list · agents.get · workflows.list · workflows.get · catalog.searchBrowse 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.

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:

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

CommandWhat 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 listEvery 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 serveReap 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.

TabShows
FlowThe unfolding map of the run, described below.
AssetsEngagement assets (hosts, services, sites, routes, files) with their depth rung.
FindingsFindings filed by the lead and its units, in the shared findings table.
MessagesThe 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.
TranscriptThe lead's transcript for the latest round, tailing live.
Eventsevents.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:

EndpointPurpose
GET /api/agentiflowsRun list, newest first.
GET /api/agentiflows/:idRecord, definition snapshot, budget, events (each round with its started_at, decision, goals and warnings), units, lead transcript paths.
GET /api/agentiflows/:id/childrenEvery child at every depth, with parent_run_id, depth, round, generated and an output excerpt.
GET /api/agentiflows/:id/messages · POST …/messages · GET …/messages/streamThe 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/boardThe board: claims (held, released, expired) from the claim journal, and directives with their retractions.
GET /api/agentiflows/:id/generated/:handleThe workflow definition the lead wrote and dispatched as child handle.
POST /api/agentiflows/:id/steerKept 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/stopLaunch a run detached (as rupu agentiflow run --detach) and stop one ({now?}). Both need rupu cp serve.
GET /api/runs/:id/graphA 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.

Start and stop from the CLI. The web page observes and steers a run; 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