Hook event 18 of 33

TaskCompleted

Runs when a task is about to be marked complete, either through TaskUpdate or because a teammate ends a turn with in-progress tasks.

Exit code veto
Exit 2 blocks. JSON continue:false only stops a teammate when its own turn completion triggered the event; a TaskUpdate-triggered event ignores that field.
Matchers
none
if filter
Does not apply
Docs checked
2026-08-29

Two roads to complete

A task gets marked complete two ways, and your hook fires before either one applies. Claude calls TaskUpdate, or a teammate ends its turn with tasks still in progress and Claude Code completes them on the teammate’s behalf. TaskCompleted fires for both. There is no matcher. The payload carries permission_mode, task_id, task_subject, an optional task_description, an optional teammate_name, and a deprecated optional team_name. Nothing in it tells you which of the two roads fired the event.

Exit 2 blocks on either road, the JSON on one

Exit 2 prevents completion and feeds stderr back to the model. That holds for both triggers. Exit 0 and exit 1 mark the task complete unless JSON that applies stops the teammate.

The JSON is the shared continue: false with stopReason, and it stops a teammate entirely, with the reason shown to you, when the teammate’s turn completion triggered the event. When a TaskUpdate call triggered it, the hooks reference says Claude Code ignores the field.

The mistake most people make first

You gate completion with continue: false because it is the field that stopped a teammate on TeammateIdle. It works when a teammate’s turn ends with the task open.

It does nothing when Claude calls TaskUpdate directly. The task is marked complete, nothing reports the ignored field, and the payload never told you which case you were in.

Exit 2 blocks on both triggers. Use it for the gate, and keep continue: false for the case where you mean to stop a teammate.