Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default defineConfig({
items: [
{ slug: 'features/agent-configuration' },
{ slug: 'features/plan-mode' },
{ slug: 'features/workflows' },
{ slug: 'features/agent-scheduling' },
{ slug: 'features/slash-commands' },
{ slug: 'features/pinned-prompts' },
Expand Down
56 changes: 56 additions & 0 deletions site/src/content/docs/features/workflows.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: Workflows
description: Watch Claude Code's multi-agent Workflow runs unfold in the transcript, with a live phase and agent tree.
---

Claude Code's **Workflow** tool orchestrates many subagents from a script — fan out a review across dimensions, pipeline a migration over dozens of files, run a judge panel over competing designs. Claudette renders each run as a live tree in the chat transcript: phases, per-agent state, token and tool counts, and elapsed time.

## What you see

A workflow appears in the transcript as a card, in place of the tool call that launched it.

- **Header** — the workflow's name (read from the script's `meta.name`), how many agents have finished, total tokens, and a badge for any that failed. A progress rail underneath fills as agents complete, turning green on success or red if any agent errored.
- **Phase groups** — agents are grouped under the phase that spawned them, in declaration order.
- **Agent rows** — one per subagent, showing its state (`○` queued, `◐` running, `●` done, `✕` failed), model, token count, tool-call count, and elapsed time. The second line shows the agent's latest tool activity, or its error if it failed.
- **Script** — a collapsed disclosure at the bottom of the card holds the workflow script itself.

Rows carry extra badges where relevant: `cached` when a resumed run served an agent from its prior result instead of re-running it, `retry N` when an agent needed more than one attempt, and `worktree` or `remote` when an agent ran isolated.

The card is expanded by default. Collapsing it hides the agent tree but keeps the header and progress rail visible, so a collapsed run still shows its agent count, failures, and tokens at a glance. That holds both while the run is live and after the launching turn has finished, and your choice carries across that boundary — collapse a running workflow and it stays collapsed, in the same shape, once the turn ends.

## The status pill

Workflows run in the background. The tool returns "launched in background" within a second, the agent finishes its turn, and the run keeps going for minutes — so the card is usually well above the fold by the time anything interesting happens.

A pill above the message composer keeps each in-flight run visible: workflow name, current phase, and agents completed. Click it to scroll its card back into view. The pill disappears when the run finishes. Concurrent workflows each get their own pill.

## Persistence

A completed run's tree is stored with the turn, so it renders the same after an app restart, in [forked sessions](/claudette/features/checkpoints-and-forking/), and when history is replayed from disk.

Because a workflow typically finishes long after its turn was saved, Claudette writes the final tree back to that turn when the completion notification arrives. If you restart Claudette while a workflow is still running, its card shows the tree as of the moment the turn ended — the run itself continues inside Claude Code, but Claudette is no longer listening for its progress.

## Limits

Claudette renders what Claude Code puts on the stream, which shapes what is and isn't available:

- **`log()` output is not shown.** Claude Code strips narrator lines before the progress stream leaves the CLI, so they never reach Claudette.
- **Runs can't be controlled from Claudette.** Pause, kill, retry, and skip live inside the Claude Code process and aren't exposed over its stream protocol. Use `/workflows` in a Claude Code terminal for those.
- **Progress is throttled.** Claude Code sends the full tree on state changes and at most every ten seconds otherwise. Elapsed times for running agents are interpolated locally so rows keep counting between updates.
- **Claude backends only.** Codex Native sessions have no equivalent tool; the card never appears there.

## Enabling workflows

Workflows are a Claude Code feature, configured in Claude Code rather than in Claudette:

- Available on paid plans; not enabled by default on Pro.
- Toggle with the **Workflows** setting in Claude Code's `/config`.
- `CLAUDE_CODE_DISABLE_WORKFLOWS` disables the tool entirely.
- Claude Code's **Workflow keyword trigger** setting controls whether the word "workflow" in a prompt starts one.

If workflows are unavailable in your Claude Code install, the tool is never offered to the agent and nothing changes in Claudette.

## Related

- [Task History](/claudette/features/task-history/) — the Tasks panel, which tracks agent-declared checklists rather than runtime-spawned agents.
- [Parallel Agents](/claudette/features/parallel-agents/) — running independent agents across separate workspaces.
21 changes: 21 additions & 0 deletions src-tauri/src/commands/chat/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ pub async fn save_turn_tool_activities(
Ok(())
}

/// Persist the final workflow progress tree for an activity whose turn has
/// already been checkpointed. See
/// [`claudette::db::Database::update_turn_tool_activity_progress`] for why a
/// targeted update is needed rather than a re-save of the whole turn.
#[tauri::command]
pub async fn update_turn_tool_activity_progress(
tool_use_id: String,
workflow_progress_json: Option<String>,
agent_status: Option<String>,
state: State<'_, AppState>,
) -> Result<(), String> {
let db = Database::open(&state.db_path).map_err(|e| e.to_string())?;
db.update_turn_tool_activity_progress(
&tool_use_id,
workflow_progress_json.as_deref(),
agent_status.as_deref(),
)
.map_err(|e| e.to_string())?;
Ok(())
}

#[tauri::command]
pub async fn load_completed_turns(
session_id: String,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ fn main() {
commands::chat::checkpoint::rollback_to_checkpoint,
commands::chat::checkpoint::clear_conversation,
commands::chat::checkpoint::save_turn_tool_activities,
commands::chat::checkpoint::update_turn_tool_activity_progress,
commands::chat::checkpoint::load_completed_turns,
commands::chat::session::list_chat_sessions,
commands::chat::session::get_chat_session,
Expand Down
4 changes: 4 additions & 0 deletions src/agent/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2854,6 +2854,7 @@ fn codex_compacting_status_event() -> AgentEvent {
description: None,
last_tool_name: None,
usage: None,
workflow_progress: None,
status: Some("compacting".to_string()),
compact_result: None,
compact_metadata: None,
Expand Down Expand Up @@ -2884,6 +2885,7 @@ fn codex_context_compaction_event() -> AgentEvent {
description: None,
last_tool_name: None,
usage: None,
workflow_progress: None,
status: None,
compact_result: None,
compact_metadata: Some(crate::agent::types::CompactMetadata {
Expand Down Expand Up @@ -2948,6 +2950,7 @@ fn codex_command_execution_status_event(item: &Value, status: &str) -> AgentEven
description: None,
last_tool_name: None,
usage: None,
workflow_progress: None,
status: Some(terminal_status.to_string()),
compact_result: None,
compact_metadata: None,
Expand Down Expand Up @@ -3069,6 +3072,7 @@ pub fn codex_turn_start_events(thread_id: &str) -> Vec<AgentEvent> {
description: None,
last_tool_name: None,
usage: None,
workflow_progress: None,
status: None,
compact_result: None,
compact_metadata: None,
Expand Down
3 changes: 2 additions & 1 deletion src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ pub use session::PersistentSession;
pub use types::{
AssistantMessage, CompactMetadata, ContentBlock, ControlRequestInner, ControlResponsePayload,
Delta, FileAttachment, InnerStreamEvent, StartContentBlock, StreamEvent, TokenUsage,
TokenUsageIteration, UserContentBlock, UserEventMessage, UserMessageContent, parse_stream_line,
TokenUsageIteration, UserContentBlock, UserEventMessage, UserMessageContent,
WorkflowAgentProgress, WorkflowProgressEntry, parse_stream_line,
};

/// Per-turn settings that control CLI flags for the agent subprocess.
Expand Down
232 changes: 232 additions & 0 deletions src/agent/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,95 @@ pub struct TaskUsage {
pub duration_ms: Option<u64>,
}

/// One entry in the `workflow_progress` array carried by
/// `subtype: "task_progress"` events for Claude Code's `Workflow` tool.
///
/// The array is a *full replacement snapshot* of the run's progress tree,
/// not a delta — but the CLI only includes it on meaningful state
/// transitions (queued → progress → done/error). Pure "still working"
/// ticks are throttled and arrive with the field absent, which means
/// "unchanged", never "empty". Consumers must not clear on `None`.
///
/// Field names are camelCase inside these entries even though the
/// enclosing `system` event uses snake_case — that asymmetry is upstream's,
/// not ours.
///
/// Unrecognized `type` values deserialize to [`WorkflowProgressEntry::Unknown`]
/// rather than failing the whole line. Note this is lossy on re-serialize:
/// an unknown entry persists as `{"type":"Unknown"}`. That is deliberate —
/// an entry kind we can't name is one we can't render either, and keeping
/// the surrounding progress tree parseable matters more than round-tripping
/// a payload nothing reads. `workflow_log` entries are the known example:
/// the CLI strips them before they ever reach the SDK stream.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum WorkflowProgressEntry {
/// Declares a phase heading. Emitted once per `phase()` call in the
/// workflow script, ahead of the agents that belong to it.
#[serde(rename = "workflow_phase")]
Phase {
#[serde(default)]
index: i64,
#[serde(default)]
title: String,
},
/// One subagent's current state. Re-emitted on each transition, so the
/// latest entry for a given `index` supersedes earlier ones.
#[serde(rename = "workflow_agent")]
Agent(Box<WorkflowAgentProgress>),
#[serde(other)]
Unknown,
}

/// Payload of [`WorkflowProgressEntry::Agent`]. Boxed at the variant because
/// — unlike [`StreamEvent`], which is built one at a time — these are held
/// by the hundred in a `Vec`, so an unboxed variant would size every entry
/// (including bare `Phase` headings) to this struct's footprint.
///
/// Every field is `#[serde(default)]` on purpose. A missing or renamed
/// field upstream would otherwise fail the entire enclosing `StreamEvent`,
/// costing us the whole progress tick rather than one attribute of one row.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct WorkflowAgentProgress {
/// Position in the run's global agent ordering. Stable across
/// re-emissions, so the UI keys rows on it.
pub index: i64,
/// Display label — the script's `opts.label`, or a summary of the prompt.
pub label: String,
pub phase_index: Option<i64>,
pub phase_title: Option<String>,
pub agent_id: Option<String>,
/// Custom subagent type (`opts.agentType`), when the script set one.
pub agent_type: Option<String>,
/// `"worktree"` or `"remote"` when the agent runs isolated.
pub isolation: Option<String>,
pub remote_session_id: Option<String>,
pub model: Option<String>,
pub fallback_model: Option<String>,
/// Observed: `"queued"`, `"progress"`, `"done"`, `"error"`. Kept as a
/// `String` rather than an enum so a future state can't break parsing —
/// same rationale as [`CompactMetadata::trigger`].
pub state: String,
pub started_at: Option<i64>,
pub queued_at: Option<i64>,
pub last_progress_at: Option<i64>,
/// Retry counter, 1-based. `> 1` means an earlier attempt failed.
pub attempt: Option<i64>,
pub last_attempt_reason: Option<String>,
pub last_tool_name: Option<String>,
pub last_tool_summary: Option<String>,
pub prompt_preview: Option<String>,
pub result_preview: Option<String>,
pub tokens: Option<u64>,
pub tool_calls: Option<u64>,
pub duration_ms: Option<u64>,
/// `true` when a resumed run served this agent from its prior result
/// instead of re-running it.
pub cached: Option<bool>,
pub error: Option<String>,
}

/// Top-level JSON line from Claude CLI stdout.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
Expand Down Expand Up @@ -118,6 +207,14 @@ pub enum StreamEvent {
/// Present on `task_progress` / `task_notification`.
#[serde(default)]
usage: Option<TaskUsage>,
/// Present on `task_progress` events for the `Workflow` tool: a full
/// snapshot of the run's phase/agent tree. Absent on the throttled
/// no-transition ticks, where it means "unchanged" — see
/// [`WorkflowProgressEntry`]. `skip_serializing_if` keeps it off the
/// re-emitted Tauri payload for the many system events that never
/// carry it.
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_progress: Option<Vec<WorkflowProgressEntry>>,
/// Only present on `subtype: "status"` events. Values observed:
/// `"requesting"` (normal API call), `"compacting"` (compaction in
/// flight), or `null` (compaction complete).
Expand Down Expand Up @@ -214,6 +311,7 @@ impl StreamEvent {
description: None,
last_tool_name: None,
usage: None,
workflow_progress: None,
status: None,
compact_result: None,
compact_metadata: None,
Expand Down Expand Up @@ -1342,3 +1440,137 @@ mod compaction_tests {
}
}
}

#[cfg(test)]
mod workflow_progress_tests {
use super::*;

/// Shape captured from a real `Workflow` run's `task_progress` event.
/// Field names inside `workflow_progress` are camelCase while the
/// enclosing event is snake_case — that split is what this pins.
#[test]
fn parses_task_progress_workflow_tree() {
let line = r#"{
"type": "system",
"subtype": "task_progress",
"task_id": "w4stpeffj",
"tool_use_id": "toolu_01AxY9PTrqaJ6k9apBCi3kue",
"description": "Review: review:completeness",
"last_tool_name": "StructuredOutput",
"usage": {"total_tokens": 84669, "tool_uses": 38, "duration_ms": 180027},
"workflow_progress": [
{"type": "workflow_phase", "index": 1, "title": "Review"},
{"type": "workflow_agent", "index": 1, "label": "review:completeness",
"phaseIndex": 1, "phaseTitle": "Review", "agentId": "ae1e803ca4fd797bf",
"model": "claude-opus-4-8", "state": "done", "startedAt": 1782843838042,
"queuedAt": 1782843838028, "attempt": 1, "lastToolName": "StructuredOutput",
"lastToolSummary": "No functional completeness gaps",
"promptPreview": "CONTEXT - a code change", "lastProgressAt": 1782844018069,
"tokens": 84669, "toolCalls": 38, "durationMs": 180027,
"resultPreview": "{\"summary\":\"...\"}", "cached": false}
]
}"#;
let ev: StreamEvent = serde_json::from_str(line).unwrap();
let StreamEvent::System {
subtype,
workflow_progress,
..
} = ev
else {
panic!("expected System event");
};
assert_eq!(subtype, "task_progress");
let entries = workflow_progress.expect("workflow_progress parsed");
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0],
WorkflowProgressEntry::Phase {
index: 1,
title: "Review".to_string(),
}
);
let WorkflowProgressEntry::Agent(agent) = &entries[1] else {
panic!("expected Agent entry, got {:?}", entries[1]);
};
assert_eq!(agent.label, "review:completeness");
assert_eq!(agent.state, "done");
assert_eq!(agent.phase_title.as_deref(), Some("Review"));
assert_eq!(agent.tokens, Some(84669));
assert_eq!(agent.tool_calls, Some(38));
assert_eq!(agent.duration_ms, Some(180027));
assert_eq!(agent.cached, Some(false));
}

/// An entry kind we don't model must not cost us the rest of the tree —
/// the surrounding phases and agents still have to parse.
#[test]
fn unknown_workflow_progress_entry_does_not_fail_the_line() {
let line = r#"{"type":"system","subtype":"task_progress","workflow_progress":[
{"type":"workflow_log","message":"3/10 found"},
{"type":"workflow_phase","index":2,"title":"Verify"}
]}"#;
let ev: StreamEvent = serde_json::from_str(line).unwrap();
let StreamEvent::System {
workflow_progress, ..
} = ev
else {
panic!("expected System event");
};
let entries = workflow_progress.expect("workflow_progress parsed");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], WorkflowProgressEntry::Unknown);
assert_eq!(
entries[1],
WorkflowProgressEntry::Phase {
index: 2,
title: "Verify".to_string(),
}
);
}

/// A `workflow_agent` stripped of every optional field must still parse.
/// Everything is `#[serde(default)]` precisely so one renamed attribute
/// upstream can't cost us the whole progress tick.
#[test]
fn workflow_agent_tolerates_missing_fields() {
let line = r#"{"type":"system","subtype":"task_progress","workflow_progress":[
{"type":"workflow_agent"}
]}"#;
let ev: StreamEvent = serde_json::from_str(line).unwrap();
let StreamEvent::System {
workflow_progress, ..
} = ev
else {
panic!("expected System event");
};
let entries = workflow_progress.expect("workflow_progress parsed");
let WorkflowProgressEntry::Agent(agent) = &entries[0] else {
panic!("expected Agent entry");
};
assert_eq!(agent.label, "");
assert_eq!(agent.state, "");
assert_eq!(agent.tokens, None);
}

/// Throttled no-transition ticks omit `workflow_progress` entirely.
/// It must deserialize to `None` (meaning "unchanged"), and must not be
/// re-emitted as an explicit `null` on the many system events that never
/// carry it.
#[test]
fn task_progress_without_workflow_tree_is_none_and_not_reserialized() {
let line = r#"{"type":"system","subtype":"task_progress","task_id":"w4stpeffj"}"#;
let ev: StreamEvent = serde_json::from_str(line).unwrap();
let StreamEvent::System {
workflow_progress, ..
} = &ev
else {
panic!("expected System event");
};
assert!(workflow_progress.is_none());
let re = serde_json::to_string(&ev).unwrap();
assert!(
!re.contains("workflow_progress"),
"absent tree should not be re-emitted: {re}"
);
}
}
Loading
Loading