--- name: thread description: "Use when working with the Cortex thread system — understanding thread architecture, writing or modifying thread config (the per-entity JSON files under config/thread-templates/ — agents, templates, shells; plus transitions and hooks fields within templates), debugging thread execution, or when the user asks about multi-agent pipelines, agent orchestration, or the !thread command. Also trigger when modifying prompts/directives/, prompts/systemPrompts/, or prompts/promptTemplates/ files that feed into threads." allowed-tools: Read, Write, Edit, Bash, Grep, Glob metadata: author: "Cortex" version: "1.0.1" date: "2026-07-28" --- # Thread System You are Cortex, working with the Thread multi-agent orchestration system. ## Arguments $ARGUMENTS --- ## What Is a Thread? A Thread is a **multi-agent orchestration work unit** — a managed pipeline that coordinates one or more Claude Code agent instances. Each thread has: - A unique `ThreadId` (`thr_XXXXX`) - A file-system workspace at `{REPO_ROOT}/tmp/threads/thr_XXXX/` - A shared `artifact.md` file through which agents communicate - A lifecycle with states: `running` → `completed` | `failed` | `cancelled` | `aborted` - Cost tracking and step history Threads can be **template-based** (following a predefined multi-agent pipeline) or **ad-hoc** (dynamically assembled by adding agents one at a time). ### Architecture Layer Diagram ``` Slack message / Scheduler / Task Dispatch │ message-router.ts / scheduled-runner.ts │ thread-manager.ts (orchestration: create, step, transition, prompt assembly) │ thread-runner.ts (execution loop: run agents, hooks, Slack output) │ runAgent() → claude-bridge.ts → Claude Code CLI process │ VirtualSlackMessage → Slack ``` ### Thread Lifecycle States | State | Meaning | Terminal? | |-------|---------|-----------| | `running` | An agent step is in progress | No | | `waiting` | Paused, waiting for user input | No | | `completed` | All steps done | Yes | | `failed` | Unrecoverable error | Yes | | `cancelled` | User cancelled via `!thread cancel` | Yes | | `aborted` | Agent self-aborted via the `thread_abort` tool | Yes | --- ## Control Plane (abort / split / wait) — DR-0015 Thread control is **out-of-band**: an agent signals its own thread by calling explicit MCP tools, NOT by writing markers into the artifact. The artifact is plain prose — text that merely mentions `[ABORT]` / `[SPLIT]` / `[WAIT_CHILDREN]` (e.g. "No [ABORT]", or a plan that says "if X then abort") triggers **nothing**. This eliminated the false-positive class behind the 2026-06-13 double-abort incident (DR-0015 problem 1). ### The three control tools (self-control of the caller's own thread) All three target `CORTEX_THREAD_ID` (the thread you are running inside) — you never pass a thread id. Calling outside a thread is an error. After calling, end your step; the runner consumes the signal at the next step boundary. - `thread_abort({ kind, diagnosis })` — terminate the thread early (terminal state `aborted`, distinct from `failed`). `kind ∈ {too-big, mis-scoped, blocked-external}`; `diagnosis` is required and becomes `thread.abortReason` (and, for a dispatch task, the task block reason). `too-big` / `mis-scoped` escalate to a re-planning manager; `blocked-external` escalates to a human. - `thread_split({ subtasks })` — propose a decomposition of a dispatch task instead of doing it: the task is decomposed keep-parent (your task becomes the join node), unclaimed, and the children flow through the dispatch queue. `subtasks` is a typed array (decomposeTask shape). - `thread_wait({ on_tasks?, on_threads? })` — suspend the thread until its awaited children finish; you are re-entered with their results once ALL are terminal. ### How Agents Know About the Control Plane The protocol is **auto-injected** by the thread system — you do **not** need to add anything to an agent's directive. `buildStepPrompt` prepends a short `THREAD_PROTOCOL_PREAMBLE` block (defined in `agent-server/src/domain/threads/prompt-builder.ts`) to every step prompt of any thread that owns a workspace artifact. The preamble is skipped for auto-record / default / direct paths (no artifact) and on `--resume` of a persistent session (already delivered). Adding a new artifact-writing agent (a new file under `config/thread-templates/agents/`) automatically gets the control tools without editing its directive / promptTemplate / systemPrompt. ### When to Abort Call `thread_abort` when: - **Upstream is missing or malformed** — required inputs are absent or corrupt and cannot be produced by retrying. - **Hardware / external resource unavailable** (`kind: blocked-external`) — target machine offline, GPU exhausted, external API permanently unreachable. - **Specification unresolvable** (`kind: mis-scoped`) — the requirements contain a contradiction that cannot be decided autonomously. - **Task is far bigger than described** (`kind: too-big`) — multiple independent units crammed together (prefer `thread_split` if you can already name the children). Do **not** abort for: normal retry situations (use the `[REVISED]` convergence convention and let the reviewer loop handle it), minor warnings, or disagreements with the plan that can be recorded as feedback in the artifact. ### Execution Semantics When the runner reads `metadata.pendingControl` after a step completes: 1. `abort` → thread status `aborted`, `abortReason` recorded; for a dispatch thread the owning task is blocked BEFORE the `onEnd` hook runs (DR-0015 problem 2). No further steps. `onEnd` still fires; `finalizeThread` flushes the artifact. 2. `split` → the runner breaks the loop leaving the signal in place; the dispatch path consumes `pendingControl.subtasks` and decomposes keep-parent. 3. `wait` → if live children remain the thread enters `waiting`; otherwise it continues. The signal is cleared after consumption so an intent fires exactly once; the webhook rejects a second concurrent control on the same thread. ### Comparison with Other Terminators | Mechanism | Who triggers it | Effect | |-----------|-----------------|--------| | `[APPROVED]` / `[IMPL-APPROVED]` | Reviewer agent (artifact text) | Convergence marker — reviewer approves, transition to next agent | | `[REVISED]` | Doer agent (artifact text, after retry) | Signals retry complete; `output_contains` starts the second review | | `thread_abort` tool | Any agent | Global abort — thread enters `aborted` terminal state, overrides all transitions | | `!thread cancel` | User (Slack command) | Thread enters `cancelled` terminal state | (`[APPROVED]` / `[REVISED]` remain in-band transition markers matched against agent output — they are evaluated by template `transitions`, a separate mechanism from the control plane.) --- ## Configuration File: thread-templates/ Location: `~/.cortex/config/thread-templates/` (a **directory**, one JSON file per entity). Config is directory-based (DR-0017 D6 Phase 2.5): entities live one-file-each under three subdirectories, and **the filename without `.json` is the entity name**: ``` ~/.cortex/config/thread-templates/ ├── agents/.json # one agent definition per file ├── templates/.json # one pipeline template (or shell binding) per file └── shells/.json # one shell definition (parameterized transition graph) per file ``` - **Loading priority:** the directory is used if it exists; otherwise the loader falls back to the legacy single file `config/thread-templates.json`. Shell bindings only resolve in the directory form (shells live under `shells/`). - **One-time migration:** on startup a legacy single file (with no directory yet) is split into per-entity files and the original is renamed `thread-templates.json.migrated-bak` (preserved, never deleted; idempotent). - **Defaults merge:** shipped defaults are a directory too, merged **per-file copy-if-missing** (never overwrites your files) so new default agents/templates/shells reach existing installs. - **Hot-reload:** each entity subdirectory (`agents/`, `templates/`, `shells/`) and the `prompts/` directory are watched via `fs.watch` (300ms debounce); reload is fail-soft (a malformed file is skipped, not the whole table). - **Editing:** hand-edit the JSON, or use the desktop **Settings → Thread templates** editor, which validates before writing. Validation lives in `domain/threads/template-validate.ts` and the write path in `template-writer.ts`; errors block a save, warnings do not. Saving an agent re-validates every template that uses it — because the executor re-reads transitions and prompts on *every* step, a bad edit does not fail at launch, it stalls a live thread mid-flight. The editor also refuses to delete an entity that other templates still reference, and rejects a save made against a stale body hash. There is no rename (the filename is the identity) — duplicate, then delete. The two core entity kinds are `agents` (independent agent definitions) and `templates` (multi-agent pipelines, either full templates or shell bindings); `shells` are parameterized transition graphs the templates reuse. ### Agents Section Each agent is an independent definition with its own identity, profile, and prompt configuration. Agents are referenced by templates but can also be used standalone via `!thread `. ```json { "agents": { "my-agent": { "name": "my-agent", "description": "Human-readable description", "profile": "execute", "persistSession": false, "directive": "You are a specialized agent for...", "promptTemplate": "{{input}}\n\nWrite your output to {{artifactPath}}.", "tools": "Agent,Bash,Edit,Glob,Grep,Read,Write" } } } ``` #### Agent Fields Reference | Field | Required | Type | Description | |-------|----------|------|-------------| | `name` | Yes | string | Agent ID, must match the key in the agents map | | `description` | No | string | Human-readable label shown in `!thread agents` | | `profile` | Yes | string | Profile from `profiles.json`, or `"__active__"` for current runtime profile | | `persistSession` | Yes | boolean | `true`: reuse Claude Code session across loop iterations. `false`: fresh session each time | | `directive` | No | string | Role/identity text prepended to the prompt. Supports `file:filename.md` to load from `prompts/directives/` | | `systemPrompt` | No | string | Full system prompt override via `--system-prompt` flag. Supports `file:filename.md` to load from `prompts/systemPrompts/` | | `promptTemplate` | Yes | string | Handlebars-style template with variables. Supports `file:filename.md` to load from `prompts/promptTemplates/` | | `claudeAgent` | No | string | Claude Code agent name, loaded via `--agent` flag from `.claude/agents/` | | `outputStyle` | No | string | Injected via Claude CLI `--settings '{"outputStyle":""}'` | | `tools` | No | string | Comma-separated tool list for `--tools` flag. Overrides the default tool set | | `pluginDirs` | No | string[] | Plugin directories passed via `--plugin-dir` flags | #### persistSession Explained - `true` — The Claude Code process stays alive between loop iterations. The agent retains full conversation history. Use for agents that iterate on feedback (planner revising after review, writer revising after critique). - `false` — A fresh Claude Code session is spawned each time the agent runs. Use for stateless evaluators (reviewer, QA) where independence from prior context is important. #### file: Reference Mechanism Directive, systemPrompt, and promptTemplate values starting with `file:` are loaded from corresponding subdirectories under `prompts/`: ``` prompts/ ├── directives/ ← for directive: "file:director.md" ├── systemPrompts/ ← for systemPrompt: "file:web.md" └── promptTemplates/ ← for promptTemplate: "file:my-template.md" ``` Loaded files are processed through `template-resolver.ts`, which supports: - **YAML frontmatter** with `extends:` (template inheritance) and variable declarations - **`@fill(name)` / `@endfill`** blocks to fill template slots - **`@block(name)` / `@endblock`** for default content slots in base templates - **`${var}` / `${var:-default}`** variable interpolation - **`@if(var)` / `@if(!var)` / `@endif`** conditional sections - **`{{runtime_vars}}`** (e.g., `{{currentDateTime}}`) pass through untouched for runtime resolution ### Templates Section Templates define multi-agent pipelines by composing agents with transition rules. ```json { "templates": { "my-pipeline": { "name": "my-pipeline", "description": "Description of what this pipeline does", "agents": ["agent-a", {"ref": "agent-b", "promptTemplate": "override..."}], "transitions": [ {"from": "agent-a", "to": "agent-b", "condition": {"type": "always"}} ], "entryAgent": "agent-a", "maxTotalSteps": 6, "maxTotalCostUsd": 5.0 } } } ``` #### Template Fields Reference | Field | Required | Type | Description | |-------|----------|------|-------------| | `name` | Yes | string | Template ID, must match the key | | `description` | Yes | string | Human-readable description | | `agents` | Yes | TemplateAgentRef[] | Ordered list of agent references (string name or override object) | | `transitions` | Yes | TransitionRule[] | Rules governing agent-to-agent transitions | | `entryAgent` | Yes | string | Which agent starts the pipeline | | `maxTotalSteps` | Yes | number | Hard cap on total agent steps | | `maxTotalCostUsd` | No | number | Cost limit in USD. Pipeline stops when exceeded | | `hooks` | No | ThreadHooks | Lifecycle hooks (onStart, onTransition, onEnd) | #### Agent References in Templates Templates reference agents in two ways: **Simple reference** (string): Uses the agent definition as-is. ```json "agents": ["planner", "reviewer"] ``` **Override reference** (object): Overrides specific fields for this template only. ```json "agents": [ { "ref": "planner", "promptTemplate": "Custom template for this pipeline...", "persistSession": true }, "reviewer" ] ``` Override objects support: `ref` (required), `promptTemplate`, `directive`, `systemPrompt`, `persistSession`, `claudeAgent`, `outputStyle`, `tools`, `pluginDirs`. ### Shell Templates (shells/ + shell bindings) When several pipelines share the same transition graph and differ only in which agents fill the roles, define the graph once as a **shell** (pure JSON in `shells/.json`) and reference it from `templates/` with a small **shell binding**. The shell declares `params` and uses placeholders in its graph: - `{param}` → the binding's value for that parameter (an agent name). - `{param.entryStage}` → that agent's `entryStage`, resolved from the agents map. Shell definition (`shells/worker-review.json` — the shipped generic produce-then-audit loop): ```json { "params": ["worker", "reviewer"], "agents": ["{worker}", "{reviewer}"], "transitions": [ { "from": "{worker}:{worker.entryStage}", "to": "{reviewer}", "condition": { "type": "always" } }, { "from": "{reviewer}", "to": "{worker}:retry", "condition": { "type": "convergence", "marker": "[APPROVED]", "maxIterations": 1 } }, { "from": "{worker}:retry", "to": "{reviewer}", "condition": { "type": "output_contains", "pattern": "\\[REVISED\\]" } } ], "entryAgent": "{worker}", "entryStage": "{worker.entryStage}", "maxTotalSteps": 4 } ``` Shell binding (`templates/doc-review.json`) — a template that just names the shell and its params: ```json { "shell": "worker-review", "worker": "doc-writer", "reviewer": "doc-reviewer", "description": "Generic produce-then-audit for documents" } ``` At load, the loader interpolates the placeholders and validates the result into a full template (here: `doc-writer` at its entry stage → `doc-reviewer` → `doc-writer:retry` until `[APPROVED]`). A validation failure (missing param, unknown placeholder, agent not found, agent missing `entryStage`, or a transition endpoint naming a stage the agent lacks) fails that one template with a logged error; an unknown shell name is fail-soft skipped — the rest of the config still loads. A binding may also set `maxTotalSteps` to override the shell's default budget. --- ## Prompt Template Variables The `promptTemplate` field supports these variables, resolved at step execution time: | Variable | Description | |----------|-------------| | `{{input}}` | The original user message that started the thread | | `{{artifactPath}}` | Absolute path to the workspace's `artifact.md` file | | `{{previousOutput}}` | The full output from the last completed agent step | | `{{modifiedFiles}}` | Bullet list of files edited/written by the previous agent (from path-only session-activity logs) | | `{{currentDateTime}}` | Current timestamp in Asia/Shanghai timezone | Conditional blocks are supported: ``` {{#if previousOutput}} Review feedback: {{previousOutput}} Please revise based on the feedback. {{/if}} ``` Directive and systemPrompt fields also support `{{currentDateTime}}` and other system variables via `resolveSystemVars()`. --- ## Transition Rules Transitions define how agents hand off to each other. Each rule has `from`, `to`, and a `condition`. ### Condition Types | Type | Fields | Behavior | |------|--------|----------| | `always` | — | Always transition to the next agent | | `convergence` | `marker`, `maxIterations` | Loop until `marker` string found in artifact content, or `maxIterations` reached | | `output_contains` | `pattern` | Transition if the artifact content matches the regex `pattern` | | `output_not_contains` | `pattern` | Transition if the artifact content does NOT match the regex `pattern` | ### Common Transition Patterns **Linear pipeline** (A → B → C): ```json "transitions": [ {"from": "A", "to": "B", "condition": {"type": "always"}}, {"from": "B", "to": "C", "condition": {"type": "always"}} ] ``` **Convergence loop** (A ↔ B until marker): ```json "transitions": [ {"from": "A", "to": "B", "condition": {"type": "always"}}, {"from": "B", "to": "A", "condition": {"type": "convergence", "marker": "[APPROVED]", "maxIterations": 5}} ] ``` The convergence condition means: if `[APPROVED]` is NOT found in the artifact, loop back to A. If found (or maxIterations hit), the pipeline stops. **Conditional branching** (B → C if approved, B → A if not): ```json "transitions": [ {"from": "A", "to": "B", "condition": {"type": "always"}}, {"from": "B", "to": "C", "condition": {"type": "output_contains", "pattern": "\\[APPROVED\\]"}}, {"from": "B", "to": "A", "condition": {"type": "output_not_contains", "pattern": "\\[APPROVED\\]"}} ] ``` Transitions are evaluated in order — the first matching rule wins. --- ## Hooks System Templates can define lifecycle hooks that execute external scripts at key moments. ```json "hooks": { "onStart": { "script": "path/to/script.mjs", "args": ["arg1"], "timeout": 30000 }, "onTransition": { "script": "...", "timeout": 10000 }, "onEnd": { "script": "...", "args": ["target-agent"], "timeout": 10000 } } ``` | Hook | When | Use Case | |------|------|----------| | `onStart` | Before the first agent step | Setup, validation | | `onTransition` | After each transition, before the next step | Intermediate processing | | `onEnd` | After all steps complete | Cleanup, reporting | Hook scripts receive `HookContext` on stdin (JSON) and return `HookResult` on stdout (JSON): **HookContext** (input): ```typescript { threadId, templateName, phase, currentStepIndex, steps, activeAgent, previousAgent, artifactContent, userMessage, totalCostUsd } ``` **HookResult** (output) — two modes: 1. `insertAgent: true` — Creates a temporary new agent to execute the prompt 2. `targetAgent: "slotId"` — Sends a prompt to an existing agent's persistent session (process alive → stdin, dead → `--resume`) --- ## Agent Communication Agents in a thread communicate through three mechanisms: 1. **Artifact file** (`{{artifactPath}}`): A shared `artifact.md` in the thread workspace. Agents read from and write to this file. This is the primary structured communication channel. 2. **Output passing** (`{{previousOutput}}`): The full text output of the previous agent step is available to the next agent via this variable. 3. **Modified files list** (`{{modifiedFiles}}`): A bullet list of files the previous agent edited/wrote, extracted from path-only session-activity JSONL logs. For ad-hoc threads (no template), the previous agent's output is automatically injected into the next agent's prompt even without `{{previousOutput}}` in the template. --- ## Task Dispatch Integration Tasks in TASKS.md use the `[template: ]` tag to specify which thread template to use when dispatched: ```markdown - [ ] Run ablation study [template: ] Why: Need to isolate depth-sensor contribution Done when: Results in experiments/EXP-NNN.md ``` The `[template:]` tag is **required** for all dispatchable tasks. Tasks without it are flagged by the `task-parser.ts` lint check (`missing-template`). Dispatch flow: `task-dispatcher.ts` selects a task → extracts template name → `createThread({templateName})` → `runThreadExec()`. --- ## Key Source Files | File | Role | |------|------| | `~/.cortex/config/thread-templates/` | Agent + template + shell configuration (directory, one JSON file per entity under `agents/`, `templates/`, `shells/`) | | `agent-server/src/thread-types.ts` | All TypeScript interfaces | | `agent-server/src/thread-store.ts` | In-memory cache + threads.json persistence | | `agent-server/src/thread-manager.ts` | Core orchestration (create, step, transition, prompt assembly) | | `agent-server/src/thread-runner.ts` | Execution loop (run agents, hooks, Slack output) | | `agent-server/src/template-resolver.ts` | `file:` reference expansion for prompts | | `agent-server/src/message-router.ts` | Slack message → thread routing | | `agent-server/src/scheduled-runner.ts` | Scheduler + dispatch → thread creation | | `agent-server/src/task-dispatcher.ts` | Task selection and dispatch prompt building | --- ## How to Add a New Agent 1. Add the agent definition as a new file `~/.cortex/config/thread-templates/agents/.json` (the filename without `.json` is the agent name) 2. If using a `file:` directive, create the directive file in `prompts/directives/` 3. If using a `file:` systemPrompt, create it in `prompts/systemPrompts/` 4. Choose the right `profile` (plan/execute/qa/__active__) 5. Choose `persistSession` based on whether the agent needs to retain context across iterations 6. The config hot-reloads — no restart needed ## How to Add a New Template 1. Define any new agents needed as files under `~/.cortex/config/thread-templates/agents/` 2. Add the template as a file `~/.cortex/config/thread-templates/templates/.json` (the filename without `.json` is the template name) with: - `agents`: list of agent refs (simple strings or override objects) - `transitions`: rules connecting agents - `entryAgent`: the starting agent - `maxTotalSteps`: safety cap 3. Optionally add `maxTotalCostUsd` for cost limits 4. Optionally add `hooks` for lifecycle automation 5. Test via `!thread ` in Slack ## Common Patterns for New Templates **Single-agent** (like scheduler): ```json { "name": "my-task", "agents": ["my-agent"], "transitions": [], "entryAgent": "my-agent", "maxTotalSteps": 1 } ``` **Two-agent convergence loop** (doer ↔ checker iteration): ```json { "name": "my-loop", "agents": ["doer", "checker"], "transitions": [ {"from": "doer", "to": "checker", "condition": {"type": "always"}}, {"from": "checker", "to": "doer", "condition": {"type": "convergence", "marker": "[PASS]", "maxIterations": 3}} ], "entryAgent": "doer", "maxTotalSteps": 8 } ``` **Linear pipeline with optional prompt overrides**: ```json { "name": "my-pipeline", "agents": [ {"ref": "scout", "promptTemplate": "Focus on {{input}}\n\nWrite findings to {{artifactPath}}."}, "analyzer", "reporter" ], "transitions": [ {"from": "scout", "to": "analyzer", "condition": {"type": "always"}}, {"from": "analyzer", "to": "reporter", "condition": {"type": "always"}} ], "entryAgent": "scout", "maxTotalSteps": 4 } ```