tencent cloud

Workflow stdio Protocol

Download
Focus Mode
Font Size
Last updated: 2026-09-30 18:15:48
AI-Translated
This document specifies how CodeBuddy CLI (cbc / codebuddy) exposes the progress and lifecycle of Dynamic Workflow over the stdio stream-json channel, as well as the alignment between this protocol and Claude Code 2.1.220. The goal is to enable any SDK / daemon / CI implemented according to the official Claude Code stream-json protocol to consume cbc workflow events without code changes.
Note:
Applies to: cbc --input-format stream-json --output-format stream-json (that is, headless stdio). The same wire format is also used in scenarios with -p / --print and --output-format=stream-json.

1. Alignment with Claude Code 2.1.220

Claude Code carries the progress of Dynamic Workflow through the existing system/task_* message family and does not introduce a separate top-level type. cbc follows the same convention:
Focus
Online Form (Consistent with 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 transition
{"type":"system","subtype":"task_updated","patch":{"status":"running|completed|failed|killed", ...}}
Final 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). Each line contains one event and ends with \\n. It is not SSE.
Direction:
stdin: control_request + user messages.
stdout: system (including subtype), assistant, user, result, control_response.
Encoding: UTF-8.
Order: Events are emitted in the order they are produced. See the timing guarantees in §6.

3. Feature Master Switch

The Workflow feature is a global switch (not subdivided by transport channel):
Trigger Condition
Description
CODEBUDDY_DISABLE_WORKFLOWS=1(env)
Disable the entire Workflow feature.
settings.json: { "disableWorkflows": true }
Same as above, but disabled at the user/project granularity.
When disabled, the Workflow tool is not registered and does not produce workflow-shaped task_started / task_progress events. Downstream consumers only see ordinary system/task_* messages from other background tasks.

4. Driving Workflows from stdin

The control channel is consistent with any other stdio session:
// 1. initialize
{"type":"control_request","request_id":"init","request":{"subtype":"initialize","hooks":{},"capabilities":{},"hasPrompt":true}}

// 2. Have the agent run a workflow once (the model will call the `Workflow` tool).
{"type":"user","message":{"role":"user","content":"run /deep-research topic=foo"}}

// 3. Stop everything (abort both the workflow and all sub-agents).
{"type":"control_request","request_id":"stop-1","request":{"subtype":"interrupt","session_id":"<sessionId>","reason":"user cancel"}}
Note:
The session_id of interrupt is taken from the earlier stdout system/init event.
interrupt aborts both the workflow and in-flight sub-agent model streams simultaneously.

4.1 Aborting a Running workflow

There is only one way to stop a workflow over the stdio protocol: send a control_request + subtype: "interrupt" from stdin. There is no command to "cancel a single workflow". The interrupt aborts the entire session, which is also the semantics that the CLI actually implements end-to-end.

4.2 Message Format

{
"type": "control_request",
"request_id": "<client-selected string>",
"request": {
"subtype": "interrupt",
"session_id": "<sessionId>",
"reason": "user cancel"
}
}
Rules:
Send only one interrupt per run. Repeated interrupts for the same session are idempotent.
It is only meaningful while the run is still alive. After the task_notification for that workflow task is received, interrupt is a no-op for the workflow state.
session_id is session-scoped. Aborting one session does not affect other sessions in the same process.
Do not use closing stdin as a substitute for interrupt.

4.3 What You Will See on stdout in Sequence

After the interrupt is delivered, the following will appear in order:
4.3.1 control_response one-time ACK:
{"type":"control_response","response":{"subtype":"success","request_id":"stop-1"}}
The ACK only means that the CLI has received the interrupt. It does not mean that the workflow has finished.
4.3.2 system/task_updated The patch.status of the workflow task changes to "killed", and patch.end_time is set to the timestamp:
{"type":"system","subtype":"task_updated","task_id":"<workflowTaskId>","patch":{"status":"killed","end_time":1785...}}
4.3.3 system/task_notification is sent only once, with status:"stopped" (internal killed / cancelled are both mapped to this value, see §5.4):
{"type":"system","subtype":"task_notification","task_id":"<workflowTaskId>","status":"stopped","summary":"...","output_file":"..."}
4.3.4 Late system/task_progress events (a superset of Claude Code in cbc). When each sub-agent finishes, its workflow_agent entry in task_progress.workflow_progress[] changes the state from "start" to "error" and carries error: "subagent aborted: ...". Claude Code does not guarantee this step. cbc emits it additionally for observability. Consumers that rely only on Claude Code-guaranteed events should treat task_notification.status="stopped" as the only authoritative terminal state.

4.4 Reference Implementation (interrupt + Graceful drain)

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 above code:
Use the session_id from system/init as the target of the interrupt.
Use task_notification (instead of the control_response ACK) as the criterion for "run finished".
Treat task_progress.workflow_progress as the authoritative snapshot: a later task_progress overwrites the previous state for the same task_id.

5. Message Reference

5.1 task_started

{
"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 workflow tasks. Other background tasks retain values such as "Agent" / "Bash" / "PowerShell".
workflow_name corresponds to the meta.name declared by the workflow (falling back to path:<basename> when the script is started by path). Claude Code additionally includes the full workflow JS source code in the prompt field, while cbc generally omits prompt.

5.2 task_progress (workflow Form)

A message is sent each time the phase / sub-agent timeline of a workflow changes. Consumers should treat the payload as the authoritative snapshot, and the latest workflow_progress for that task_id overwrites the previous one.
{
"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 format (aligned with Claude Code 2.1.220):
workflow_phase: the phase declared by the workflow script, with the structure { type, index, title }.
workflow_agent:{ type, index, agentId, state, startedAt, endedAt?, label?, phaseIndex?, phaseTitle?, tokens?, resultPreview? }
state:"start" → "done" | "error" | "cached".
agentId: a content-addressed key in the format v<schema>:<sha256>. It remains stable across reruns, making it easy for consumers to deduplicate or map to cached artifacts.

5.3 task_updated

A message is sent each time a Workflow task changes state. Use patch to observe the incremental changes.
{"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

A message is sent each time a Workflow task enters a terminal state.
{"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: both killed and cancelled converge to "stopped". This is the authoritative terminal state signal — do not derive completion determination from other events.

6. Timing Guarantees

task_started / task_updated / task_notification are critical events: they are guaranteed to arrive in the order they occurred and will not be missed before the task reaches a terminal state.
The task_progress event of the Workflow class provides a delivery guarantee for phase / sub-agent lifecycle changes: before a workflow task enters a terminal state, the workflow_agent entry of every non-cached sub-agent appears at least once in a terminal state (done / error / cached).
Ordering under interruption (a cbc superset relative to Claude Code): On the interruption path, sub-agents finish asynchronously, and some task_progress events (typically changing workflow_agent.state to "error") may arrive later than task_notification. Consumers should treat task_notification.status="stopped" as the terminal state signal, but keep the task_id for a short grace window (5 seconds recommended) to capture late task_progress updates.
Snapshot replacement semantics: a later task_progress for the same task_id overwrites the previous workflow_progress snapshot. Consumers do not need to accumulate diffs and can simply use the latest payload as the source of truth.

7. Compatibility with Claude Code

Parts where cbc guarantees byte-level exact equivalence with Claude Code 2.1.220:

The system/task_started form (where task_id, tool_use_id, task_type: "local_workflow", workflow_name, description, uuid, and session_id are all identical).
The system/task_progress.workflow_progress[] element form: workflow_phase and workflow_agent, with field names (agentId, state, startedAt, endedAt, phaseIndex, phaseTitle, label, tokens, resultPreview) and the state enum (start / done / error / cached) being identical.
Semantics of system/task_updated.patch and system/task_notification.status.
Declared supersets of cbc relative to Claude Code (does not violate the Claude Code schema and is safe for native Claude Code consumers):
On the interruption path, cbc additionally sends task_progress messages to change the workflow_agent.state of in-flight sub-agents from "start" to "error". Claude Code does not guarantee this step.
When the script is started by path, cbc uses path:<basename> for workflow_name (Claude Code uses meta.name from the workflow source code).
Consumers MUST ignore unrecognized fields. cbc reserves the right to add new timeline entry types and optional fields without a major version upgrade (see §8).

8. Versioning Conventions

This protocol evolves additively:
New timeline entry type values may be added under workflow_progress. Consumers MUST ignore unrecognized types.
Existing fields will not be renamed or removed within a major version.
Each workflow task is guaranteed to send only one task_notification. Consumers can treat it as a terminal-state signal.

9. Security and Privacy

The control_response of initialize contains account.token (Keycloak JWT), the enterprise id, and the email address. Do not archive or publicly share stdout dumps without filtering.
task_started.workflow_name, task_progress.workflow_progress[].label, and task_progress.workflow_progress[].resultPreview may contain user input or sub-agent output fragments, and must be treated as untrusted when rendered downstream.
The Workflow event payload itself does not contain the full prompt or complete model output, only IDs, counts, and short previews.


Help and Support

Was this page helpful?

Help us improve! Rate your documentation experience in 5 mins.

Feedback