Skip to content

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 / --print with --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:

ConcernWire 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 + user messages.
    • stdout: system (with subtype), assistant, user, result, control_response.
  • 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):

TriggerMeaning
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_id for interrupt comes from an earlier stdout system/init event.
  • interrupt simultaneously 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_notification has been received, an interrupt is a no-op on 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.

What You Will See on stdout (in order) โ€‹

After the interrupt is delivered, the following will appear in sequence:

  1. 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.

  2. system/task_updated โ€” the workflow task's patch.status flips to "killed" and patch.end_time is populated with a timestamp:

    json
    {"type":"system","subtype":"task_updated","task_id":"<workflowTaskId>","patch":{"status":"killed","end_time":1785...}}
  3. system/task_notification โ€” sent exactly once, with status:"stopped" (internal killed / cancelled are both mapped to this value; see ยง5.4):

    json
    {"type":"system","subtype":"task_notification","task_id":"<workflowTaskId>","status":"stopped","summary":"...","output_file":"..."}
  4. Late system/task_progress events (cbc superset relative to Claude Code) โ€” as each sub-agent winds down, its workflow_agent entry in task_progress.workflow_progress[] flips state from "start" to "error" and carries error: "subagent aborted: ...". Claude Code does not guarantee this step; cbc emits it for observability. Consumers that rely solely on the Claude Code guarantee should treat task_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_id from system/init as the interrupt target.
  • Use task_notification (not the control_response ACK) as the criterion for "the run is done."
  • Treat task_progress.workflow_progress as an authoritative snapshot: a later task_progress overwrites the previous state for the same task_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_name corresponds to the workflow declaration's meta.name (falls back to path:<basename> when the script is started by path). Claude Code additionally includes the full workflow JS source in the prompt field; cbc generally omits prompt.

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 key v<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_notification are critical events: they always arrive in the order they occur and are not skipped before the task reaches a terminal state.
  • Workflow-shaped task_progress events have a delivery guarantee for phase / sub-agent lifecycle changes: before the workflow task enters a terminal state, every non-cached sub-agent's workflow_agent entry 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_progress events (typically those flipping workflow_agent.state to "error") may arrive after task_notification. Consumers should treat task_notification.status="stopped" as the terminal status signal, but retain the task_id for a short grace window (recommended 5 seconds) 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 โ€” 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_started shape (task_id, tool_use_id, task_type: "local_workflow", workflow_name, description, uuid, session_id all consistent).
  • system/task_progress.workflow_progress[] element shapes: workflow_phase and workflow_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.patch and system/task_notification.status semantics.

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_progress messages that flip in-flight sub-agents' workflow_agent.state from "start" to "error"; Claude Code does not guarantee this step.
  • cbc's workflow_name uses path:<basename> when the script is started by path (Claude Code uses meta.name from 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 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 emit exactly one task_notification; consumers may use it as the terminal signal.

9. Security and Privacy โ€‹

  • The control_response from initialize contains account.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, and task_progress.workflow_progress[].resultPreview may 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.