Workflow stdio Protocol (stream-json) โ
This document specifies how CodeBuddy CLI (cbc / codebuddy) exposes the progress and lifecycle of Dynamic Workflows over the stdio stream-json channel, and how this protocol aligns with Claude Code 2.1.220. The goal is to let any SDK / daemon / CI that implements the Claude Code official stream-json protocol consume cbc's workflow events without code changes.
Applies to:
cbc --input-format stream-json --output-format stream-json(i.e., headless stdio). The same wire format is also used for-p/--output-format=stream-json.
1. Alignment with Claude Code 2.1.220 โ
Claude Code carries Dynamic Workflow progress through the existing system/task_* message family and does not introduce a separate top-level type. cbc follows the same convention:
| Concern | Wire format (consistent between Claude Code 2.1.220 and cbc) |
|---|---|
| Workflow run declaration | {"type":"system","subtype":"task_started","task_type":"local_workflow","workflow_name":"...", ...} |
| Phase / sub-agent progress | {"type":"system","subtype":"task_progress","workflow_progress":[{"type":"workflow_phase",...}, {"type":"workflow_agent",...}], ...} |
| Status transitions | {"type":"system","subtype":"task_updated","patch":{"status":"running|completed|failed|killed", ...}} |
| Terminal state | {"type":"system","subtype":"task_notification","status":"completed|failed|stopped", ...} |
| Interrupt / cancel | {"type":"control_request","request":{"subtype":"interrupt", ...}} (session-level) |
There is no cbc-specific top-level type:"workflow" event. Any consumer that speaks the Claude Code stream-json protocol dialect can seamlessly drive and observe cbc workflows.
2. Transport Layer โ
- Framing: newline-delimited JSON (ndjson). One event per line, terminated by
\n. Not SSE. - Direction:
- stdin:
control_request+usermessages. - stdout:
system(with subtype),assistant,user,result,control_response.
- stdin:
- Encoding: UTF-8.
- Ordering: Events are emitted in the order they are produced. See ยง6 for ordering guarantees.
3. Global Feature Toggle โ
The Workflow feature is a global switch (not segmented by transport channel):
| Trigger | Meaning |
|---|---|
CODEBUDDY_DISABLE_WORKFLOWS=1 (env) | Disables the entire Workflow feature. |
settings.json: { "disableWorkflows": true } | Same as above; can be turned off at the user / project level. |
When disabled, the Workflow tool is not registered and no workflow-shaped task_started / task_progress events are produced; downstream consumers will only see ordinary system/task_* messages from other background tasks.
4. Driving a Workflow from stdin โ
The control channel is identical to any other stdio session:
jsonc
// 1. initialize
{"type":"control_request","request_id":"init","request":{"subtype":"initialize","hooks":{},"capabilities":{},"hasPrompt":true}}
// 2. Ask the agent to start a workflow (the model will invoke the `Workflow` tool)
{"type":"user","message":{"role":"user","content":"run /deep-research topic=foo"}}
// 3. Stop everything (aborts the workflow and all sub-agents simultaneously)
{"type":"control_request","request_id":"stop-1","request":{"subtype":"interrupt","session_id":"<sessionId>","reason":"user cancel"}}Notes:
- The
session_idforinterruptcomes from an earlier stdoutsystem/initevent. interruptsimultaneously aborts the workflow and any in-flight sub-agent model streams.
4.1 Aborting a Running Workflow โ
Over the stdio protocol, there is only one way to stop a workflow: send a control_request with subtype: "interrupt" from stdin. There is no "cancel individual workflow" command โ interrupt aborts the entire session, which is also the end-to-end semantics actually executed by the CLI.
Message Format โ
jsonc
{
"type": "control_request",
"request_id": "<client-chosen string>",
"request": {
"subtype": "interrupt",
"session_id": "<sessionId>",
"reason": "user cancel"
}
}Rules:
- Send only one interrupt per run; repeated interrupts against the same session are idempotent.
- It is only meaningful while the run is still alive. After the workflow task's
task_notificationhas been received, an interrupt is a no-op on the workflow state. session_idis session-scoped; aborting one session does not affect other sessions in the same process.- Do not use closing stdin as a substitute for interrupt.
What You Will See on stdout (in order) โ
After the interrupt is delivered, the following will appear in sequence:
control_responseโ a one-time ACK:json{"type":"control_response","response":{"subtype":"success","request_id":"stop-1"}}The ACK only means "the CLI received the interrupt"; it does not mean the workflow has wound down.
system/task_updatedโ the workflow task'spatch.statusflips to"killed"andpatch.end_timeis populated with a timestamp:json{"type":"system","subtype":"task_updated","task_id":"<workflowTaskId>","patch":{"status":"killed","end_time":1785...}}system/task_notificationโ sent exactly once, withstatus:"stopped"(internalkilled/cancelledare both mapped to this value; see ยง5.4):json{"type":"system","subtype":"task_notification","task_id":"<workflowTaskId>","status":"stopped","summary":"...","output_file":"..."}Late
system/task_progressevents (cbc superset relative to Claude Code) โ as each sub-agent winds down, itsworkflow_agententry intask_progress.workflow_progress[]flipsstatefrom"start"to"error"and carrieserror: "subagent aborted: ...". Claude Code does not guarantee this step; cbc emits it for observability. Consumers that rely solely on the Claude Code guarantee should treattask_notification.status="stopped"as the sole authoritative terminal signal.
Reference Implementation (interrupt + graceful drain) โ
js
import { spawn } from 'node:child_process';
const cbc = spawn('cbc', [
'--input-format', 'stream-json',
'--output-format', 'stream-json',
'--permission-mode', 'bypassPermissions',
], { stdio: ['pipe', 'pipe', 'pipe'] });
const send = obj => cbc.stdin.write(JSON.stringify(obj) + '\n');
let buf = '';
let sessionId;
const tasks = new Map(); // task_id -> { workflow_progress, status, notified }
cbc.stdout.on('data', chunk => {
buf += chunk;
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.type === 'system' && msg.subtype === 'init') sessionId = msg.session_id;
if (msg.type !== 'system') continue;
if (msg.subtype === 'task_started' && msg.task_type === 'local_workflow') {
tasks.set(msg.task_id, { workflow_progress: [], status: 'running', notified: false });
}
if (msg.subtype === 'task_progress' && Array.isArray(msg.workflow_progress)) {
const t = tasks.get(msg.task_id);
if (t) t.workflow_progress = msg.workflow_progress; // authoritative snapshot
}
if (msg.subtype === 'task_updated') {
const t = tasks.get(msg.task_id);
if (t && msg.patch?.status) t.status = msg.patch.status;
}
if (msg.subtype === 'task_notification') {
const t = tasks.get(msg.task_id);
if (t) { t.status = msg.status; t.notified = true; }
}
}
});
send({ type: 'control_request', request_id: 'init',
request: { subtype: 'initialize', hooks: {}, capabilities: {}, hasPrompt: true } });
send({ type: 'user', message: { role: 'user', content: '/deep-research topic=foo' } });
async function cancel() {
if (!sessionId) return;
send({ type: 'control_request', request_id: 'stop-' + Date.now(),
request: { subtype: 'interrupt', session_id: sessionId, reason: 'user cancel' } });
await new Promise(resolve => {
const check = () => {
const stillRunning = [...tasks.values()].some(t => !t.notified);
if (!stillRunning) resolve();
else setTimeout(check, 200);
};
check();
});
}Key points in the code above:
- Use the
session_idfromsystem/initas the interrupt target. - Use
task_notification(not thecontrol_responseACK) as the criterion for "the run is done." - Treat
task_progress.workflow_progressas an authoritative snapshot: a latertask_progressoverwrites the previous state for the sametask_id.
5. Message Reference โ
5.1 task_started โ
jsonc
{
"type": "system",
"subtype": "task_started",
"task_id": "<uuid>",
"tool_use_id": "<toolUseId>",
"task_type": "local_workflow", // โ discriminator for workflow tasks
"workflow_name": "path:simple.workflow.js",
"description": "<workflow description>",
"uuid": "<messageUuid>",
"session_id": "<sessionId>"
}task_type: "local_workflow"identifies a workflow task; other background tasks retain values such as"Agent"/"Bash"/"PowerShell".workflow_namecorresponds to the workflow declaration'smeta.name(falls back topath:<basename>when the script is started by path). Claude Code additionally includes the full workflow JS source in thepromptfield; cbc generally omitsprompt.
5.2 task_progress (workflow shape) โ
One is emitted each time the workflow's phase / sub-agent timeline changes. Consumers should treat the payload as an authoritative snapshot โ the latest workflow_progress for a given task_id overwrites all previous ones.
jsonc
{
"type": "system",
"subtype": "task_progress",
"task_id": "<uuid>",
"tool_use_id": "<toolUseId>",
"description": "<workflow description>",
"usage": { "total_tokens": 0, "tool_uses": 0, "duration_ms": 0 },
"workflow_progress": [
{ "type": "workflow_phase", "index": 1, "title": "Gather" },
{ "type": "workflow_agent", "index": 1, "agentId": "v2:<hash>",
"state": "start", "startedAt": 178547..., "label": "child-1",
"phaseTitle": "Gather", "phaseIndex": 1 }
],
"uuid": "<messageUuid>",
"session_id": "<sessionId>"
}Timeline entry shapes (aligned with Claude Code 2.1.220):
workflow_phase:{ type, index, title }โ a phase declared in the workflow script.workflow_agent:{ type, index, agentId, state, startedAt, endedAt?, label?, phaseIndex?, phaseTitle?, tokens?, resultPreview? }state:"start"โ"done"|"error"|"cached".agentId: content-addressable keyv<schema>:<sha256>; stable across reruns, convenient for consumer-side deduplication or mapping to cached artifacts.
5.3 task_updated โ
Emitted on every status transition of a workflow task; observe the patch for deltas.
jsonc
{"type":"system","subtype":"task_updated","task_id":"<id>","patch":{"status":"completed","end_time":178547...}}status values: pending, running, paused, completed, failed, killed.
5.4 task_notification โ
Emitted once each time a workflow task enters a terminal state.
jsonc
{"type":"system","subtype":"task_notification","task_id":"<id>",
"status":"completed", // or "failed" / "stopped"
"summary":"<summary>","output_file":"...","usage":{...}}status is mapped from the internal task state: killed / cancelled both collapse to "stopped". This is the authoritative terminal signal โ do not derive completion from other events.
6. Ordering Guarantees โ
task_started/task_updated/task_notificationare critical events: they always arrive in the order they occur and are not skipped before the task reaches a terminal state.- Workflow-shaped
task_progressevents have a delivery guarantee for phase / sub-agent lifecycle changes: before the workflow task enters a terminal state, every non-cached sub-agent'sworkflow_agententry will appear at least once in a terminal state (done/error/cached). - Ordering under interrupt (cbc superset relative to Claude Code): on the interrupt path, sub-agents wind down asynchronously, and some
task_progressevents (typically those flippingworkflow_agent.stateto"error") may arrive aftertask_notification. Consumers should treattask_notification.status="stopped"as the terminal status signal, but retain thetask_idfor a short grace window (recommended 5 seconds) to capture latetask_progressupdates. - Snapshot replacement semantics: a later
task_progressfor the sametask_idoverwrites the previousworkflow_progresssnapshot. Consumers do not need to accumulate diffs โ just use the latest payload.
7. Compatibility with Claude Code โ
Parts cbc guarantees are byte-for-byte equivalent to Claude Code 2.1.220:
system/task_startedshape (task_id,tool_use_id,task_type: "local_workflow",workflow_name,description,uuid,session_idall consistent).system/task_progress.workflow_progress[]element shapes:workflow_phaseandworkflow_agent, field names (agentId,state,startedAt,endedAt,phaseIndex,phaseTitle,label,tokens,resultPreview) and the state enum (start/done/error/cached) are consistent.system/task_updated.patchandsystem/task_notification.statussemantics.
Declared cbc supersets relative to Claude Code (do not violate the Claude Code schema; safe for Claude Code native consumers):
- On the interrupt path, cbc additionally emits
task_progressmessages that flip in-flight sub-agents'workflow_agent.statefrom"start"to"error"; Claude Code does not guarantee this step. - cbc's
workflow_nameusespath:<basename>when the script is started by path (Claude Code usesmeta.namefrom the workflow source).
Consumers MUST ignore unrecognized fields โ cbc reserves the right to add new timeline entry types and optional fields without a major version bump (see ยง8).
8. Versioning Conventions โ
This protocol evolves additively:
- New timeline entry
typevalues may be added underworkflow_progress; consumers MUST ignore unrecognized types. - Existing fields will not be renamed or removed within a major version.
- Each workflow task is guaranteed to emit exactly one
task_notification; consumers may use it as the terminal signal.
9. Security and Privacy โ
- The
control_responsefrominitializecontainsaccount.token(Keycloak JWT), enterprise id, and email. Do not log or share stdout dumps without filtering. task_started.workflow_name,task_progress.workflow_progress[].label, andtask_progress.workflow_progress[].resultPreviewmay contain user input or sub-agent output fragments โ treat them as untrusted when rendering downstream.- Workflow event payloads themselves do not contain full prompts or full model outputs โ only ids, counts, and short previews.