<!-- description: AgentRQ workflows have no if/else, no conditions, no decision nodes. Routing is a lookup on (workflow, event), branching is a choice of event name, and loops are rejected at save time. Here's the mechanism, tested against a live v0.4.1 build. -->
<!-- date: 2026-08-18 -->
<!-- author: AgentRQ Team -->
<!-- ogimage: https://agentrq.com/assets/blog/workflows-branch-og.png -->

# Workflows Without Decision Trees

Open any workflow builder and you will find the same furniture: a decision node, a condition expression, a true branch, a false branch. Somewhere in there is a little diamond with a question in it.

AgentRQ's Workflows have none of that. There is no condition field, no expression language, no decision node — and no place to put one. The pipeline still branches, still handles the failure case, still fans out. It just does it without anything in the system evaluating a rule.

This post is about how, and about where the model runs out of road.

## A Step Is an Edge, Not an Instruction

The whole data model is one row shape. A workflow step says: when **this event** fires, create a task in **this workspace**, and when that task completes, publish **this event**. Three fields — `eventId`, `workspaceId`, `emitEventId` — and nothing else.

That shape is why there is no control flow to express. From the cycle checker in `backend/internal/controller/crud/workflow.go`:

```
// Only the event-to-event shape matters: a step is an edge
// event → workspace → event, and the workspace is a pass-through, so a run
// loops exactly when the events do.
```

A workspace is a pass-through. It does work, and the work does not change where the output goes — the graph already decided that. So "what happens next" is never computed at runtime; it is a lookup. When an event is published inside a run, the consumer asks for the steps registered against that `(workflow, event)` pair and creates a task for each one:

```go
steps, err := c.repo.SystemListWorkflowStepsByEvent(ctx, ev.WorkflowID, ev.EventID)
if err != nil || len(steps) == 0 {
    return
}
for _, step := range steps {
    c.createWorkflowTask(ctx, step, ev, faqText)
}
```

That is the entire routing engine. No predicate is evaluated, because there is no predicate.

## The Agent's Entire Coordination Contract Is One Line

If the graph does not decide, who does? The agent — but only in the narrowest possible way. Here is exactly what a workflow step appends to the task it creates, generated by `internal/service/eventinstruction`:

```
[On completion, before marking this task completed: call publishEvent(name: "qa_passed", taskId: "0hGyTHNe4RN", payload: "<what happened, in your words>", faq: [{q, a}, ...])]
Copy name and taskId exactly as written above — taskId is what tells the server which run this continues.
Write the payload yourself. faq is optional: question/answer pairs covering what subscribers are likely to ask — tasks triggered by this event can render both.
```

That's it. The agent is told an event name to publish, its own task ID to echo back, and to describe what happened in its own words. It is never told which workspace consumes the event, whether anything consumes it at all, what workflow it is part of, or what stage it is at.

The `taskId` is doing quiet but important work here. In v0.4.1 (#326) the `workflow` argument was **removed** from `publishEvent` entirely — the server now reads the workflow and the hop count off the publishing task's own row:

```go
// WorkflowID is the run the publishing task belongs to, read from the task
// itself. The task is the only thing that knows this: an agent is told which
// task it is publishing for, never which workflow that task sits in.
```

Before that fix, the server reconstructed "which task is publishing" from an in-memory session map that `createTask`, `reply` and `updateTaskStatus` each overwrote as a side effect, with a last resort of "whichever task is ongoing." An agent that created a follow-up task before publishing got resolved against the new task, which has no workflow, and the run stopped there while the publish still reported success. Same graph, same agent, different outcome depending on the incidental order of unrelated tool calls. Naming the task fixed it — and it also means the agent needs no concept of the pipeline at all.

Even starting a run is not a special operation. You create an ordinary task and pick a workflow under "on completion"; the task row gets the workflow's start event, and when the agent finishes and publishes, the run begins. There is no orchestrator object anywhere in the system — a run is a chain of tasks that each happen to publish an event.

## Branching Is a Choice of Event Name

So where does the failure path live? In the name.

Consider a release gate: run the checks, and if they pass, draft the announcement; if they fail, record the attempt instead. In a decision-tree builder this is a condition node reading some `status` variable. Here it is two event names — `qa_passed` and `qa_failed` — with a different workspace subscribed to each. The agent that ran the checks is the only party that knows which happened, so it is the party that chooses which name to publish. Nothing else evaluates anything.

I built exactly that on a live v0.4.1 build to check the claim rather than assert it:

![The release_gate canvas: the armed qa_passed path drawn from the start event, an "Unreachable steps" warning for the qa_failed branch, and directly below it the task that branch created during a real run](/assets/blog/workflows-branch-canvas.png)

The run: a task in `doc` bound to the workflow published `code_changed`, which created "Run the release checks" in `test`. That task's body armed it to publish `qa_passed`. Instead — playing the agent whose checks just failed — I published `qa_failed` with the same `taskId`.

The branch fired. "Record the failed release attempt" was created in `changelog`, carrying the payload, and the `blog` task on the happy path was never created:

![The branch task in the changelog workspace, carrying the publisher's payload about which tests failed](/assets/blog/workflows-branch-task.png)

No condition was configured anywhere. The decision was made by the only participant holding the information, and expressed as four characters of difference in an event name.

## What the Canvas Can't Know

Look at that first screenshot again, because it is also showing a real wart. The `qa_failed → changelog` step sits under a warning that reads **"1 step(s) subscribe to an event no run can reach from the start event, so they will never fire"** — while the task it created during the run is listed directly underneath, and the warning offers a `remove` link for a step that is doing its job.

The reason is that reachability is computed by walking `emitEventId` edges out from the start event. A branch that no step is armed to emit — because an agent picks it at runtime — is invisible to that walk. The runtime is right and the drawing is conservative.

Two consequences of the same root cause, worth knowing before you rely on it:

- **Text mode doesn't render the branch.** `GetWorkflowText` serialises only the reachable tree, so an agent-chosen branch simply isn't in the document.
- **Saving from text mode deletes it.** `ReplaceWorkflowFromText` swaps the entire step set, so a round-trip through the text editor drops any step the text can't express (and resets per-step titles and bodies to the default template).

So today the pattern lives comfortably on the canvas and via the API, and text mode is for pipelines whose shape is fully declared. That's a fixable UI gap, not a hole in the model — but it is the kind of thing you would rather read here than discover after a save.

## No Loop Control, Because Loops Can't Be Saved

The other half of a decision tree is iteration: a loop with an exit condition. Workflows have no loop construct, and the reason is the same as for branches — the topology carries it.

A step whose emitted event can reach its own source event is rejected when you save it, not guarded when it runs:

```
// A cycle is rejected at setup rather than guarded at runtime because a
// looping graph spawns tasks forever, and the editor can show the problem
// while the user is still looking at the canvas.
```

Since the graph is acyclic by construction, "does this terminate?" is answered by the shape rather than by a counter someone remembered to add. There is still a runtime backstop — `maxWorkflowDepth = 32` — but read what it is for:

```
// Cycles are rejected when a step is created, so a well-formed graph can never
// reach this. It is a backstop for the cases setup validation cannot see: a
// graph edited mid-run, or rows written before that validation existed.
```

That is the honest shape of the guarantee: the invariant is enforced at edit time, and the runtime limit exists for the cases edit-time validation cannot see.

## The Branch You Usually Don't Need

Most of the time the answer isn't a branch at all — it's fan-out.

Subscribe two workspaces to the same event and both get a task when it fires. In a decision tree you would be tempted to route between them ("if the change touches docs, notify the docs team"). Here you subscribe both and let each decide whether there is work for it, in the workspace that actually understands the question. Nothing coordinates them, they run in parallel, and adding a third consumer is one drag on the canvas — no upstream prompt is edited, because the publisher never knew who was listening.

That is the shift the model asks for: **decisions belong to whoever holds the information, and routing belongs to the graph.** A conditional in a builder is neither — it is a third place that has to be told about the other two.

## Where This Model Runs Out

Being straight about the limits, since they follow from the same design:

- **No joins.** Nothing waits for two branches to converge. A diamond re-join draws as one node, but at runtime each publish fans out on its own, so a consumer of a re-joined event gets one task per publish rather than one task after both. If you need "when both are done," that is a workspace whose job is to check both, not a graph feature.
- **No retries.** "Try again" is a cycle, and cycles are rejected at save time. A retry has to live inside the task — the agent trying again — rather than as an edge that loops back.
- **No timers or waits between steps.** A step runs when its event is published, full stop. Scheduling belongs to tasks (cron), not to the workflow graph.
- **The publisher must be trusted to name the right event.** The graph cannot check the agent's judgement — it can only route what it is told. That is a real trade: you get no condition to misconfigure, and no condition to catch a wrong call either.

None of these are oversights so much as the cost of the trade. What you get back is a pipeline whose behaviour you can read off a picture: every arrow is a real edge, every box is a real task, and there is no hidden rule deciding which arrow gets taken.

If you want the feature tour rather than the mechanism, the [Workflows launch post](/blog/agentrq-workflows) walks through the canvas, the text format and the run scoping.

---

*AgentRQ is currently in public beta. Join our [GitHub community](https://github.com/agentrq/agentrq) to help shape the future of human-agent collaboration.*
