The cron row is green. The JSONL says the MCP tool finished. The stdout field is empty.
That is the most expensive sentence a headless coding agent can write. The next step is invented. The PR lands. The Laravel worker image still talks to last week’s schema. Nobody owns the rollback because the transcript never recorded a failure.
Anthropic’s Claude Code notes for 25 August 2026 (tag v2.1.246, published 2026-08-25T22:31:43Z) name the lie in one line: MCP tool calls interrupted by an incoming message in headless and remote sessions were reported to the model as “completed with no output” instead of an explicit interrupted error. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246]
The question is not whether a new tag demos well. It is whether your harness still treats empty completion as a close.

Empty completion is a contract, not a vibe
I run scheduled coding-agent work the same way I run a Laravel queue: the process is not the product. The product is a named side effect plus a proof artifact. A green cron exit is not a finished job already covers Unix exit 0 with stale work. This post is the sibling case that lives inside the agent turn: the tool result looks done, the payload is empty, and the model keeps writing.
Official Claude Code headless docs are blunt about process exit: claude -p exits 0 on success and non-zero when the run fails. Invalid flags go to stderr before the run starts. Failures inside the run, such as missing authentication, print as the result on stdout. [Source: https://code.claude.com/docs/en/headless]
That is a process contract. It is not a tool-result contract.
An interrupted MCP call that is rewritten as “completed with no output” satisfies the process contract. The parent cron still sees 0. The model still sees a finished tool. The only missing thing is the work.
The same tag also fixed a quieter cousin: a command interrupted mid-run showing as “Ran 1 shell command” with no sign it was cut. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246] If your reviewer only reads the summary line, both lies look identical.
What the 25 August notes actually changed
Do not turn this into a version table. Pin four neighboring behaviors from the same official body. They are the reasons a “tool finished” line is still not enough.
| Signal the model used to see | Official repair in the same tag | Harness rule |
|---|---|---|
| Interrupted MCP call → “completed with no output” | Explicit interrupted error | Fail the turn unless status=interrupted |
Empty-schema MCP args ({}) sent as JSON strings | Sent as their real type | Assert argument type, not typeof string |
Subagent hits maxTurns and looks finished | Result marked partial, hint to continue via SendMessage | Reject unmarked “done” at the turn cap |
| Background session fails closed after 45s | Open after deleted start dir, sleep, or slow host | Treat a missing session as fail-closed, not skip |
[Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246]
Two more lines from the same notes matter for CI:
- Non-interactive sessions (
-p, SDK, cloud) now automatically continue a response cut off mid-stream by a server error, connection loss, or stall, instead of ending with an error. - Telemetry and metrics requests to Anthropic no longer carry the API key configured for a third-party gateway (
ANTHROPIC_BASE_URL); a credential is sent only to its own host.
The first line is a resilience change. It is not permission to treat a later empty tool result as success. Automatic continue can hide a stall. The harness still has to name the last tool status.
The second line is a secret-routing change. It is not today’s fixture, but it is a reminder that “the vendor patched it” is not the same as “your wrapper stopped shipping the old header.”
Source Claude Code release notes — v2.1.246

Headless mode is where the lie used to hide
Interactive terminals have a human. Headless does not.
Claude Code’s current headless page is the source of record for scripted runs. The primary switch is -p / --print. Combine it with --allowedTools, --output-format, --mcp-config, --strict-mcp-config, and --max-turns. --bare skips auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md. Without --bare, a -p session loads the project’s .mcp.json with no workspace trust dialog and no per-server approval prompt. [Source: https://code.claude.com/docs/en/headless]
That last sentence is why a Laravel/Vue repo with a leftover .mcp.json is a production surface, not a toy.
Official notes on stop signals are equally specific. SIGTERM on a -p run exits 143. The in-progress turn is left unfinished and records no result. SIGINT, or the Agent SDK interrupt(), ends the turn instead. On SIGTERM, Claude Code kills the Bash process tree, runs SessionEnd hooks, and starts no new tool call. [Source: https://code.claude.com/docs/en/headless]
So you already have two different “stopped” shapes:
- OS stop: exit 143, no result recorded.
- In-turn interrupt of an MCP tool: used to look like a completed empty tool.
If your wrapper only checks the process code, case 2 never exists.
The same docs tell you how to fail CI when a plugin or MCP server does not load. From v2.1.221, -p with --mcp-config waits for still-pending servers before the first turn, up to MCP_TIMEOUT (default 30 seconds). A remote server with a cached tool list can show pending in system/init and connect on first tool call. From v2.1.219, skipped --mcp-config entries land in mcp_server_errors. If you redirect stderr, the startup warning is gone and that array is the only signal. [Source: https://code.claude.com/docs/en/headless]
A server that never loaded plus a model that still writes “I called the tool” is the same family as empty completion. Different layer. Same ownership.
Headless flags that belong in the fixture, not in a changelog tweet
-p/--print— non-interactive.--bare— no surprise MCP from the laptop profile.--mcp-config+--strict-mcp-config— only the servers you named.--output-format jsonorstream-json— machine-readable status.--max-turns— hard ceiling; pair with the partial-result rule.MCP_TIMEOUT— startup wait, not a tool-result timeout.
A fixture that refuses the empty-success story
I do not want another dashboard. I want a file the next engineer can run on Monday.
The fixture below is a classifier, not a Claude Code mock. It takes a captured tool event and decides whether the turn is allowed to continue. Put it in the repo that owns the cron. Pin the event shape your wrapper actually writes. Do not invent vendor-internal fields.
1from __future__ import annotations
2
3from dataclasses import dataclass
4from typing import Any, Literal
5
6
7Status = Literal["ok", "interrupted", "partial", "empty_unproven", "type_coercion"]
8
9
10@dataclass(frozen=True)
11class ToolEvent:
12 name: str
13 is_mcp: bool
14 reported_status: str
15 stdout: str
16 stderr: str
17 args: Any
18 schema: dict[str, Any] | None
19 hit_max_turns: bool
20 proof_path: str | None
21
22
23def classify_tool_event(event: ToolEvent) -> Status:
24 reported = event.reported_status.lower()
25 if reported in {"interrupted", "aborted", "cancel", "cancelled"}:
26 return "interrupted"
27 if event.hit_max_turns and reported in {"ok", "completed", "done", ""}:
28 return "partial"
29 if event.is_mcp and event.schema == {} and isinstance(event.args, str):
30 return "type_coercion"
31 empty = event.stdout.strip() == "" and event.stderr.strip() == ""
32 if empty and reported in {"ok", "completed", "done", ""}:
33 if event.proof_path:
34 return "ok"
35 return "empty_unproven"
36 return "ok"
37
38
39def allow_next_step(status: Status) -> bool:
40 return status == "ok"
The rule that matters is line 31: empty + “completed” without a proof path is not ok. A real empty success has to bring a side-effect receipt — a written file, a ticket id, a releaseURL, a database row count. That is the same artifact rule as background-agent recovery.
Wire the classifier into a pytest that names the interrupt case first.
1from pathlib import Path
2
3from harness_classify import ToolEvent, classify_tool_event
4
5
6def test_interrupted_mcp_is_never_empty_success():
7 event = ToolEvent(
8 name="mcp__tracker__create_ticket",
9 is_mcp=True,
10 reported_status="completed",
11 stdout="",
12 stderr="",
13 args={"title": "bump lockfile"},
14 schema={"type": "object"},
15 hit_max_turns=False,
16 proof_path=None,
17 )
18 assert classify_tool_event(event) == "empty_unproven"
19
20
21def test_explicit_interrupt_fails_closed():
22 event = ToolEvent(
23 name="mcp__tracker__create_ticket",
24 is_mcp=True,
25 reported_status="interrupted",
26 stdout="",
27 stderr="incoming message interrupted the tool",
28 args={"title": "bump lockfile"},
29 schema={"type": "object"},
30 hit_max_turns=False,
31 proof_path=None,
32 )
33 assert classify_tool_event(event) == "interrupted"
34
35
36def test_empty_schema_string_args_are_coercion():
37 event = ToolEvent(
38 name="mcp__notes__append",
39 is_mcp=True,
40 reported_status="ok",
41 stdout="wrote",
42 stderr="",
43 args='{"text":"hello"}',
44 schema={},
45 hit_max_turns=False,
46 proof_path=None,
47 )
48 assert classify_tool_event(event) == "type_coercion"
49
50
51def test_max_turns_done_is_partial():
52 event = ToolEvent(
53 name="explore",
54 is_mcp=False,
55 reported_status="done",
56 stdout="still reading files",
57 stderr="",
58 args={},
59 schema=None,
60 hit_max_turns=True,
61 proof_path=None,
62 )
63 assert classify_tool_event(event) == "partial"
64
65
66def test_proven_empty_stdout_can_pass(tmp_path: Path):
67 receipt = tmp_path / "ticket.json"
68 receipt.write_text('{"id":"T-19"}', encoding="utf-8")
69 event = ToolEvent(
70 name="mcp__tracker__create_ticket",
71 is_mcp=True,
72 reported_status="completed",
73 stdout="",
74 stderr="",
75 args={"title": "bump lockfile"},
76 schema={"type": "object"},
77 hit_max_turns=False,
78 proof_path=str(receipt),
79 )
80 assert classify_tool_event(event) == "ok"
If you only keep one test, keep the first. That is the screenshot a peer actually uses.

Wrap claude -p so CI cannot swallow the lie
A classifier without a wrapper is a blog comment. The wrapper has to fail the job.
Official JSON output from -p --output-format json includes a text result plus session metadata. Official docs also say a SIGTERM run records no result. [Source: https://code.claude.com/docs/en/headless] Your wrapper therefore treats a missing result the same way it treats empty_unproven.
1#!/usr/bin/env bash
2# scripts/run-headless-agent.sh
3set -euo pipefail
4
5PROMPT=${1:?usage: run-headless-agent.sh "<prompt>"}
6OUT=${AGENT_OUT:-/var/lib/agent-runs/last.json}
7MCP_CONFIG=${MCP_CONFIG:-./.ci/mcp.json}
8
9mkdir -p "$(dirname "$OUT")"
10
11set +e
12claude --bare -p "$PROMPT" \
13 --mcp-config "$MCP_CONFIG" \
14 --strict-mcp-config \
15 --output-format json \
16 --max-turns "${MAX_TURNS:-8}" \
17 --allowedTools "Read,Bash" \
18 >"$OUT"
19CODE=$?
20set -e
21
22if [[ "$CODE" -eq 143 ]]; then
23 echo "headless run received SIGTERM; turn unfinished" >&2
24 exit 1
25fi
26
27if [[ "$CODE" -ne 0 ]]; then
28 echo "headless process failed: $CODE" >&2
29 exit "$CODE"
30fi
31
32python3 scripts/assert_tool_events.py "$OUT"
assert_tool_events.py is the thin reader. It does not re-implement Claude Code. It walks whatever tool events your team already persists — JSONL transcript, stream-json lines, or a wrapper log — and calls classify_tool_event.
1import json
2import sys
3from pathlib import Path
4
5from harness_classify import ToolEvent, allow_next_step, classify_tool_event
6
7
8def load_events(path: Path) -> list[ToolEvent]:
9 raw = json.loads(path.read_text(encoding="utf-8"))
10 events = []
11 for item in raw.get("tool_events", []):
12 events.append(
13 ToolEvent(
14 name=item.get("name", ""),
15 is_mcp=bool(item.get("is_mcp")),
16 reported_status=str(item.get("status", "")),
17 stdout=str(item.get("stdout", "")),
18 stderr=str(item.get("stderr", "")),
19 args=item.get("args"),
20 schema=item.get("schema"),
21 hit_max_turns=bool(item.get("hit_max_turns")),
22 proof_path=item.get("proof_path"),
23 )
24 )
25 return events
26
27
28def main() -> int:
29 path = Path(sys.argv[1])
30 payload = json.loads(path.read_text(encoding="utf-8"))
31 if not payload.get("result"):
32 print("missing result field after a zero exit", file=sys.stderr)
33 return 2
34 events = load_events(path)
35 if not events:
36 print("zero tool events with a completion claim", file=sys.stderr)
37 return 2
38 for event in events:
39 status = classify_tool_event(event)
40 if not allow_next_step(status):
41 print(f"{event.name}: {status}", file=sys.stderr)
42 return 3
43 return 0
44
45
46if __name__ == "__main__":
47 raise SystemExit(main())
If your current claude -p JSON blob does not yet include tool_events, stop pretending the vendor schema is the harness. Write the events yourself from stream-json as the run proceeds. Official stream mode emits newline-delimited events and ends with a result message. [Source: https://code.claude.com/docs/en/headless] Persist each tool_use / tool_result pair. Then classify.
This is the same pattern we already use for Postiz: a success: true create is not a published post until /posts readback names the state. QUEUE after a matching create is async lag. Empty completion after an interrupt is not lag. It is a wrong status.
Neighboring lies in the same family
The interrupt fix is one member of a set. Treat the others as the same Monday checklist, not as extra news.
1. Empty schema, stringified args. The official notes say MCP arguments were sent as JSON strings when the parameter schema was {}. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246] A server that does int(args["limit"]) then throws, or worse, concatenates the quotes into a query. Your fixture already returns type_coercion. Keep a golden request log.
2. maxTurns that looks finished. A subagent that stops at its turn cap now returns output marked partial, with a hint to continue via SendMessage. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246] If your orchestrator still promotes “the child said done” into a merge, you will ship a half-read of the repo. Official --max-turns on -p is a hard ceiling with no default. [Source: https://code.claude.com/docs/en/cli-reference]
3. Bash allow rules that match options. A startup warning now fires for rules with a wildcard before the subcommand, such as Bash(git * main), because they also match options inserted before the subcommand. [Source: https://github.com/anthropics/claude-code/releases/tag/v2.1.246] This is permission drift, not an MCP interrupt, but it is the same class of “the policy text is not the policy.” Official headless docs already warn that Bash(git diff *) needs the space before *; without it, git diff-index matches. [Source: https://code.claude.com/docs/en/headless]
4. Historical silent -p + HTTP MCP. GitHub issue #32191 (opened 8 March 2026) reports claude -p with an HTTP MCP server exiting 0 with no stdout and no stderr, while stdio MCP worked. [Source: https://github.com/anthropics/claude-code/issues/32191] That issue is older than this week’s tag. Do not claim 2.1.246 closed it. Do claim this: exit 0 plus empty output is a known failure shape on this CLI, and your wrapper already has to reject it.

Who owns rollback when the model already wrote the next file
A harness that only prints “interrupted” still loses if the model already edited the tree.
Give the turn a lease:
- Worktree or branch name owned by the job id.
- Proof directory that must contain either a receipt or an interrupt marker.
- Named human who can revert the branch if the marker is interrupt and the diff is non-empty.
That is change control. It is the same ownership sentence as a lockfile bump: the bump is not done when npm prints a version. It is done when a person can roll it back. Strategy this week banned version-pin posts for a reason. The useful artifact is the owner, not the tag.
On the agent-ops hub I keep this as a one-line policy: AI agent operations is a verification problem first. Model quality is downstream.
1job: 2026-08-26-headless-ticket
2branch: agent/2026-08-26-headless-ticket
3owner: oncall-backend
4proof:
5 - required: proofs/2026-08-26-headless-ticket.json
6 - on_interrupt: revert branch, keep proof
7 - on_empty_unproven: revert branch, page owner
If that YAML feels heavy, write three lines in the cron comment. The name is the point.
What you should do Monday morning
- Capture one real headless transcript from a job that uses MCP. If you have none, run
claude --bare -pagainst a dummy MCP that sleeps, then send SIGINT. Keep the file. - Add
classify_tool_eventand the five tests to the repo that owns the cron, not to a gist. - Fail CI on
empty_unproven,interrupted,partial, andtype_coercionunless a human-signed override file exists for that job id. - Require a proof path for any MCP tool whose success is a side effect (ticket, deploy, Postiz post, database write).
- Name the rollback owner on the job record. If the model wrote files after an interrupt, revert the branch before the next prompt.
- Re-read the official v2.1.246 body before you tell the team “we already patched.” Your wrapper version and the CLI version are two pins.
- Link the fixture from
/developer-tools/so the next hire does not rediscover empty completion as a personality quirk of the model.
Further reading
Source Claude Code v2.1.246 release notes — interrupted MCP, partial maxTurns, argument types
Source Claude Code docs — run programmatically with -p, SIGTERM 143, MCP startup fields
Source GitHub issue 32191 — claude -p + HTTP MCP silent exit 0
Related on this site: A green cron exit is not a finished job, zombie detection, background-agent recovery CLI, Start here.
