workflowagentsizing_gate

August 20, 2026 · View on GitHub

What it blocks

A Workflow tool call whose inline script (or scriptPath file) contains an agent(...) call site with no model: key, or a model: value that isn't one of haiku/sonnet/opus, or a model: value that isn't a static string literal the hook can verify (a variable, expression, or template interpolation).

Why this shape is worth a gate

agent_sizing_gate.py (its sibling doc) closes this gap for the Agent tool, but the Workflow tool spawns subagents at runtime via its own agent(prompt, opts) calls inside the script body — those spawns never go through the Agent tool's PreToolUse hook at all, because the Workflow runtime drives them directly. A single Workflow script can spawn dozens of agents in one call; every one of them silently inherits the parent's model (often Opus, during an ultracode/effort session) unless sized explicitly. Without this hook, the entire workflow population is exempt from the sizing mandate the Agent-tool sibling enforces — the exact blind spot a batch of un-sized, expensive spawns would exploit invisibly.

BLOCKED

$ echo '{"tool_name":"Workflow","tool_input":{"script":"agent(\"do a thing\", {subagent_type: \"general-purpose\"});"}}' \
  | python3 workflow_agent_sizing_gate.py
BLOCKED: this Workflow has agent() call site(s) without an explicit `model`.
...
Un-sized / invalid agent() call site(s) (1 total):
  - line 1: agent("do a thing", {subagent_type: "general-purpose"})
$ echo $?
2

ALLOWED

The nearest legitimate thing: the identical call with model: "sonnet" added to the opts object:

$ echo '{"tool_name":"Workflow","tool_input":{"script":"agent(\"do a thing\", {subagent_type: \"general-purpose\", model: \"sonnet\"});"}}' \
  | python3 workflow_agent_sizing_gate.py
$ echo $?
0

Both commands above were run against this tree this session, along with three additional probes confirming the current (fixed) state described below: a bare // workflow-model-ok with no reason still blocks (rc == 2, with a distinct note naming which line carries the unreasoned marker); a recognized-looking but invalid value (model: "claude-3") blocks (rc == 2, naming the bad value); and the reasoned form, // workflow-model-ok: deliberate inherit from parent, allows (rc == 0).

The escape marker

// workflow-model-ok: <reason> on the same line as the agent() call. As of this release, the reason is required: ESCAPE_RE matches only // workflow-model-ok: <non-whitespace text>, and a separate BARE_ESCAPE_RE detects the no-reason form specifically so the block message can name it as a failed escape attempt ("carries // workflow-model-ok with no reason after the colon") rather than silently falling through to the generic missing-model message.

Scope

Universal — fires only for tool_name == "Workflow". No config file, no env vars. A named/builtin workflow reference (tool_input.name with no inline script/scriptPath) cannot be statically introspected and is allowed through unconditionally — the hook's own stance, stated in its source, is that the agent-sizing hygiene of a named workflow is its author's responsibility, not something this static parser can verify from the Workflow call site alone.

How we know it fires

test_workflow_agent_sizing_gate.py. Run this session:

$ python3 -m pytest .claude/hooks/test_workflow_agent_sizing_gate.py -q
...........                                                             [100%]
11 passed in 0.23s

All 11 pass, including the two _FINDING-suffixed tests (test_unrecognised_model_value_should_be_blocked_FINDING, test_bare_escape_without_reason_should_not_clear_block_FINDING) that were filed red against two real defects in this hook and are pinned in this doc's Known limits below.

Known limits

Until this release, this hook had two real defects, now fixed — worth knowing if you're running an older copy. The escape marker's regex used to be a bare presence check (r"//\s*workflow-model-ok\b") with no reason requirement — unlike every other marker in this pack, a plain // workflow-model-ok with no colon and no text cleared the block. And the hook used to check only whether a model: key existed, not whether its value was a recognized tier — agent(p, {model: "claude-3"}) sailed through as "sized" even though "claude-3" isn't one of haiku/sonnet/ opus. Both gaps are closed as of the current source (verified live above); if you're auditing a fork or an older release of this pack, check workflow_agent_sizing_gate.py's ESCAPE_RE and _find_agent_calls' status logic directly rather than trusting this description without re-verifying against the copy you actually have.

What the hook still cannot do, by design, not by oversight: it is a static parser over the script's text, not a JS interpreter. It cannot resolve agent(prompt, opts) where opts is a variable built elsewhere in the script, and it cannot resolve a model: value that isn't a plain quoted string literal (a variable, a member expression, a function call, a template literal with ${} interpolation). Both cases are treated as unverifiable and BLOCKED rather than trusted — a safe-default choice stated directly in the source, but it means a legitimately-computed model value (e.g. model: pickModel(taskSize)) will always need the // workflow-model-ok: escape, since the hook has no way to evaluate what pickModel returns.

The judgment this gate can't make

Same boundary as the Agent-side gate: this checks that a model: was named at each call site, not that it is the right one. The sizing discipline itself is in Agent sizing.