Cortex Thread System¶
The thread system is Cortex's multi-agent orchestration engine. A thread is a relay of focused agents — each with its own system prompt, tools, and plugins — passing a shared artifact file between them to accomplish complex, multi-step research work.
Mental Model¶
A thread is like a relay race. Each agent (runner) picks up the baton (the artifact.md file), does its work, and hands off to the next agent based on transition rules. The artifact file is the shared memory — agents write their findings to it, and subsequent agents read what came before.
Threads are defined by templates in ~/.cortex/config/thread-templates.json. Threads are often launched by the task dispatch system — see tasks.md for how tasks trigger thread execution. A template specifies: which agents participate, in what order, with what transition logic, and what lifecycle hooks fire between steps.
Configuration File¶
The thread system is configured via ~/.cortex/config/thread-templates.json (read from $CORTEX_HOME/config/thread-templates.json). This file has two top-level sections:
{
"agents": { ... }, // Independent agent definitions
"templates": { ... } // Multi-agent pipeline templates
}
Configuration supports hot-reload: changes to thread-templates.json or any prompt files in the prompts/ directory are detected via fs.watch (300ms debounce) and reloaded without restarting the server. A notification is sent to the admin Slack channel on reload.
Agent Definitions¶
Each agent in the agents map is an independent entity with its own identity, tools, and prompt:
{
"agents": {
"planner": {
"description": "Plans the research approach",
"profile": "claude-sonnet",
"persistSession": false,
"directive": "You are a research planner. Break down problems into testable hypotheses.",
"promptTemplate": "file:planner-prompt.md",
"pluginDirs": ["plugins/cortex-common", "plugins/cortex-surveyor"],
"tools": "Agent,AskUserQuestion,Bash,Read,Grep,Glob,Write,Edit,WebSearch,WebFetch,Skill"
}
}
}
Agent definition fields:
| Field | Type | Description |
|---|---|---|
name |
string | Agent ID (key in the agents map) |
profile |
string | Profile name from profiles.json, or "__active__" to use the current runtime profile |
persistSession |
boolean | true: reuse the same LLM session across iterations (preserves conversation context). false: fresh session each step |
directive |
string? | Agent role/identity, prepended to the prompt. Supports file:filename.md references |
systemPrompt |
string? | Full system prompt override. Supports file: references |
promptTemplate |
string? | Template with {{input}}, {{artifactPath}}, {{previousOutput}}, {{modifiedFiles}}, {{modifiedFilesWithDiff}}, {{currentDateTime}} variables. Supports file: references |
claudeAgent |
string? | Claude Code agent name (--agent flag, loads from .claude/agents/) |
outputStyle |
string? | Claude Code output style |
tools |
string? | Comma-separated tool list (overrides defaults) |
pluginDirs |
string[]? | Plugin directories to load (--plugin-dir flags) |
Multi-Stage Agents¶
An agent can declare multiple stages via the stages field. When stages are present, promptTemplate is ignored — the engine selects the appropriate stage's prompt for each step based on the transition target.
{
"coder": {
"profile": "claude-sonnet",
"persistSession": true,
"pluginDirs": ["plugins/cortex-coder"],
"stages": {
"implement": {
"promptTemplate": "You are implementing the plan. Write code to {{artifactPath}}.",
"description": "Write the implementation"
},
"review": {
"promptTemplate": "You are reviewing the code in {{artifactPath}}. Check for correctness.",
"continuesSession": true,
"description": "Review the implementation"
}
},
"entryStage": "implement"
}
}
When continuesSession: true is set on a stage, and the agent has a persistent session that is being resumed, the engine sends only the stage-specific incremental prompt — skipping the directive, protocol preamble, and automatic previousOutput injection.
File References¶
Fields that accept file:filename.md syntax load their content from prompts/<subdir>/filename.md:
| Field | Subdirectory |
|---|---|
directive |
prompts/directives/ |
promptTemplate |
prompts/promptTemplates/ |
systemPrompt |
prompts/systemPrompts/ |
The template system supports a YAML-frontmatter-based format with extends: (inheritance), @fill(name)/@endfill named blocks, @block(name)/@endblock template blocks, ${var} / ${var:-default} variable interpolation, and @if(var)/@endif conditionals.
Templates¶
Templates compose agents into multi-step pipelines:
{
"templates": {
"coder-review": {
"description": "Implement a feature then review it",
"agents": ["planner", "coder", "reviewer"],
"transitions": [
{"from": "planner", "to": "coder:implement", "condition": {"type": "always"}},
{"from": "coder:implement", "to": "coder:review", "condition": {"type": "always"}},
{"from": "coder:review", "to": "reviewer", "condition": {"type": "always"}}
],
"entryAgent": "planner",
"maxTotalSteps": 10,
"maxTotalCostUsd": 5.00,
"hooks": {
"onEnd": {
"command": "node hooks/post-task-hook.mjs",
"timeout": 30000
}
}
}
}
}
Template fields:
| Field | Type | Description |
|---|---|---|
name |
string | Template ID (used in !thread <name> and task dispatch) |
agents |
TemplateAgentRef[] | Ordered list of participating agents |
transitions |
TransitionRule[] | Rules governing when to move from one agent to the next |
entryAgent |
string | The first agent to run |
entryStage |
string? | Which stage to enter on the first step (defaults to agent's entryStage) |
maxTotalSteps |
number | Hard limit on total agent steps |
maxTotalCostUsd |
number? | Cost limit in USD |
hooks |
ThreadHooks? | Lifecycle hooks (onStart, onTransition, onEnd) |
Agent References in Templates¶
Templates reference agents either by name (as a string) or with per-template overrides (as an object):
// Simple reference — use agent as defined
"agents": ["planner", "reviewer"]
// With overrides — customize the agent for this template
"agents": [
{"ref": "planner"},
{"ref": "coder", "promptTemplate": "file:special-coder-prompt.md", "tools": "Read,Write,Edit"}
]
Override fields: promptTemplate, directive, systemPrompt, persistSession, claudeAgent, outputStyle, tools, pluginDirs.
Transitions¶
Transitions determine how the thread moves from one agent to the next. They are evaluated after each agent step completes.
Transition Endpoint Syntax¶
Endpoints use the syntax "agent" or "agent:stage". A bare agent name matches any stage of that agent. An agent:stage endpoint matches only that specific stage.
Condition Types¶
| Type | Behavior | Parameters |
|---|---|---|
always |
Always transition | None |
convergence |
Loop until a marker string appears in the artifact output, or until maxIterations is reached |
marker (string to find), maxIterations (max loops, default 3) |
output_contains |
Transition if the artifact output matches a regex pattern | pattern (regex string) |
output_not_contains |
Transition if the artifact output does NOT match a regex pattern | pattern (regex string) |
Evaluation Order¶
Transitions are evaluated in the order they appear in the template. The first matching rule wins. If no rule matches, the thread stops (terminal state: no_matching_transition).
A rule's from endpoint is matched against the last completed step. Only rules whose from matches the last step's agent (and optionally stage) are considered.
Convergence Example¶
{
"from": "coder:implement",
"to": "coder:review",
"condition": {
"type": "convergence",
"marker": "[IMPLEMENTATION COMPLETE]",
"maxIterations": 5
}
}
This means: after coder:implement runs, check if the artifact contains [IMPLEMENTATION COMPLETE]. If it does, transition to coder:review. If not, loop back to coder:implement. If it loops 5 times without the marker, stop with max_iterations.
Template Limits¶
Two hard limits are checked before evaluating any transitions:
maxTotalSteps— if the thread has reached this many total steps, stop withmax_iterationsmaxTotalCostUsd— if the accumulated cost exceeds this, stop withcost_limit
Thread Lifecycle¶
States¶
A thread moves through these states during its lifetime:
running → completed (all steps finished successfully)
running → failed (unrecoverable error)
running → cancelled (user cancelled via !cancel or button)
running → aborted (agent self-aborted via [ABORT] marker in artifact)
running → waiting (waiting for user input — Phase 6 buffering)
Terminal states: completed, failed, cancelled, aborted.
Agent-Initiated Abort¶
Any agent in a thread can abort the entire thread by writing [ABORT] or [ABORT: <reason>] to the artifact file. The abort marker is checked after every agent step completion and has higher priority than all transition rules. When detected, the thread is immediately terminated with status aborted, but onEnd hooks still fire.
Execution Loop¶
The main execution loop in runner.ts runs as follows:
- onStart hook: Fire before the first step (template hook first, then caller's extraHooks)
- Loop:
a. Resolve the next step (which agent, which stage)
b. Build the step config (prompt, session, profile, execution registry entry)
c. Set up streaming callbacks (assistant message aggregation, tool traces)
d. Execute the agent (spawn LLM process, await result)
e. Record step outcome (persist to thread store, register session, finalize execution)
f. Check for abort marker (
[ABORT]in artifact) g. Evaluate transitions (first matching rule wins, or stop) h. onTransition hook: Fire between steps (if transitioning) - onEnd hook: Fire after the main loop completes
- Mark thread as completed (if still running)
Lifecycle Hooks¶
Hooks are shell commands executed at specific points in the thread lifecycle. They receive context as JSON on stdin and can return instructions as JSON on stdout. Thread hooks are one of three hook subsystems — see hooks.md for the full hook architecture, including agent-level and session-level hooks.
Hook Points¶
| Hook | When it fires | Context |
|---|---|---|
onStart |
Before the first agent step | { threadId, templateName, phase: "start", steps: [], activeAgent, artifactContent, userMessage, totalCostUsd } |
onTransition |
After each transition, before the next step | Same as above, plus previousAgent identifying the agent that just completed |
onEnd |
After all steps complete, before the thread is marked done | Same as above, with final artifact content and completed steps |
Hook Configuration¶
{
"onEnd": {
"command": "node hooks/post-task-hook.mjs",
"args": ["--project", "flywheel"],
"timeout": 30000
}
}
command— full shell invocation (interpreter must be included:node ...,bash ..., etc.)args— positional arguments passed as$1,$2, ... viash -c 'cmd "$@"'timeout— execution timeout in milliseconds (default: 30000)
Hook Return Values¶
Hooks return JSON on stdout to control what happens next:
Insert a temporary agent:
{
"insertAgent": true,
"prompt": "Run post-task cleanup: verify all tests pass",
"profile": "claude-haiku",
"directive": "You are a cleanup agent"
}
Target an existing agent's session:
{
"targetAgent": "reviewer",
"prompt": "Double-check the results in the artifact against the original requirements"
}
reviewer agent's persistent session (via stdin if the process is still alive, or --resume if dead). targetAgent takes priority over insertAgent.
Hook Execution Order¶
Template hooks fire first, then the caller's extraHooks (injected by scheduler/dispatch) at the same phase. Both use identical execution semantics. ExtraHooks are not persisted to the ThreadRecord — they are valid only for the current runThread() invocation.
Workspace and Artifact¶
Each thread gets an isolated workspace on the filesystem:
tmp/threads/thr_a1b2c3d4/
├── artifact.md # The shared artifact — agents read and write this
└── ... # Any other files agents create
The artifact path is available to all agents via the {{artifactPath}} template variable. Agents communicate by reading what previous agents wrote and appending their own findings.
Agents can also read files modified by previous agents:
- {{previousOutput}} — the complete output from the last completed step
- {{modifiedFiles}} — list of files edited by the previous agent (extracted from session activity logs)
- {{modifiedFilesWithDiff}} — file list with per-file diff blocks reconstructed from session activity JSONL
Thread Commands¶
Starting a Thread¶
!thread coder-review Implement user authentication for the API
!thread researcher Survey recent papers on tactile sensing
The first word after !thread is the template name (or agent name for single-agent execution). The rest is the user message passed to the first agent.
Adding an Agent¶
This dynamically adds an agent to an existing thread. The thread must be completed or waiting (not currently running). If the thread was an auto-record (no filesystem workspace), a workspace is created lazily.
Other Thread Commands¶
| Command | Description |
|---|---|
!thread list |
List active threads |
!thread status [id] |
Show thread status and steps |
!thread cancel [id] |
Cancel a running thread |
!thread agents |
List available agents |
!thread templates |
List available templates |
Thread Types¶
Cortex uses three types of thread records internally:
| Type | templateName | Workspace | Used by |
|---|---|---|---|
| Template thread | Actual template name | Yes | !thread <template>, task dispatch |
| Default thread | "default" |
Yes | Single-agent messages (the normal chat path) |
| Auto thread | null |
No (initially) | !thread add chaining from single-agent runs |
The distinction matters because the runner treats default threads differently: they run exactly one step (no transitions), use the channel's existing session, and forward streaming output directly to the user.
Thread Record¶
Each thread's full state is persisted in ~/.cortex/data/threads.json as a ThreadRecord:
| Field | Description |
|---|---|
id |
Thread ID (thr_<8 hex>) |
status |
Current lifecycle state |
channel |
Slack channel ID |
templateName |
Template used (null for ad-hoc) |
userMessage |
The original user message |
workspacePath / artifactPath |
File paths for the shared artifact |
agents |
Map of agent slots with their state (sessionId, status, persistSession) |
activeAgent / activeStage |
Which agent and stage runs next |
steps[] |
Recorded execution history per step (agent, stage, cost, duration, output) |
iterationCounts |
Track convergence loop counts per transition edge |
totalCostUsd |
Cumulative cost across all steps |
metadata |
Caller-provided context: scheduleTaskId, trigger, project, pendingMessages |
abortReason |
Reason if agent self-aborted |
Old threads are cleaned up on startup: threads older than 7 days are removed (24 hours for auto-records without workspaces).
Prompt Variables¶
Agent prompts support template variables that are resolved at runtime:
| Variable | Description |
|---|---|
{{input}} |
The user message (for the first step) or the previous agent's output |
{{artifactPath}} |
Absolute path to artifact.md |
{{previousOutput}} |
Full output from the last completed step |
{{modifiedFiles}} |
Files edited by the previous agent |
{{modifiedFilesWithDiff}} |
Files with inline diffs from previous agent |
{{currentDateTime}} |
Current date and time in ISO format |
Plugin Loading¶
Each agent definition specifies which plugin directories to load via pluginDirs. Plugins are resolved relative to DATA_DIR (default: ~/.cortex/). For example, plugins/cortex-coder resolves to ~/.cortex/plugins/cortex-coder/.
The plugin directories are passed to the LLM backend as --plugin-dir flags (Claude Code) or --skill flags (PI). The backend then scans for SKILL.md files and makes them available as invocable skills. See skills-and-plugins.md for the full skill and plugin system.
Thread Cleanup¶
When a thread completes, fails, or is cancelled:
- The agent handle is removed from
RunningExecutions - Thread-specific sessions (keyed by
thr:<threadId>:) are closed - The thread store is flushed to disk
On server startup, any threads left in running status are marked as failed to prevent stale state.