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. An SDK, daemon, or CI that implements the message shapes in this document can drive and observe cbc workflow events.
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. Message family β
Workflow progress uses the existing system/task_* message family and does not introduce a separate top-level type:
| Concern | Wire format |
|---|---|
| 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 separate top-level type:"workflow" event. A consumer that implements the message shapes in this document can 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 β as each sub-agent winds down, itsworkflow_agententry intask_progress.workflow_progress[]flipsstatefrom"start"to"error"and carrieserror: "subagent aborted: ...". cbc emits this for observability. Consumers 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). cbc does not put the full workflow source inprompt.
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:
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: 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. Stable fields and extensions β
Stable contract consumers can rely on:
system/task_startedshape:task_id,tool_use_id,task_type: "local_workflow",workflow_name,description,uuid,session_id.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).system/task_updated.patchandsystem/task_notification.statussemantics.
Extra events cbc emits without changing the meaning of the fields above:
- On the interrupt path, cbc emits an additional
task_progressthat flips in-flight sub-agents'workflow_agent.statefrom"start"to"error". - When a script is started by path,
workflow_nameispath:<basename>.
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.