# Workflows

> A workflow runs a model-authored Python script that fans out subagents over deterministic control flow. Intermediate results stay in script variables and never re-enter the conversation — only the script's returned st…

A workflow runs a model-authored Python script that fans out subagents over
deterministic control flow. Intermediate results stay in script variables and
never re-enter the conversation — only the script's returned string does — so a
wide fan-out (a 40-file review, a multi-source audit) is distilled to one answer
instead of drowning the main context.

<!-- SOURCE: src/kin/harness/model_routes.py, src/kin/harness/tools/workflow.py, src/kin/harness/workflow_exec.py, src/kin/harness/nudges.py, src/kin/harness/session/, src/kin/harness/worktrees.py, src/kin/harness/sandbox.py, src/kin/harness/backends/base.py, src/kin/harness/commands.py, src/kin/harness/modes.py, src/kin/tui/commands_mixin.py, src/kin/tui/modals/nudges.py, src/kin/tui/workflows_modal.py, src/kin/tui/workflow_card.py -->

## Outcome

Use one visible, bounded run for repeated research or review, then bring only
the distilled result back into the main conversation.

## Fast path

1. Ask for a repeated read/analysis task with a clear reduction, such as
   reviewing every module and returning only actionable findings.
2. Confirm that a workflow is useful: there are several similar independent
   items, not one focused delegation.
3. Review the generated orchestration script when Kin asks.
4. Approve once; the card shows phases, parallel agents, and progress.
5. Expand the card for logs or child rows only when supervision requires it.
6. Read the single reduced result returned to the conversation.

[![Kin workflow card showing collect and distill phases, settled parallel agent rows, and a concise final output.](/docs-assets/kin/assets/screenshots/workflow_run.svg)](/docs-assets/kin/assets/screenshots/workflow_run.svg)

*A workflow keeps the fan-out visible while only the reduced answer re-enters
the main model context.*

## Why a workflow, not many `task` calls

The [`task`](/docs/kin/guide/subagents/) tool delegates one job and returns its summary into
context. For a loop or a wide fan-out that is two problems: the orchestration
logic lives in the model's head (brittle), and every child's prose lands back in
context (token volume). A workflow fixes both — the control flow is real Python,
and the reduction over the children's output happens in the script. This is why
it is scoped to **read / analysis fan-out** (review, audit, research): the win is
distilling volume, not mutating the workspace.

## DSL and execution reference

The remainder of this page is the script authoring and lifecycle reference.
You do not need its primitives to approve or supervise a normal workflow.

### The `workflow` tool

The model calls `workflow` with a `script` (Python defining `async def main()`)
and an optional one-line `goal`. The tool is **ASK-kind**: you see and approve
the generated script before it runs. That approval is the human-in-the-loop
review of model-authored code *and* the guard against a wide fan-out's surprise
token burn.

The script runs in a **closed namespace** (no imports, no file or `eval` access —
capability-by-absence, not a sandbox). `main()` **must return a string**; that
returned string is the only thing that re-enters the model's context.

A workflow script is *orchestration*, not general computation — if the model
needs to actually run Python (data crunching, quick experiments), that's the
OS-sandboxed [`run_code` interpreter](/docs/kin/guide/tools/#code-interpreter-python), a
separate subprocess with a real kernel-enforced boundary.

#### The seven primitives

Inside `main()` the script holds exactly these seven injected primitives:

| Primitive | What it does |
|-----------|--------------|
| `await agent(prompt, opts)` | Run one subagent to completion; returns its prose — or a validated `dict` when `opts` carries a `schema` (see [Structured output](#structured-output-agentschema)). `opts` is a dict that may set `label` (the agent's name in the transcript card — defaults to a truncation of the prompt), `agentType` (a profile), `route` (an operator-authorized [model route](/docs/kin/guide/models-and-providers/#model-routes)) or legacy `model` override (mutually exclusive), `schema` (a JSON Schema dict), `isolation: "worktree"` (run an EDIT agent in its own git worktree — see [Worktree isolation](#worktree-isolation-parallel-edit)), and `publish: "<name>"` (write the result to the run's artifact store and return a `{ref, summary}` handle instead — see [The artifact store](#the-artifact-store-publish-ref-handles)). |
| `await parallel([thunk, ...])` | Gather agent thunks at a barrier. A failed child maps to `None`; a cancellation propagates (Esc tears the whole run down). |
| `await pipeline(items, *stages)` | Run each item through the stage chain independently — **no barrier between stages** (the wall-clock win). A per-item stage error drops that item to `None`. |
| `publish(name, data)` | Write `data` to the run's artifact store (sync); returns the workspace-relative path. A dict/list is stored as JSON, anything else as text. |
| `ref(name)` | The path of an artifact this run already published (sync); an unpublished name is a clear error. |
| `phase(title)` | Telemetry (sync): mark a new phase. Updates the `/workflows` panel and emits a transcript breadcrumb. |
| `log(message)` | Telemetry (sync): append a log line (kept to the last ~200). |

Profile and default `workflow` assignments outrank a requested route. The
workflow model sees only routes whose `selectable_for` includes `workflow`,
plus their operator descriptions and vision state—never endpoints or keys.
Route identity is shown on each workflow agent row, and the resolved non-secret
route fingerprint participates in the resume cache key, so changing what a
route points at cannot reuse an old child result at the same structural call
path.

#### Example `main()`

```python
async def main():
    phase("collect")
    files = ["auth.py", "session.py", "tokens.py"]

    # Fan out a read-only review over each file, in parallel. `label` names
    # each agent's row in the transcript card (else it's a prompt truncation).
    reviews = await parallel([
        lambda f=f: agent(
            f"Review {f} for auth bugs. Return one finding per line, or 'none'.",
            {"agentType": "researcher", "label": f"review {f}"},
        )
        for f in files
    ])

    phase("distill")
    findings = [r for r in reviews if r and r.strip() and r.strip() != "none"]
    log(f"{len(findings)} files had findings")

    if not findings:
        return "No auth issues found across the reviewed files."
    return "Auth review:\n\n" + "\n\n".join(findings)
```

Ask each child for a delimited string (one finding per line) and reduce over it
in the script — or, for typed data, use a `schema` (below) and get a real `dict`.

### Structured output (`agent(schema=...)`)

Pass a JSON Schema as `opts["schema"]` and `agent()` returns a **validated
`dict`** instead of prose:

```python
async def main():
    schema = {
        "type": "object",
        "properties": {
            "severity": {"type": "string", "enum": ["low", "med", "high"]},
            "findings": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["severity", "findings"],
    }
    r = await agent(
        "Review auth.py for auth bugs.",
        {"schema": schema, "agentType": "researcher"},
    )
    # r is a dict — reduce over it with normal Python, no string parsing.
    if r["severity"] == "high":
        return "HIGH: " + "; ".join(r["findings"])
    return f"{r['severity']}: {len(r['findings'])} findings"
```

The dict lives in the script's variables and is reduced over in code — it never
re-enters the model's context on its own (only `main()`'s returned **string**
does, as always). The script can subscript it, branch on it, and aggregate
across many agents before returning one distilled string.

**How it works — multi-turn, then structure.** The subagent runs its normal
loop first (its real analysis work, with its tools and reasoning). When that loop
ends, the harness makes **one extra structuring call** that asks the child to
emit its final answer in the schema's shape, then validates the result with
`jsonschema`. A schema-violating result, or a child that can't produce
conformant output, surfaces as a clean workflow error the model can read and
retry — it is never silently coerced to a string.

**Built for the models kin actually runs.** kin's real traffic is non-Claude
serves (vLLM/Qwen, z.ai/GLM, MiniMax) over both wires, so structuring does **not**
use any Anthropic-only output mode. It uses a portable two-tier mechanism:

1. **Forced tool call** — a synthetic `structured_output` tool whose input
   schema *is* your schema, with `tool_choice` forced to it (Anthropic
   `{type:"tool"}` / OpenAI `{type:"function"}`). On vLLM a forced
   `tool_choice` is **grammar-constrained server-side on both wires**
   (probed on vLLM 0.23.0 — `research/probe_strict_decoding.py`), so the
   arguments are schema-valid by construction; the OpenAI-compat wire also
   ships a strict-normalized schema + `strict: true` so OpenAI-proper-class
   endpoints enforce too (see
   [Strict decoding](/docs/kin/guide/models-and-providers/#strict-decoding-schema-valid-tool-calls)).
   The `jsonschema` validation above still runs on the result — the server
   guarantee makes it near-never-fire; it doesn't replace it.
2. **Prompt-JSON fallback** — if a serve doesn't honor forced `tool_choice`, the
   child is re-asked for *only* a JSON object matching the schema; the first JSON
   object is leniently extracted (tolerating markdown fences / surrounding
   prose), validated, with one bounded retry. This floor works on **any** model.

### The artifact store (`publish` / `ref` / handles)

A wide fan-out often produces more than the orchestrator needs to *hold*: a
lane's full structured findings matter to the final writer, not to the control
flow. The **run-scoped artifact store** externalizes that data — files under
`<workdir>/.kin/artifacts/<run_id>/` — so the script (and follow-up subagents)
work with **paths and summaries** instead of payloads. This is the
lead-keeps-context pattern: the orchestrator reasons over a digest; only the
subagent that needs the full result reads it.

Two ways in:

- **`publish(name, data)`** writes an artifact and returns its
  **workspace-relative path** (e.g. `.kin/artifacts/<run>/draft-r1.md`). Embed
  that path in a later prompt and the subagent reads it with `read_file` —
  the prompt carries a pointer, not the payload. `ref(name)` looks the path up
  again later. Re-publishing a name overwrites (the revise-loop pattern).
- **`agent(prompt, {"schema": ..., "publish": "lane-1"})`** — the opt-in
  handle path. The subagent's result is published for you and the call returns
  `{"ref": <path>, "summary": <digest>}` instead of the raw result. The
  summary is the dict's own `summary` field when the schema has one (else a
  compact preview), capped at ~2,000 chars. Without `publish`, `agent()`
  returns the prose / validated dict directly, exactly as before.

```python
async def main():
    # Lanes publish full findings; the script holds only handles.
    handles = await parallel([
        lambda f=f, i=i: agent(
            f"Analyze {f}. `summary` = one paragraph; `evidence` = path:line facts.",
            {"schema": lane_schema, "publish": f"lane-{i + 1}", "agentType": "explorer"},
        )
        for i, f in enumerate(files)
    ])
    digest = "\n".join([
        f"- {h['summary']}\n  full findings: {h['ref']}"
        for h in handles if isinstance(h, dict)
    ])
    # The writer reads a lane's FULL artifact only when its summary isn't enough.
    return await agent(
        "Write the report from these lane digests; read a lane's artifact with "
        "read_file when you need the detail.\n\n" + digest,
        {"agentType": "explorer"},
    )
```

Semantics worth knowing:

- **Run-scoped, not a cross-session blackboard.** Each run gets its own
  directory; nothing is shared between runs or sessions. On
  [resume](#resume-within-run-by-cache), the cached **raw** result is
  re-published under the resuming run's own directory, so handles never point
  at a stale run.
- **Names are validated, strictly.** 1–64 chars of letters/digits/`._-`,
  starting with a letter or digit — no slashes, no `..`. A bad name is a clear
  workflow error (rejected, never silently rewritten): a name is a handle the
  script refs back by, so it must round-trip exactly.
- **Failures stay raw.** A crashed subagent's error prose is returned as-is —
  never published, never wrapped in a handle — so `isinstance(h, dict)` checks
  still catch it.
- **Doesn't mix with `isolation: "worktree"`.** A worktree agent runs in a
  fresh checkout that doesn't contain the run's artifacts — artifact refs are
  for the read/analysis fan-out on the shared workdir.

The bundled [`/revise`](#save-a-run-as-a-reusable-command) and `/research`
commands are the shipped consumers: `/revise` publishes each draft so graders
read it by ref; `/research` lanes return `{ref, summary}` handles the
synthesizer expands on demand.

### Worktree isolation (parallel edit)

The MVP workflow is for read/analysis fan-out — distilling volume, not mutating
the workspace — because many parallel agents editing the *one* working copy would
collide. `isolation: "worktree"` lifts that limit for **edit** agents: each runs
with its workdir set to its own fresh `git worktree`, so concurrent edit agents
touch **disjoint trees**.

```python
async def main():
    files = ["auth.py", "session.py", "tokens.py"]
    # Each agent edits in its OWN worktree — no collisions on the shared tree.
    results = await parallel([
        lambda f=f: agent(
            f"Add type hints to {f}. Edit the file in place, then commit.",
            {"isolation": "worktree", "agentType": "coder", "label": f"types {f}"},
        )
        for f in files
    ])
    return "Edited " + ", ".join(files)
```

How it works and what to expect:

- **Created from `HEAD`.** The worktree is `git worktree add -b kin-wf/<run>/<n>
  <tmp> HEAD` on a unique branch per agent (no shared-branch lock). It checks out
  **HEAD**, so an edit agent does **not** see your uncommitted working-tree
  changes — commit (or stash) first if the agents need them.
- **Clean → removed, dirty → kept for review.** When an agent finishes, a clean
  worktree (no changes) is removed (worktree + branch + temp dir). A **dirty** one
  is **kept**: its path and branch are appended to the agent's result *and*
  surfaced via `log()`, so you can inspect, diff, and merge the work. kin never
  silently discards an edit agent's changes.
- **Best in auto mode.** Edit *confinement* — edits running with no per-call
  prompt and writes kernel-confined to the worktree — is only enforced in
  [auto mode](/docs/kin/guide/auto-mode-and-sandbox/). Outside auto, the worktree still **isolates
  the tree**, but each edit goes through the normal approval gate (and isn't
  sandbox-confined); the workflow logs a one-line note saying so. Because N
  parallel edit agents would otherwise pile N approval modals onto the one prompt,
  running an `isolation: "worktree"` edit workflow in **auto** is the intended path.
- **Concurrency cap.** At most `WORKFLOW_MAX_PARALLEL` (6) worktrees exist at once
  — the worktree lifecycle lives inside the fan-out permit, so the parallel cap
  bounds the checkouts (and their index locks) too.
- **Non-git workdir → no isolation.** If the workspace isn't a git repo, the agent
  simply runs unisolated (the feature degrades, it doesn't error).

> **The common gitdir is writable under the sandbox**
>
> A linked worktree's git internals (its index/HEAD and the shared
> objects/refs) live *outside* the checkout, so in auto mode the OS sandbox is
> given a matching write-allow for those paths — otherwise a sandboxed
> `git add`/`commit` inside the worktree would fail. The code-execution surfaces
> (`hooks/`, `config`) stay denied, same as for a normal repo.

### Resume (within-run, by cache)

A killed or edited workflow can be **resumed**: pass a prior run's id as
`resume_from_run_id` and every completed `agent()` call replays from that run's
journal instead of re-dispatching a subagent — only new, changed, or unfinished
calls run live.

```python
# The model re-issues the workflow tool with the prior run's id; the harness
# hydrates the cache from that run's journal before main() runs.
workflow(script=<same or edited script>, resume_from_run_id="<the prior run id>")
```

When a run is interrupted (Esc) or errors with at least one completed agent, the
harness surfaces a system note naming the run id so the next attempt can replay
it. The card and `/workflows` panel mark replayed agents (`↺ cached`, `N replayed`)
so you can see at a glance what was reused versus run fresh.

How the cache decides hit vs miss:

- **Keyed by structural call path, not call order.** Each `agent()` result is
  keyed by *where in the script it ran* — a `parallel` branch index, a `pipeline`
  `item:stage` position (nesting composes), plus a per-position occurrence — **not**
  by a global "Nth call" counter. This is load-bearing for `pipeline`: its
  per-item stages finish in a *run-varying* order, so a sequence index would
  attach a cached result to the wrong call on resume. The structural path is
  stable across runs, so each cached result maps back to the exact call that
  produced it.
- **Content hash prevents stale hits.** The key also carries a hash of the
  inputs that determine the result — the prompt, `agentType`, `model`/`route`,
  the resolved route fingerprint, and `schema`. Edit any input—or redefine the
  named route—at the same position and the call **misses** (re-runs with the new
  inputs) instead of returning a stale wrong answer.
- **Cached agents must be side-effect-free.** Only read / analysis / `schema`
  agents are cached. An **editing** agent should use `isolation: "worktree"` —
  and a worktree (side-effecting) agent is **never** cached or journaled (a cache
  hit wouldn't reproduce its edits), so it re-runs on every resume. A subagent
  that crashed is also never cached (resume re-runs it, rather than replaying the
  failure).
- **Dict results round-trip.** A `schema` agent's validated `dict` is journaled
  and replayed as a real `dict` (the script still subscripts it on resume).

> **Within-run only**
>
> Resume-by-cache is a *within-run checkpoint*, not cross-session history.
> Cross-session resume is already automatic — a whole workflow serializes as a
> single `tool_use` + `tool_result`, so `--resume` brings back its result with
> the rest of the conversation. `resume_from_run_id` is the orthogonal "don't
> redo the expensive fan-out I already half-finished" lever. The journal lives
> beside the session journal (`<project_dir>/<run_id>.workflow.jsonl`).

### In the transcript

A running workflow renders as a single live **card**, not the flat stream of its
subagents' internal tool calls. The card shows the run and hides the tool noise:

```
⚙ workflow · summarize each harness file        ⠹ running · 9s

   phase  ✓ Scan  ›  ⠹ Summarize  ›  · Synthesize
   ◇ events.py — the event vocabulary            ✓
   ◇ session.py — lifecycle + subagent spawn     ✓
   ◇ loop.py — the agent loop                    ⠹
   ◇ subagents.py — profiles + caps              ·  queued
   log  loop.py: 3 sections summarized

   4 agents · 2 done · 1 running · 1 queued · 9s
```

- **The phase trail** shows done (`✓`), current (animated spinner), and
  **upcoming** phases — the upcoming titles are pre-parsed from the script's
  `phase("…")` literals, so the remaining steps read at a glance before they
  run (a dynamically-built title the parse missed simply joins the trail when
  its event arrives).
- **Each agent's row** carries a status glyph — `✓` done, spinner running,
  `·  queued`, `✗` error — coloured by the role palette (cyan for running,
  green for done, coral for error, muted for queued). A wide fan-out caps the
  visible rows at 8 (live rows always keep a slot) and folds the rest into an
  `… +N done` overflow line pointing at `/workflows`.
- **The `log` line** is the latest `log(…)` breadcrumb — a one-line narrator;
  the `/workflows` panel keeps the full history.
- **The header's right side** mirrors the selected spinner vibe and live
  elapsed time (`running · 9s` → `✓ N agents · M phases · {elapsed}s`,
  `⏸ interrupted · {elapsed}s`, or `✗ failed`). It is the transcript counterpart
  to the status-bar heartbeat and respects reduced motion, holding the style's
  static rest glyph when animations are off.
- **Clicking anywhere on a running card opens the `/workflows` panel** for the
  full detail.

When the workflow finishes, the card swaps to the distilled string `main()`
returned and folds the agent rows under a `▸ agents` expander:

```
⚙ workflow · summarize each harness file        ✓ 4 agents · 3 phases · 14s
   <the distilled string main() returned>
   ▸ agents
```

If you interrupt the foreground turn, the card settles immediately: its timer
stops, the current phase and unfinished agents use the neutral interrupted
state, and the body records that the workflow did not complete. Late results,
phases, agent updates, and already-queued child calls cannot turn that row back
into a live or successful card.

The `/workflows` panel below is the other view — a polling overlay you can open
mid-run (or click through to from the card) to see every run at full detail.

### Commands

| Command | Argument | What it does |
|---------|----------|--------------|
| `/workflow` | `<goal>` | Nudge the model to accomplish a goal *as* a workflow — it sends a turn instructing the model to write an `async def main()` orchestration and call the `workflow` tool. |
| `/workflow-save` | `<name>` | Save the most recent successful run's script as a reusable `/<name>` command (below). |
| `/workflows` | | Open the read-only runs panel — one row per run this session. |
| `/nudges` | | Choose session-scoped behavioral preferences (below). |

`/workflow <goal>` is a normal `kind: prompt` command: the body is sent as a user
turn with `$ARGUMENTS` substituted, so the model still has to author and (via the
ASK gate) get your approval for the script.

#### Save a run as a reusable `/command`

Once a workflow has run cleanly, `/workflow-save <name>` persists its script to
`<workdir>/.kin/commands/<name>.md` as a [`kind: workflow`](/docs/kin/guide/slash-commands/#kind-workflow-saved-workflow-scripts)
command. Re-invoking `/<name> <text>` re-runs that exact script — no re-authoring
by the model.

- **It still runs through the real `workflow` tool path.** Every invocation passes
  the same **ASK approval gate** (you review the script) and the same AST filter, so
  a saved workflow is **no more privileged** than its original run — it can't become
  a trust hole by being saved.
- **`<text>` reaches the script as the `args` string global**, not via
  `$ARGUMENTS` substitution. The text is injected as *data* (a `str` named `args`),
  never spliced into the script source — so it can't smuggle code past the structural
  filter. Read it like a variable: `files = args.split()`. It defaults to `""`.

kin ships five bundled examples out of the box. `/critique <target>`
(five-dimension review: correctness, edge cases, security, performance,
maintainability) and `/security-review <target>` (six-dimension security
audit with attack-path disclosure) both dispatch three parallel read-only
reviewer subagents over disjoint rubric subsets and synthesize findings by
severity.
[`/deep-research <question>`](/docs/kin/guide/deep-research/) runs the full **web**
research loop — plan, parallel researcher lanes, adversarial gap review, a
citation-liveness gate, and a cited report. `/research <question>` is its
**local** sibling: a general codebase/repo fan-out that scales effort to
the question's complexity (1 explorer lane for a simple lookup, up to 5
for a complex survey), gives each lane a clearly-scoped brief, and
synthesizes from `{ref, summary}` [artifact handles](#the-artifact-store-publish-ref-handles).
`/revise <task>` runs a rubric-graded revise loop — draft, three parallel
graders (accuracy / completeness / clarity, each reading the published
draft by ref), targeted revision, at most two bounded rounds. The scripts
are fixed, so every invocation dispatches the same subagents the same way
with no improvisation between runs.

```
/workflow review every file changed on this branch, one finding per line
…   (you approve the generated script; it runs; you like the result)
/workflow-save review-branch
…   (later, on another branch:)
/review-branch src/kin/harness/loop.py src/kin/harness/session.py
```

#### Session nudges

`/workflow <goal>` remains the direct request to author and run one workflow.
`/nudges` opens a multi-select modal for softer, session-wide behavioral
preferences. The first built-in nudge is `workflow-first`: it asks Kin to prefer
workflows for substantial staged, parallel, or iterative work while leaving
inline work available when it is the clearer fit. Apply replaces the complete
selection; Cancel or Escape changes nothing. Active nudges appear as display-only
status-bar chips.

Nudges change *propensity, not autonomy*. They never change effort, permissions,
tools, or guarantee that a workflow will be used. Guidance rides the per-turn
`<system-reminder>` channel on root turns, so it works on every wire/model without
changing the cached system prompt. The `workflow` tool remains **ASK-gated**.
Nudges are human-only, survive resume and compaction, and are inherited by a
session fork; unrelated new sessions start with none.

#### The `/workflows` panel

`/workflows` opens a polling overlay (the same shape as the ++ctrl+o++ agent
panel) that reads the live run state from the session once a second — clicking
a running transcript card opens it too. The top list shows one row per run:
the status (running / done / error / cancelled), the goal, the current phase,
the agents run so far (and how many are in flight), elapsed seconds, and the
phase count. Below it, a **detail pane** follows the highlighted run (++up++ /
++down++ pick one) with the full picture the transcript card caps: the whole
phase history, **every** agent row with its status, and the log tail. It is
**read-only** for now — there is no kill surface; ++escape++ closes it.
Pressing ++escape++ during the turn tears the whole run down.

### Caps and safety

The harness caps every run so a wide fan-out can't spiral:

| Limit | Value | Meaning |
|-------|-------|---------|
| Max parallel | 6 (`WORKFLOW_MAX_PARALLEL`) | Concurrent subagents in flight, sized on a **dedicated** host session's semaphore (the normal `task` budget is untouched) |
| Max agents | 64 (`WORKFLOW_MAX_AGENTS`) | Hard total-agent ceiling per run — the real runaway guard (the parallel cap bounds concurrency, not the aggregate) |
| Run timeout | 600s (`WORKFLOW_TIMEOUT`) | Cooperative wall-clock bound on the whole script |
| Stall watchdog | 3s (`WORKFLOW_STALL`) | Busy-loop / non-cooperative-script watchdog |

Security posture:

- **ASK approval** — you review the model-authored script before it runs.
- **Closed namespace** — no imports, no file/`eval` access; the script can only
  use the seven injected primitives (`publish`/`ref` reach exactly one
  strictly-validated directory under `.kin/artifacts/`, nothing else).
- **Read/analysis scope** — the MVP is for fan-out that distills information
  (review, audit, research), not workspace mutation.
- **No nesting** — a workflow cannot be called from inside a subagent (a
  depth-`>0` guard, and the tool is scoped out of subagent registries), so the
  per-run caps stay meaningful.

> **Note**
>
> Each subagent a workflow spawns still inherits the session's permission mode,
> so any destructive tool call it makes goes through the normal gate. The
> workflow's own approval covers the *script*, not a blanket grant for the
> children.
