Files
kandev/docs/agents/claude_code.md
T
2026-02-21 22:05:24 +00:00

20 KiB

Claude Code Integration

Technical reference for Kandev's Claude Code CLI integration. Covers the stream-json protocol, message lifecycle, control plane, tool normalization, session resume, and permission hooks.

Protocol Overview

Kandev communicates with Claude Code via the stream-json protocol: newline-delimited JSON over stdin/stdout. The CLI runs as a subprocess managed by agentctl.

┌─────────────┐  stdin (JSON lines)  ┌──────────────────┐
│   agentctl  │ ──────────────────►  │  Claude Code CLI  │
│  (adapter)  │  ◄──────────────────  │  (subprocess)     │
└─────────────┘  stdout (JSON lines) └──────────────────┘

CLI invocation:

npx -y @anthropic-ai/claude-code@<version>
  -p                              # Print mode (non-interactive)
  --output-format=stream-json     # Streaming JSON output
  --input-format=stream-json      # Streaming JSON input
  --permission-prompt-tool=stdio  # Permission prompts via stdio control messages
  --include-partial-messages      # Stream in-progress assistant messages
  --replay-user-messages          # Replay user messages on session resume
  --verbose                       # Extended logging
  --model <model>                 # Model selection
  --resume <session-id>           # Resume existing session

Architecture

pkg/claudecode/
├── types.go         # Protocol message types, constants, structs
└── client.go        # stdin/stdout client, read loop, control request handling

internal/agentctl/server/adapter/transport/streamjson/
├── adapter.go                # Main adapter: state, dispatch, sendUpdate
├── streamjson_init.go        # Initialize, hooks, MCP config
├── streamjson_session.go     # NewSession / LoadSession
├── streamjson_prompt.go      # Prompt sending, image attachments, Cancel
├── streamjson_messages.go    # System, assistant, user, rate-limit handlers
├── streamjson_result.go      # Result handler, deduplication, completion
├── streamjson_permissions.go # Permission requests, hook callbacks, timeout
├── streamjson_tracing.go     # OTel tracing helpers for control messages
└── normalize.go              # Tool data → NormalizedPayload conversion

Message Types

Every line on stdout is a JSON object with a type field. The client dispatches based on this type:

Type Direction Description
system CLI → Adapter Session initialization (session_id, status, slash commands)
assistant CLI → Adapter Assistant response with content blocks (text, thinking, tool_use)
user CLI → Adapter Tool results (tool_result blocks) or slash command output
result CLI → Adapter Turn completion with stats (cost, tokens, duration, errors)
rate_limit CLI → Adapter API rate limit notification
control_request CLI → Adapter Permission request or hook callback
control_cancel_request CLI → Adapter Cancels a pending permission/hook request
control_response Adapter → CLI Response to a control_request

Message Lifecycle

Client Layer (pkg/claudecode/client.go)

The Client reads stdout via bufio.Scanner (10 MB buffer), parses each line into a CLIMessage, and dispatches:

stdout line
  │
  ├── type=control_request      → RequestHandler(requestID, *ControlRequest)
  ├── type=control_cancel_request → CancelHandler(requestID)
  ├── type=control_response     → matches pending request by ID, unblocks goroutine
  └── (all other types)         → MessageHandler(*CLIMessage)

Control messages are handled immediately. All other messages are forwarded to the adapter's handleMessage().

Adapter Layer (streamjson/adapter.go)

The adapter's handleMessage() marshals the message for debug logging/tracing, then dispatches:

handleMessage(msg)
  │
  ├── system    → handleSystemMessage     → session_status, available_commands
  ├── assistant → handleAssistantMessage  → message_chunk, reasoning, tool_call, context_window
  ├── user      → handleUserMessage       → tool_update (complete/error), message_chunk (slash cmds)
  ├── result    → handleResultMessage     → complete, error (+ auto-complete pending tools)
  └── rate_limit → handleRateLimitMessage → rate_limit

Every dispatched event goes through sendUpdate(), which:

  1. Logs the normalized event (JSONL debug file)
  2. Creates an OTel span (child of the active prompt span)
  3. Sends to the updatesCh channel for WebSocket delivery

Turn Anatomy

A single turn (prompt → result) produces this message sequence:

Adapter                              Claude Code CLI
  │                                        │
  │  ── user message (prompt) ──────────►  │
  │                                        │
  │  ◄── system (session_id, status) ────  │  (first prompt only)
  │  ◄── assistant (text blocks) ────────  │  (streaming chunks)
  │  ◄── assistant (thinking blocks) ────  │  (reasoning, if enabled)
  │  ◄── assistant (tool_use block) ─────  │  (tool invocation)
  │                                        │
  │  ◄── control_request (can_use_tool) ─  │  (if hooks require approval)
  │  ── control_response (allow/deny) ──►  │
  │                                        │
  │  ◄── user (tool_result block) ───────  │  (tool output)
  │  ◄── assistant (text blocks) ────────  │  (more streaming)
  │  ◄── result ─────────────────────────  │  (turn complete)
  │                                        │

Text Deduplication

The result message may contain a text field that duplicates already-streamed assistant text. The adapter tracks streamingTextSentThisTurn:

  • Set to true when any message_chunk event is sent from assistant text blocks
  • If true when result arrives, result.text is skipped
  • Reset to false after each result

Message UUIDs

Each message has a uuid field used for session truncation on resume:

  • Assistant UUIDs are held as pendingAssistantUUID until the result message commits them to lastMessageUUID
  • User UUIDs are committed immediately (safe to resume from)
  • lastMessageUUID is included in the complete event and persisted for --resume-session-at

Content Blocks

Assistant messages contain an array of content blocks:

Text Block

{ "type": "text", "text": "Here's the implementation..." }

Emitted as message_chunk events for real-time streaming.

Thinking Block

{ "type": "thinking", "thinking": "Let me analyze the code structure..." }

Emitted as reasoning events. Only present when extended thinking is enabled.

Tool Use Block

{
  "type": "tool_use",
  "id": "toolu_abc123",
  "name": "Edit",
  "input": { "file_path": "/src/main.go", "old_string": "...", "new_string": "..." }
}

Normalized into a NormalizedPayload and emitted as a tool_call event with status "running".

Tool Result Block (in user messages)

{
  "type": "tool_result",
  "tool_use_id": "toolu_abc123",
  "content": "File edited successfully",
  "is_error": false
}

Enriches the pending NormalizedPayload with output data and emits a tool_update event with status "complete" or "error".


Control Plane

Initialize

Sent once after the subprocess starts. Registers hooks and retrieves available slash commands.

Adapter → CLI:
{
  "type": "control_request",
  "request_id": "<uuid>",
  "request": {
    "subtype": "initialize",
    "hooks": {
      "PreToolUse": [{ "matcher": "^Bash$", "hookCallbackIds": ["tool_approval"] }],
      "Stop": [{ "hookCallbackIds": ["stop_git_check"] }]
    }
  }
}

CLI → Adapter:
{
  "type": "control_response",
  "response": {
    "subtype": "success",
    "request_id": "<uuid>",
    "response": {
      "commands": [
        { "name": "commit", "description": "Create a git commit" },
        { "name": "review-pr", "description": "Review a pull request" }
      ]
    }
  }
}

Permission Request (can_use_tool)

When the CLI needs approval for a tool call:

CLI → Adapter:
{
  "type": "control_request",
  "request_id": "req_123",
  "request": {
    "subtype": "can_use_tool",
    "tool_name": "Bash",
    "tool_use_id": "toolu_abc",
    "input": { "command": "rm -rf /tmp/test" },
    "blocked_paths": "/etc,/usr"
  }
}

Adapter → CLI:
{
  "type": "control_response",
  "request_id": "req_123",
  "response": {
    "subtype": "success",
    "result": {
      "behavior": "allow",
      "updatedPermissions": [],
      "message": ""
    }
  }
}

The behavior field is "allow" or "deny". On deny, an optional interrupt: true stops the current operation.

Hook Callbacks

Hooks are registered during initialize and fire before tool execution. The response format differs from permission responses:

PreToolUse hook response:

{
  "type": "control_response",
  "request_id": "req_456",
  "response": {
    "subtype": "success",
    "result": {
      "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "permissionDecisionReason": "Auto-approved by SDK"
      }
    }
  }
}

permissionDecision values:

  • "allow" — approve the tool call
  • "deny" — reject the tool call
  • "ask" — trigger a separate can_use_tool permission request

Stop hook response:

{
  "type": "control_response",
  "request_id": "req_789",
  "response": {
    "subtype": "success",
    "result": {
      "decision": "approve",
      "reason": ""
    }
  }
}

Cancel Request

The CLI can cancel a pending permission dialog (e.g., when the tool call is superseded):

CLI → Adapter:
{
  "type": "control_cancel_request",
  "request_id": "req_123"
}

The adapter emits a permission_cancelled event so the frontend closes the dialog.

Interrupt

Sent by the adapter to cancel the current operation:

Adapter → CLI:
{
  "type": "control_request",
  "request_id": "<uuid>",
  "request": { "subtype": "interrupt" }
}

Permission Modes

The adapter supports three permission policies, configured via hooks at initialize:

Autonomous (default)

No hooks registered. --dangerously-skip-permissions flag bypasses all checks. Agent runs freely.

Supervised

Uses --permission-mode=bypassPermissions with hooks:

  • PreToolUse hook: Matches all tools except safe read-only ones (Glob, Grep, NotebookRead, Read, Task, TodoWrite). Responds with permissionDecision: "ask" which triggers a can_use_tool flow, forwarding to the frontend for user approval.
  • Stop hook: Checks for uncommitted changes before agent exit.
  • Non-matching tools (read-only) are implicitly auto-approved by Claude Code.

Plan

Uses --permission-mode=bypassPermissions with hooks:

  • PreToolUse hook (ExitPlanMode): Requires approval to exit plan mode.
  • PreToolUse hook (everything else): Auto-approved.
  • Stop hook: Same as supervised.

Result Message

The result message signals turn completion and contains aggregate statistics:

{
  "type": "result",
  "subtype": "success",
  "cost_usd": 0.0234,
  "duration_ms": 15234,
  "duration_api_ms": 12100,
  "is_error": false,
  "num_turns": 3,
  "total_input_tokens": 45000,
  "total_output_tokens": 2300,
  "model_usage": {
    "claude-sonnet-4-5": { "context_window": 200000 }
  },
  "result": {
    "text": "I've implemented the changes...",
    "session_id": "sess_abc123"
  }
}

On result, the adapter:

  1. Auto-completes any pending tool calls that never received a tool_result
  2. Updates context window size from model_usage if available
  3. Emits result text only if no streaming text was sent this turn
  4. Commits the pending assistant UUID as lastMessageUUID
  5. Emits complete event with cost/token/duration stats
  6. Signals completion to unblock the Prompt() goroutine
  7. Emits error event if is_error: true

Session Resume

Resume by Session ID

--resume <session-id>

Resumes the conversation from the last message. The session ID is stored in the executors_running table as the resume token.

Resume at Message UUID

--resume-session-at <message-uuid>

Truncates the conversation to the specified message UUID and resumes from there. Used for retry — when a turn fails, resume from the last known-good message instead of replaying the failed context.

The lastMessageUUID is:

  • Tracked from message uuid fields during the conversation
  • Committed on result messages (assistant UUIDs are pending until result confirms success)
  • Included in the complete event's data.last_message_uuid field
  • Persisted in the executors_running.last_message_uuid column

Tool Normalization

The normalizer converts Claude Code tool calls into protocol-agnostic NormalizedPayload structs. This allows the frontend to render tools uniformly regardless of which agent produced them.

Tool → Kind Mapping

Claude Code Tool NormalizedPayload Kind Frontend Renderer
Edit, Write, NotebookEdit modify_file tool_edit
Read read_file tool_read
Glob, Grep code_search tool_search
Bash shell_exec tool_execute
WebFetch, WebSearch http_request tool_call
Task subagent_task tool_call
TaskCreate create_task tool_call
TaskUpdate, TaskList, TodoWrite manage_todos todo
(unknown) generic tool_call

Normalization Flow

tool_use block (name, id, input)
  │
  ├── NormalizeToolCall(name, input) → NormalizedPayload
  │     stored in pendingToolCalls[id]
  │
  ├── emit tool_call event (status: "running", payload attached)
  │
  ... (tool executes) ...
  │
  ├── tool_result block (tool_use_id, content, is_error)
  │
  ├── NormalizeToolResult(payload, content) → enriches payload with output
  │
  └── emit tool_update event (status: "complete" or "error", payload attached)

NormalizedPayload Structure

A discriminated union — exactly one kind-specific field is populated:

NormalizedPayload
  ├── kind: ToolKind (read_file, modify_file, shell_exec, ...)
  ├── read_file:     { file_path, offset, limit, output: { content, line_count, truncated } }
  ├── modify_file:   { file_path, mutations: [{ type, content, old_content, new_content, diff }] }
  ├── shell_exec:    { command, work_dir, description, timeout, background, output: { exit_code, stdout, stderr } }
  ├── code_search:   { query, pattern, path, glob, output: { files, file_count, truncated } }
  ├── http_request:  { url, method, response, is_error }
  ├── subagent_task: { description, prompt, subagent_type }
  ├── create_task:   { title, description }
  ├── manage_todos:  { operation, items: [{ id, description, status }] }
  ├── generic:       { name, input, output }
  └── misc:          { label, details }

Agent Events

Events emitted through the updatesCh channel to the frontend via WebSocket:

Event Type When Key Fields
session_status First system message session_id, data.session_status ("new"/"resumed")
available_commands After initialize or system message available_commands[] (name, description)
message_chunk Assistant text or slash command output text
reasoning Thinking/chain-of-thought reasoning_text
tool_call Tool invocation starts tool_call_id, tool_name, tool_title, tool_status: "running", normalized
tool_update Tool result received tool_call_id, tool_status: "complete"/"error", normalized
context_window Token usage update context_window_size, context_window_used, context_window_remaining, context_efficiency
complete Turn finished data (cost_usd, duration_ms, num_turns, input/output_tokens, last_message_uuid)
error Turn failed error message
rate_limit API rate limited rate_limit_message
permission_cancelled Permission dialog cancelled pending_id

Subagent Nesting

When Claude Code uses the Task tool, it spawns subagents. Messages from subagents include parent_tool_use_id, which maps to the parent tool_call_id. The frontend uses this for visual nesting.


Context Window Tracking

Context usage is tracked from two sources:

  1. Assistant messages — usage field provides per-message token counts:

    context_used = input_tokens + output_tokens + cache_creation_input_tokens + cache_read_input_tokens
    
  2. Result messages — model_usage map provides the actual context window size per model:

    { "claude-sonnet-4-5": { "context_window": 200000 } }
    

The adapter tracks mainModelName (first model seen, excluding subagent models) and updates mainModelContextWindow from result stats. The default fallback is 200,000 tokens.


MCP Server Configuration

MCP servers are passed to Claude Code via the --mcp-config flag:

--mcp-config '{"mcpServers":{"my-server":{"command":"node","args":["server.js"]}}}'

Supported transports:

  • stdio: { "command": "...", "args": [...] }
  • SSE/HTTP: { "url": "...", "type": "sse" }

Configuration is built from cfg.McpServers in PrepareCommandArgs().


Image Attachments

Since stream-json doesn't support multimodal content blocks in prompts, image attachments are handled by:

  1. Saving base64-decoded images to .kandev/temp/images/ in the workspace
  2. Prepending file references to the prompt text
  3. Claude Code reads the images via its Read tool

Supported formats: PNG, JPEG, GIF, WebP.


Debug & Tracing

Enabled via KANDEV_DEBUG_AGENT_MESSAGES=true.

Debug Logging

Writes JSONL files in the working directory:

  • raw-streamjson-<agentId>.jsonl — raw protocol messages as received
  • normalized-streamjson-<agentId>.jsonl — normalized AgentEvents

OTel Tracing

Requires OTEL_EXPORTER_OTLP_ENDPOINT in addition to the debug flag.

Span hierarchy:

streamjson.prompt (parent span, covers entire turn)
  ├── streamjson.system           (session init)
  ├── streamjson.message_chunk    (streaming text)
  ├── streamjson.reasoning        (thinking)
  ├── streamjson.tool_call        (tool invocation)
  ├── streamjson.tool_update      (tool result)
  ├── streamjson.context_window   (token usage)
  ├── streamjson.control.control_request.can_use_tool   (incoming permission request)
  ├── streamjson.control.permission_response            (outgoing permission response)
  ├── streamjson.control.hook_response.pre_tool_use     (outgoing hook response)
  └── streamjson.complete         (turn completion)

Each span carries raw and normalized events for side-by-side comparison in Jaeger/Tempo.


Key Source Files

File Package Purpose
pkg/claudecode/types.go claudecode Protocol message types and constants
pkg/claudecode/client.go claudecode stdin/stdout client, read loop, control requests
streamjson/adapter.go streamjson Main adapter state, message dispatch, sendUpdate
streamjson/streamjson_init.go streamjson Initialize, hook building, MCP config
streamjson/streamjson_session.go streamjson Session create/resume
streamjson/streamjson_prompt.go streamjson Prompt send, wait, cancel, image attachments
streamjson/streamjson_messages.go streamjson System, assistant, user, rate-limit handlers
streamjson/streamjson_result.go streamjson Result processing, text dedup, completion signaling
streamjson/streamjson_permissions.go streamjson Permissions, hooks, timeout, cancel
streamjson/normalize.go streamjson Tool call/result → NormalizedPayload
streams/tool_payload.go streams NormalizedPayload discriminated union
streams/agent.go streams AgentEvent types and structure
agents/claude_code.go agents Agent definition, CLI flags, models