Flows
Overview
A flow is a visual, graph-based definition of agent work. Where a conversation is an ad-hoc chat and a pipeline is a fixed sequence of stages, a flow is an explicit directed graph --- nodes are steps, edges are control flow --- authored in a ReactFlow canvas and executed by an event-driven engine in the Rust Core.
Flows are how Qadra expresses branching, conditions, human-in-the-loop gates, loops, sub-flows, and knowledge operations as a single composable artifact. An agent can trigger a flow from a conversation (via LLM tool-use), or a flow can be executed directly through the API.
The core idea: agents are programs that sometimes call models. A flow makes the program explicit --- you can see the control flow, the branch points, and where a human has to sign off.
| Concept | What it is |
|---|---|
| AgentFlow | The graph definition ({nodes, edges, viewport}). Tenant-scoped, reusable. |
| FlowExecution | A single run of a flow. Has its own state machine and per-node state. |
| Flow Node | One step in the graph. 17 node types, each with its own config and behavior. |
| State (blackboard) | A JSONB dictionary that flows between nodes --- each node reads inputs, writes outputs. |
The Flow Data Model
A flow is stored in two pieces: the editable definition and an immutable snapshot taken at execution time.
flow_data --- the editable definition
agent_flows.flow_data holds the ReactFlow graph verbatim as JSONB:
{
"nodes": [ { "id": "start", "type": "Start", "data": {} }, ... ],
"edges": [ { "id": "e1", "source": "start", "target": "agent-1" }, ... ],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
ReactFlow serializes to a single object, so JSONB stores it with no impedance mismatch. The executor always loads the full graph --- it never queries individual nodes --- so there is no benefit to normalizing nodes and edges into separate tables.
CREATE TABLE agent_flows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
flow_data JSONB NOT NULL DEFAULT '{"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}',
node_count INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, name)
);
flow_snapshot --- the immutable run copy
When an execution starts, the current flow_data is copied into flow_executions.flow_snapshot. The execution runs entirely against this snapshot, so editing the flow definition mid-run --- or after a run completes --- never changes what an in-flight or historical execution did. This is the same immutability pattern as workloads.pipeline_snapshot.
CREATE TYPE flow_execution_status AS ENUM
('pending','running','paused','completed','failed','cancelled');
CREATE TABLE flow_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
flow_id UUID NOT NULL REFERENCES agent_flows(id) ON DELETE CASCADE,
flow_snapshot JSONB NOT NULL,
status flow_execution_status NOT NULL DEFAULT 'pending',
state JSONB NOT NULL DEFAULT '{}',
node_states JSONB NOT NULL DEFAULT '{}',
execution_queue JSONB NOT NULL DEFAULT '[]',
current_node_id TEXT,
error TEXT,
triggered_by UUID REFERENCES users(id) ON DELETE SET NULL,
input JSONB NOT NULL DEFAULT '{}',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
| Column | Role |
|---|---|
flow_snapshot | Immutable copy of flow_data captured at run start |
state | The blackboard dictionary flowing between nodes |
node_states | Per-node {status, input, output, error, started_at, completed_at} |
execution_queue | Ordered node IDs ready to run next |
current_node_id | Node currently executing (or paused at) |
Execution State Machine
Every execution moves through a fixed set of states:
+-----------+
create -----> | pending |
+-----+-----+
|
v
+-----------+ HumanInput node
| running | <----------------------+
+-----+-----+ |
| |
+---------------+---------------+ |
| | | user submits
v v v input
+-----------+ +-----------+ +-----------+ |
| completed | | failed | | paused | -------+
+-----------+ +-----------+ +-----------+
|
cancel ---------+--> +-----------+
| cancelled |
+-----------+
| Status | Meaning |
|---|---|
pending | Execution created, not yet started |
running | Executor is processing the queue |
paused | Stopped at a HumanInput node, awaiting user response |
completed | An End node was reached |
failed | A node errored or a safety rail was hit |
cancelled | Explicitly cancelled via the API |
The Blackboard State
The state column is a blackboard --- a shared JSONB dictionary that every node can read from and write to. There is no per-node message passing; instead, nodes communicate through named keys in this dictionary.
- A node reads its inputs from state keys (e.g. an Extract node reads
state[input_key]). - A node writes its outputs back to state under a configured
output_key. - Downstream nodes read those keys as their own inputs.
Most nodes use {{variable}} templating in their config to interpolate state values --- for example a KnowledgeLookup node's query can be "financials for {{company}}", where company was written to state by an earlier Extract node.
The execution input (an initial JSONB object) seeds the blackboard at run start --- this is how a conversation passes user_message into a flow it triggers.
The Executor Loop
The engine lives in src/flow_executor.rs. It is an event-driven queue processor, not a recursive tree-walker.
- Start --- Parse the flow snapshot graph, find the Start node, seed the execution queue.
- Loop --- Pop a node from the queue, check that all its predecessors are complete, execute it, store its output to state, enqueue its successors, and persist the full execution row.
- Pause --- A HumanInput node sets the execution to
pausedand stops the loop, waiting for user input. - Resume --- When the user submits input, it is merged into state, the node's successors are re-enqueued, and the loop continues.
- End --- Reaching an End node sets the execution status to
completed.
State is persisted after each step (the executor reads/writes the full node_states and state as a batch, the same pattern as stage_results on workloads), so an execution is always recoverable from its database row.
Safety Rails
Because flows can branch and loop, the executor enforces hard limits to prevent runaway or recursive executions:
| Rail | Limit | Purpose |
|---|---|---|
MAX_TOTAL_STEPS | 500 | Cap on total node executions in one run |
| Max Loop iterations | 100 | Per-Loop-node iteration cap |
| Max Delay | 300s | Longest a Delay node may wait |
Hitting a rail transitions the execution to failed with an explanatory error rather than spinning indefinitely.
HumanInput: Pause and Resume
A HumanInput node is the human-in-the-loop gate. When the executor reaches one:
- The execution status becomes
paused. - The node status becomes
waiting_for_input. - The executor loop stops and persists state.
The execution stays paused --- indefinitely, durably --- until a user submits input via POST /flows/executions/:execId/input (NATS flow.do.human_input). The submitted input is merged into the blackboard state, the node's successors are re-enqueued, and the loop resumes from where it stopped.
The resume path is guarded against a time-of-check/time-of-use race: the resume SQL only updates when the row is still
paused, so two concurrent input submissions cannot both resume the same execution.
Node Types
A flow supports 17 node types. Each has its own data config and execution behavior.
| Node | Purpose | Key config |
|---|---|---|
| Start | Entry point. Seeds the queue. | --- |
| End | Terminal node. Marks the execution completed. | --- |
| Agent | Runs an agent persona via an LLM call, using state as context. | agent selection, prompt context |
| Condition | Branches control flow by evaluating a predicate against state. | condition expression, true/false edges |
| HumanInput | Pauses execution and waits for user input. | prompt, fields |
| Loop | Repeats a sub-section of the graph (capped at 100 iterations). | iteration source, max iterations |
| Http | Makes an outbound HTTP request. | url, method, headers, body |
| ExecuteFlow | Invokes another flow as a sub-flow. | target flow id, input mapping |
| Transform | Reshapes / maps values in the blackboard state. | transform expression, output_key |
| Delay | Waits for a fixed duration (capped at 300s). | duration |
| KnowledgeLookup | Queries the Knowledge Core (SPO/KG), Corpus (vectors), or both. | query (templated), source (knowledge_core | corpus | both), output_key, limit |
| Research | Autonomous research via external neural search (Exa). | query (templated), max_results, output_key |
| Artifact | Produces an artifact from an artifact template. | template_id, artifact_name (templated), content_key, output_key |
| Extract | Uses an LLM to parse structured parameters out of unstructured text. | input_key (default user_message), output_variables (comma-separated), instructions |
| SpoFilter | Reads SPO triples (optionally filtered by asserter) into state for downstream nodes. | asserter filter, output_key |
| SpoWrite | Persists an agent's output as SPO triples with provenance. | content_key, asserter, source, output_key |
| ClaimValidation | Grounds prior-stage agent claims against SPO triples in the Knowledge Core. | input_keys (default scarlet_output,ayana_output), output_key, asserter_filter, query_limit |
Notes on specific nodes
- Extract is the bridge between unstructured conversation and structured flow inputs. It reads
state[input_key], prompts the LLM to return JSON for the requestedoutput_variables, and merges the result into state. If the LLM returns non-JSON, it gracefully sets each variable tonullrather than failing the run. - KnowledgeLookup is the read side of the Knowledge Core.
sourcechooses SPO triples, vector chunks, or both; results land in state underoutput_key. - SpoFilter / SpoWrite are the SPO read/write pair --- a flow can pull facts into state, run agents over them, then write new claims back to the graph with provenance (asserter + source).
- ClaimValidation is the cross-stage groundedness checkpoint: it labels each prior-stage claim SUPPORTED, CONTRADICTED, or NOT_MENTIONED before unverified claims propagate downstream (e.g. into an investment memo). See the Knowledge Graph page for the grounding mechanics.
- Research currently runs as a placeholder pending full Exa integration; the node and config exist so flows authored against it remain valid.
SSE Streaming of Execution Progress
Long-running executions stream progress to the frontend over Server-Sent Events, so a user watching a flow run sees nodes start and complete in real time rather than waiting for one final response.
Because a NATS request-reply only allows a single response, progress cannot be streamed over the reply itself. Instead, a dedicated NATS progress subject is used:
React (SSE consumer) <--SSE-- Gateway <--NATS pub/sub-- Rust Core (FlowExecutor)
- The Gateway creates a unique NATS subject
_PROGRESS.{uuid}and subscribes to it. - It sends the execution request with that subject in the payload.
- The Rust Core's executor publishes
FlowProgressEvent { event, data, seq }messages to the subject as it runs --- fire-and-forget, with a monotonically increasing sequence counter for ordering. - The Gateway forwards each as an SSE event (
event: {type}\ndata: {json}\n\n) and closes the stream ondoneor client disconnect.
Emitted event types include flow_started, node_started, node_completed, node_failed, flow_completed, flow_failed, and done. The same machinery powers streaming flow execution from within a conversation.
NATS Operations and REST Endpoints
Flow subjects are tenant-scoped under qadra.{tid}.flow.do.>, handled by the Rust Core.
| Operation | Subject suffix | Description |
|---|---|---|
create | flow.do.create | Create a flow definition |
get | flow.do.get | Get a flow by ID |
list | flow.do.list | List flows (paginated) |
update | flow.do.update | Update name, description, flow_data, or is_active |
delete | flow.do.delete | Delete a flow |
execute | flow.do.execute | Start an execution (snapshots the flow, runs the graph) |
execution_get | flow.do.execution_get | Get execution state |
execution_list | flow.do.execution_list | List executions for a flow |
execution_cancel | flow.do.execution_cancel | Cancel a running execution |
human_input | flow.do.human_input | Submit human input to a paused execution |
generate | flow.do.generate | AI-generate a flow graph from a natural-language prompt |
These are reached over HTTP through the Gateway at /flows and /flows/executions/... (including POST /flows/:flowId/execute, POST /flows/executions/:execId/input, and the SSE-friendly execution endpoints). See the API Reference for the full request/response shapes --- the flow routes there map one-to-one onto the NATS operations above.