Composable Agentic Workflows
We started with 8 fixed phases. Here's why we replaced them with composable bash and agent steps — and how to migrate.
Composable Agentic Workflows
When we shipped AgenticKode, the worker ran every task through a fixed 8-phase pipeline: workspace_setup → init → planning → coding → testing → reviewing → approval → finalization. It was easy to explain, easy to draw on a slide, and easy to defend in an ADR.
It was also wrong about how modern agents actually work.
This post explains why we replaced the fixed pipeline with composable bash and agent steps, what the new model looks like, and how to migrate. Existing templates keep running — the legacy pipeline is now one of several templates, preserved as the default. See ADR-007 for the long-form rationale.
What was wrong with 8 phases
The original ADR for the 8-phase pipeline assumed each phase would use a different specialist agent — a planner model, a coder model, a reviewer model. The framework would orchestrate the handoffs and the user would get the benefit of "an independent reviewer catches what the coder missed."
That assumption stopped being true almost as fast as we shipped it. Claude Code, Codex, OpenHands, Gemini CLI — modern coding agents routinely do plan, code, test, and self-review inside a single session. When you run the default template with Claude, the framework spins up four separate Claude sessions in a row, paying the startup cost each time, while the agent re-derives context that the previous instance just produced. The "planner ≠ coder ≠ reviewer" separation existed in the framework but not in the actual agent's head.
It was LARP-style specialization. The framework was abstracting a separation that the underlying tool didn't have, and giving users no way to opt out.
Three other problems compounded it:
1. The fixed shape blocked non-coding workflows. A workflow that "fetches a Sentry alert, summarizes it with an agent, posts to Slack" doesn't need planning or coding or reviewing or approval. But the pipeline insisted. Users either forked the codebase or bent a coding template into running 5 of 8 phases as no-ops with phase_overrides redirecting the remaining 3. Neither was acceptable.
2. Inter-phase data flow was implicit and fragile. The contract that coding reads planning_result.subtasks and reviewing reads coding_results.session_id was encoded as direct attribute reads on TaskRun, mirrored from PhaseExecution.result via a hardcoded map. Adding a step between planning and coding required editing both modules. Removing one risked KeyErrors downstream. Users couldn't see what a phase produced or consumed without reading the source.
3. The flexibility was already there — we just hid it. WorkflowTemplate.phases is a JSONB array. PhaseConfig.params is an open dict. The phase registry auto-discovers any module exporting run(). CommandExecutor is a generic bash primitive every workspace-touching phase already used. CLIAdapter.generate already abstracts "run an agent against a prompt." The DB layer was always composable. The frontend, docs, and seed templates were the ones lying about it.
What the new model looks like
Every step is one of three kinds:
bash— runs a shell command on the workspace server. Captures stdout, stderr, exit code.agent— invokes a role (planner / coder / reviewer / custom) throughRoleResolverwith a rendered prompt. Supportsgenerateandtaskmodes, session continuity, per-step agent overrides.legacy_phase— invokes one of the original phase modules (planning,coding,testing,reviewing,approval,finalization,pr_fetch,task_creation,agent_loop). Kept indefinitely. Thedefaulttemplate is composed entirely of these.
Both bash and agent share the same rules: timeout_seconds, retry_count, failure_mode (fail or skip), trigger_mode (auto, wait_for_trigger, wait_for_approval). Identical surface, predictable behavior.
Two built-in steps — workspace_setup and init — always run first. They're not user-composable, because the workspace path and project context they produce are preconditions for everything else.
Inter-step data flow is explicit:
{{run.title}} → task_run.title
{{run.description}} → task_run.description
{{steps.plan.subtasks}} → latest completed `plan` step's result["subtasks"]
{{steps.build.stdout}} → stdout of the most recent completed `build` bash step
No magic globals. No hidden mirroring. If you can read the template, you can see what every step consumes.
Triggers are first-class workflow config — not scattered across seven webhook handlers. A template's triggers[] array declares what fires it: GitHub / Gitea / GitLab / Plane / Notion webhooks, schedule (cron), labels, PR events, or manual (never auto-fires; runs only via direct API). One TriggerMatcher service routes every incoming event to the templates that match.
Here's the composable equivalent of the old default pipeline. One agent does the whole task, then make test, then gh pr create with a human approval gate:
{
"name": "agent-end-to-end",
"triggers": [{"type": "label", "match_any": ["feature", "bug"]}],
"phases": [
{
"phase_name": "implement",
"kind": "agent",
"role": "coder",
"params": {
"prompt": "Task: {{run.title}}\n\n{{run.description}}\n\nContext:\n{{steps.init.context_summary}}",
"mode": "task"
},
"timeout_seconds": 5400
},
{
"phase_name": "test",
"kind": "bash",
"params": {"command": "make test"}
},
{
"phase_name": "open_pr",
"kind": "bash",
"params": {
"command": "gh pr create --title {{run.title}} --body 'Closes {{run.task_id}}'"
},
"trigger_mode": "wait_for_approval"
}
]
}
Three steps instead of eight. One agent session instead of four. Same end state. And — this is the part that wasn't possible before — the same primitive composes a Sentry-alert responder, a docs-update runner, a scheduled audit, or anything else you'd previously have to write a custom phase module for:
{
"name": "hourly-error-summary",
"triggers": [{"type": "schedule", "cron": "0 * * * *"}],
"phases": [
{
"phase_name": "fetch",
"kind": "bash",
"params": {"command": "curl -s https://sentry.example.com/api/0/issues/?statsPeriod=1h"}
},
{
"phase_name": "summarize",
"kind": "agent",
"role": "reviewer",
"params": {
"prompt": "Summarize these errors as a Slack message:\n\n{{steps.fetch.stdout}}"
}
},
{
"phase_name": "post",
"kind": "bash",
"params": {
"command": "curl -X POST $SLACK_WEBHOOK -d '{\"text\": \"{{steps.summarize.response}}\"}'"
},
"failure_mode": "skip"
}
]
}
What we kept
Update (v0.5.2): The five legacy label-routed templates (
planner,hotfix,small-task,pr-review,fix-pr) have since been retired and removed from new installs. Thedefaulttemplate shipped today is the five-step composable workflow described in the Workflows guide. The two-template seed (default+example-composable) is the current state.
Nothing in the existing system breaks. The phase modules in backend/worker/phases/ are unchanged. The RoleAdapter Protocol, GitProvider Protocol, ServiceContainer, repository pattern, broadcaster — all unchanged.
The 8-phase pipeline is still available — it's just one option now instead of the only one. If you've written automation against TaskRun.coding_results.pr_diff or task_run.planning_result.subtasks, your code keeps reading the same columns for one more release. (We deprecate the legacy result-mirror columns in 0.6.0 and drop them in 0.7.0.)
The approval and finalization legacy modules still do their thing — push branch, create PR, park the run for human approval; send notifications, merge if configured, clean up. We considered re-implementing those as bash recipes, but they encapsulate behavior (approval-timeout parking, multi-channel notifications, worktree cleanup) that's genuinely worth keeping. So they stay, addressable as kind: legacy_phase steps.
Migrating
We're not asking anyone to migrate. The default template still works.
When you want to compose your own workflow, the recipe per legacy phase is in the Workflows guide — but the short version is:
| Legacy phase | Composable equivalent |
|---|---|
planning | {kind: agent, role: planner, params: {prompt: "Plan: {{run.title}}\n\n{{run.description}}"}} |
coding | {kind: agent, role: coder, params: {prompt: "Implement: {{steps.plan.response}}", mode: "task"}} |
testing | {kind: bash, params: {command: "make test"}} |
reviewing | {kind: agent, role: reviewer, params: {prompt: "Review the diff."}} |
approval | Any step with trigger_mode: wait_for_approval |
finalization | {kind: bash, params: {command: "gh pr merge --auto --squash"}} |
Per-phase prompt overrides that used to live in RoleConfig.phase_binding still work for legacy_phase steps. For agent steps, just put the prompt directly in params.prompt — no separate registry, no special-case lookup, no hidden routing.
What's also new in 0.5.0
The same release adds:
- Worktree-per-run workspace strategy — opt-in alternative to the shared clone. Each run gets
<project_root>/.worktrees/run-<id>-<ts>/on the workspace server. Inspired by the myDash pattern: pure path functions, idempotent create/remove, timestamped to avoid collisions. Orphan worktrees are swept by a scheduled cleanup job. POST /api/workflow-templates/{id}/dry-run— test whether a synthetic event would fire a template's triggers. Drives the frontend's trigger preview.GET /api/step-kinds— JSON schema for the step editor.- UI step composer — the workflow editor now exposes step kind selectors and per-kind param editors (
StepEditor,StepListEditor,GenericStepResult) instead of being locked to the eight-phase shape.
Closing
The fixed 8-phase pipeline was the right starting point in 2026 — it was easy to reason about and let us ship a working framework fast. But it stopped reflecting how the agents underneath actually worked, and it blocked categories of workflow we wanted to support.
Composable steps fix both. And because the storage layer was always JSONB, fixing both was additive: nothing breaks, the legacy pipeline still runs, and the new primitives are something you can adopt one template at a time.
If you have feedback or want to see specific workflow recipes, drop us a line in Discussions. The Workflows reference has the full spec; ADR-007 has the rationale.