Skip to content

RFC: claude-code task status via lifecycle hooks instead of system-prompt + MCP #867

@morganl-ant

Description

@morganl-ant

Problem

The claude-code module gets task status into the Coder dashboard by asking the model to produce it. When report_tasks = true (the default), three things happen:

  1. A ~25-line system prompt is injected via CODER_MCP_CLAUDE_SYSTEM_PROMPT instructing Claude to call coder_report_task(state, summary) on every state change.
  2. coder exp mcp configure claude-code writes the same prompt plus the admin's system_prompt into ~/.claude/CLAUDE.md, so it appears twice in context.
  3. The coder MCP server is registered with the full toolsdk surface (~25 tools: CreateWorkspace, DeleteTemplate, UploadTarFile, etc.). AllowedTools filters which can be called, but all ~25 schemas are still rendered into the system prompt.

AIGOV-93 reports the cost on a stock dogfood template: /context shows 61k/200k tokens consumed before any user input, a pwd task burns ~68k input tokens, and a 69-line code change costs ~2.5M tokens in ~7 minutes. Conservatively 12k to 20k of the per-turn overhead is the status-reporting machinery alone.

The signal is also non-deterministic. Status only updates if the model remembers to call the tool. When it forgets, the Tasks UI shows "working..." forever; when it over-reports, every micro-step becomes a tool round-trip.

#861 removes report_tasks and the system-prompt injection from this module, which is the right direction. This RFC proposes what should replace it.

Proposal

Claude Code already emits the lifecycle signals Tasks needs, deterministically, with zero context cost:

CC hook event Fires when Maps to Tasks state
SessionStart session begins working (initial)
Stop agent finishes a turn complete (or failure if last result has is_error)
SubagentStop subagent finishes (heartbeat)
Notification agent needs user attention (permission prompt, idle) failure (per current semantics: "needs user input")

Hooks support a type: "http" handler that POSTs the hook payload as JSON to a local URL with no shell spawn. The design:

**In whatever module owns Tasks orchestration post-**#861 (or in this module if #861 does not land):

locals {
  status_hooks = jsonencode({
    hooks = {
      SessionStart = [{ hooks = [{ type = "http", url = "http://127.0.0.1:${var.agentapi_port}/internal/hook", headers = { "X-Coder-App-Slug" = local.app_slug } }] }]
      Stop         = [{ hooks = [{ type = "http", url = "http://127.0.0.1:${var.agentapi_port}/internal/hook", headers = { "X-Coder-App-Slug" = local.app_slug } }] }]
      SubagentStop = [{ hooks = [{ type = "http", url = "http://127.0.0.1:${var.agentapi_port}/internal/hook", headers = { "X-Coder-App-Slug" = local.app_slug } }] }]
      Notification = [{ hooks = [{ type = "http", url = "http://127.0.0.1:${var.agentapi_port}/internal/hook", headers = { "X-Coder-App-Slug" = local.app_slug } }] }]
    }
  })
}

Written to /etc/claude-code/managed-settings.d/10-coder-status.json at install time (composes with #863, the managed_settings PR).

In coder/agentapi: add POST /internal/hook (loopback-only) that maps the hook payload to the existing app-status API. AgentAPI already holds CODER_AGENT_TOKEN and already forwards coder_report_task MCP calls to the same sink, so this is a sibling route, not new plumbing.

In coder/coder (cli/exp_mcp.go):

  • Add --no-claude-md flag to exp mcp configure claude-code so it stops appending to ~/.claude/CLAUDE.md.
  • Add --tool <name> flag to exp mcp server; default to report_task only when CODER_MCP_APP_STATUS_SLUG is set. This drops the in-task MCP context cost from ~15k tokens to ~150 and closes a minor lateral-movement surface (the in-task agent can currently reach CreateWorkspace/DeleteTemplate with the owner's identity via other MCP clients).

working heartbeat: until #705 (agentapi v3 / SDK mode) lands, keep coder_report_task available as the single MCP tool so the model can post intermediate progress. Once AgentAPI consumes --output-format stream-json, derive working from assistant text blocks directly and the MCP tool becomes optional.

Token delta

Source Today (report_tasks=true) Hooks-based
report_tasks prompt in <system> ~230 0
Same prompt duplicated in CLAUDE.md ~230 0
Coder MCP toolsdk schemas (~25 tools) ~12k to 20k ~150 (report_task only)
Per-step tool_use round-trip ~80 × N 0
Total ~12.5k to 20.5k + 80N ~150

Migration

Ship behind report_tasks_mode = "hooks" | "legacy" (default legacy in the first release, flip to hooks after 2 to 4 weeks of opt-in). legacy is byte-identical to v4.x for rollback.

Dependencies

  • coder/coder: exp mcp configure --no-claude-md, exp mcp server --tool with in-task default
  • coder/agentapi: POST /internal/hook receiver
  • This repo: managed_settings plumbing (#863) so the hook config has somewhere to land

Open questions

  1. Does the Stop hook payload include the final assistant message text directly, or only transcript_path? If only the path, AgentAPI tails the JSONL for the summary. (I can confirm this on the Anthropic side and update.)
  2. chore(claude-code)!: strip boundary, agentapi, tasks, tools #861 pushes "starting Claude" to the caller. Where should the hooks drop-in be written from: this module's install (so it's present regardless of who starts Claude), or a new claude-code-tasks companion module?

References: AIGOV-93, #284, #861, #705.


Disclosure: I work at Anthropic on the Claude Code team. Filing this as an RFC rather than a PR because #861 *removes the surface a PR would patch; happy to turn it into code once the post-*#861 module shape is settled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions