-
Notifications
You must be signed in to change notification settings - Fork 22
workflow guide
This guide covers the role-based workflow, note schema system, dependency blocking, and efficient query patterns for v3 MCP Task Orchestrator.
Every WorkItem moves through a set of lifecycle phases called roles. Roles are semantic — they describe where an item is in the workflow, independent of any specific status label.
| Role | Meaning |
|---|---|
queue |
Pending, not yet started. Default role at creation. |
work |
Actively being worked on. |
review |
Work complete, undergoing verification or review. |
terminal |
Finished. Use reopen to move back to queue if needed. |
blocked |
Paused due to an unresolved dependency or explicit hold. |
queue --> work --> review --> terminal
| ^
+--------- (no review) ---+
|
(hold/block)
|
blocked --> (resume) --> previous role
When an item has no review-phase notes defined in its schema, start from work advances directly to terminal, skipping the review phase.
All role transitions use advance_item(trigger=...). There is no direct role assignment.
| Trigger | From Role(s) | To Role | Notes |
|---|---|---|---|
start |
queue |
work |
Queue-phase required notes must be filled. |
start |
work |
review or terminal
|
Work-phase required notes must be filled. |
start |
review |
terminal |
Review-phase required notes must be filled. |
complete |
Any non-terminal | terminal |
Enforces gates: all required notes across ALL phases must be filled. |
block |
Any non-terminal | blocked |
Saves previousRole for resume. |
hold |
Any non-terminal | blocked |
Alias for block. |
resume |
blocked |
Previous role | Restores role saved at block time. |
cancel |
Any non-terminal | terminal |
Sets statusLabel = "cancelled". |
reopen |
terminal |
queue |
Clears statusLabel, bypasses gates. Parent cascades TERMINAL → WORK. |
advance_item(transitions=[
{ "itemId": "abc-123", "trigger": "start" }
])advance_item(transitions=[
{ "itemId": "abc-123", "trigger": "complete", "summary": "All tests passed" },
{ "itemId": "def-456", "trigger": "start" }
])Note schemas gate role transitions. They define what documentation an item must carry before it can advance to the next phase.
A note schema is a named set of note definitions. When an item matches a schema, the system enforces note requirements at start transitions.
Schema resolution order:
-
Type-first lookup — the item's
typefield (e.g.,feature,task,bug) is looked up directly inwork_item_schemas. If a match is found, that schema is used. - Tag fallback — if no type match, the item's tags are checked against schema keys. The first matching tag wins.
-
Default schema — if neither type nor tags match, the
defaultschema is used (if configured). Items with no match at all advance freely with no gate enforcement.
Schemas are defined in .taskorchestrator/config.yaml in the project root.
Schemas vs notes: Schemas and notes are independent systems. A schema is a set of rules defined in your project config that specifies what documentation an item must carry — it is never stored in the database. A note is actual content written by agents during implementation — it is stored in the database and carries no reference to any schema. The two meet only at gate-check time:
advance_itemfetches the item's notes from the database and checks them against the schema's requirements.
-
advance_item(trigger="start")checks required notes for the current phase before advancing. -
advance_item(trigger="complete")checks all required notes across all phases. -
advance_item(trigger="cancel")does not enforce gates. - Optional notes never block transitions.
Gate enforcement applies to items that resolve a schema via type, tag, or default fallback.
The preferred format uses work_item_schemas:, which supports a lifecycle: field for cascade control. The legacy note_schemas: format is still accepted and fully backward-compatible.
# Preferred format
work_item_schemas:
<schema-key>:
lifecycle: <AUTO|MANUAL|AUTO_REOPEN|PERMANENT> # optional, default: AUTO
notes:
- key: <note-key>
role: <queue|work|review>
required: <true|false>
description: "<Short description of what this note should contain.>"
guidance: "<Optional longer guidance shown to agents filling the note.>"
traits: # optional — composable trait keys applied to this schema
- <trait-key>
# Legacy format (still works)
note_schemas:
<schema-key>:
- key: <note-key>
role: <queue|work|review>
required: <true|false>
description: "<Short description of what this note should contain.>"
guidance: "<Optional longer guidance shown to agents filling the note.>"| Field | Type | Required | Description |
|---|---|---|---|
key |
string | yes | Unique identifier for this note within the schema. Used in manage_notes. |
role |
string | yes | Phase this note belongs to: queue, work, or review. |
required |
boolean | yes | Whether this note must be filled before advancing past this phase. |
description |
string | yes | Short description of expected content. Shown in get_context gate status. |
guidance |
string | no | Longer authoring guidance for agents. Shown in get_context as guidancePointer. |
skill |
string | no | Skill to invoke when filling this note. Shown in get_context as skillPointer. |
-
keyvalues must be unique within a schema. - The same
keymay appear in multiple schemas (schemas are independent). - Type lookup takes priority over tag matching — each item matches at most one schema.
- Schemas with no matching item (no type, tag, or default fallback) have no effect.
The lifecycle: field on a schema controls how parent items cascade when all children reach terminal. This is only relevant for container items (items with children).
| Mode | Behavior |
|---|---|
AUTO |
(default) Parent automatically advances to terminal when all children reach terminal. |
MANUAL |
Suppress auto-cascade — parent must be completed explicitly via advance_item or complete. |
AUTO_REOPEN |
Auto-cascade to terminal, and reopen the parent to work when a new child is created under a terminal parent. |
PERMANENT |
Parent never auto-terminates, regardless of child state. |
Set lifecycle at the schema level in work_item_schemas::
work_item_schemas:
quick-fix:
lifecycle: AUTO
notes:
- key: session-tracking
role: work
required: true
description: "Brief record of what was changed and test results."work_item_schemas:
feature-implementation:
lifecycle: AUTO
notes:
- key: feature-summary
role: queue
required: true
description: "Lean feature-level summary — goal, findings-to-tasks mapping, dependency edges, non-goals pointer."
guidance: "Cover the goal in 2-3 sentences, a findings→tasks mapping, dependency edges between child tasks, and a pointer to where non-goals and alternatives are recorded."
- key: implementation-notes
role: work
required: true
description: "Context handoff for downstream agents — deviations, surprises, and decisions that affect dependent work."
guidance: "Document decisions made during implementation that weren't in the feature-summary. Focus on deviations from the plan, interface surprises, and assumptions that turned out wrong."
- key: review-checklist
role: review
required: true
description: "Quality gate — plan alignment, test quality, and simplification review."
guidance: "Verify what was built aligns with the feature-summary, tests cover the test strategy, and any /simplify changes have test coverage."The legacy
note_schemas:flat-list format is still accepted. New configs should preferwork_item_schemas:for access to thelifecycle:andtraits:fields.
queue
requires: feature-summary (filled)
|
[start]
|
work
requires: implementation-notes (filled)
|
[start]
|
review
requires: review-checklist (filled)
|
[start]
|
terminal
Notes are created or updated using manage_notes(operation="upsert").
manage_notes(operation="upsert", notes=[
{
"itemId": "abc-123",
"key": "feature-summary",
"role": "queue",
"body": "## Goal\nAllow users to reset passwords via email.\n\n## Findings -> Tasks\n- Token storage design -> PasswordResetTokenRepository task\n\n## Dependencies\nNone.\n\n## Non-goals\nSee linked spec doc for alternatives considered."
}
])(itemId, key) is unique — upserting an existing (itemId, key) pair updates it in place.
Use get_context(itemId=...) to inspect the current gate status before calling advance_item.
get_context(itemId="abc-123")Response includes:
{
"item": { "id": "abc-123", "title": "...", "role": "queue" },
"gateStatus": {
"canAdvance": false,
"phase": "queue",
"missing": ["feature-summary"]
},
"schema": [
{
"key": "feature-summary",
"role": "queue",
"required": true,
"description": "Lean feature-level summary — goal, findings-to-tasks mapping, dependency edges, non-goals pointer.",
"guidance": "Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency edges between child tasks, and a pointer to where non-goals and alternatives are recorded.",
"exists": false,
"filled": false
}
],
"guidancePointer": "Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency edges between child tasks, and a pointer to where non-goals and alternatives are recorded."
}When canAdvance: true, calling advance_item(trigger="start") will succeed.
query_notes(operation="list", itemId="abc-123")This walkthrough covers a complete lifecycle for a feature-implementation item.
Step 1: Create the item
manage_items(operation="create", items=[
{
"title": "Password Reset Feature",
"type": "feature-implementation",
"tags": "backend,auth",
"priority": "high"
}
])The response includes expectedNotes when the type (or tags) match a schema:
{
"id": "abc-123",
"title": "Password Reset Feature",
"role": "queue",
"tags": "feature-implementation",
"expectedNotes": [
{ "key": "feature-summary", "role": "queue", "required": true, "description": "Lean feature-level summary — goal, findings-to-tasks mapping, dependency edges, non-goals pointer.", "guidance": "Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency edges between child tasks, and a pointer to where non-goals and alternatives are recorded.", "exists": false },
{ "key": "implementation-notes", "role": "work", "required": true, "description": "Context handoff for downstream agents — deviations, surprises, and decisions that affect dependent work.", "guidance": "Document decisions made during implementation that weren't in the feature-summary. Focus on deviations from the plan, interface surprises, and assumptions that turned out wrong.", "exists": false },
{ "key": "review-checklist", "role": "review", "required": true, "description": "Quality gate — plan alignment, test quality, and simplification review.", "guidance": "Verify what was built aligns with the feature-summary, tests cover the test strategy, and any /simplify changes have test coverage.", "exists": false }
]
}Step 2: Fill queue-phase notes
Before writing notes, consult guidancePointer for authoring instructions:
get_context(itemId="abc-123")
// guidancePointer: "Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency edges between child tasks, and a pointer to where non-goals and alternatives are recorded."Use the guidance to author the note:
manage_notes(operation="upsert", notes=[
{
"itemId": "abc-123",
"key": "feature-summary",
"role": "queue",
"body": "Goal: let users reset passwords by email.\n\nFindings -> Tasks:\n- Token storage design -> PasswordResetTokenRepository task\n\nDependencies: none.\n\nNon-goals: see linked spec doc."
}
])Step 3: Verify gate, then advance to work
get_context(itemId="abc-123")
// gateStatus.canAdvance: true
advance_item(transitions=[{ "itemId": "abc-123", "trigger": "start" }])
// item is now role: workStep 4: Fill work-phase notes
Consult guidancePointer again — it now points to the first unfilled work-phase note:
get_context(itemId="abc-123")
// guidancePointer: "Document decisions made during implementation that weren't in the feature-summary. Focus on deviations from the plan, interface surprises, and assumptions that turned out wrong."Use the guidance to author the work-phase note:
manage_notes(operation="upsert", notes=[
{
"itemId": "abc-123",
"key": "implementation-notes",
"role": "work",
"body": "Added PasswordResetTokenRepository. Token TTL configurable via env var. 42 tests passing, 0 failing; added 8 new tests for token expiry edge cases."
}
])Step 5: Advance to review
advance_item(transitions=[{ "itemId": "abc-123", "trigger": "start" }])
// item is now role: reviewStep 6: Advance to terminal
advance_item(transitions=[{ "itemId": "abc-123", "trigger": "start" }])
// item is now role: terminalThe guidance field in note schemas is a communication channel from schema authors to automated agents. It provides specific authoring instructions that go beyond the short description — telling agents how to write a note, not just what the note is for.
guidance is an optional string set in .taskorchestrator/config.yaml alongside each note definition. It carries intent from whoever designed the schema to whoever (or whatever) fills the note at runtime. Agents should treat it as a prompt: follow it when composing note bodies.
Guidance surfaces in four places:
-
get_context(itemId=...)—guidancePointerat the top level. Points to the guidance of the first unfilled required note for the current phase.nullwhen all required notes are filled or no matching schema has guidance. -
manage_items(create)response — each entry inexpectedNotesincludes an optionalguidancefield when the schema defines one. -
create_work_treeresponse —expectedNoteson root and child items include the optionalguidancefield. -
advance_itemgate errors —missingNotesarray entries include the optionalguidancefield when present.
The standard protocol is:
- Call
get_context(itemId=...)before writing notes. - Read
guidancePointer— it is the instruction for the first unfilled required note in the current phase. - Use the guidance text as authoring instructions for
manage_notes(operation="upsert"). - After filling that note, call
get_contextagain if there are more unfilled required notes —guidancePointerwill advance to the next one.
Alternatively, use expectedNotes from the item creation response to batch-fill all notes at once, using each entry's guidance field individually.
-
guidancePointeralways points to the first unfilled required note for the current phase. - It is
nullwhen all required notes for the current phase are filled, or when no schema entry for the current phase has aguidancevalue. - Advancing to the next phase resets the pointer to the first unfilled required note of the new phase.
- Optional notes (required: false) do not contribute to
guidancePointer.
Different tools return different subsets of the expectedNotes fields:
| Field | manage_items |
advance_item (success) |
advance_item (gate error) |
create_work_tree |
get_context |
|---|---|---|---|---|---|
key |
✓ | ✓ | ✓ | ✓ | ✓ |
role |
✓ | ✓ | — | ✓ | ✓ |
required |
✓ | ✓ | — | ✓ | ✓ |
description |
✓ | ✓ | ✓ | ✓ | ✓ |
guidance |
✓ (optional) | ✓ (optional) | ✓ (optional) | ✓ (optional) | ✓ (optional) |
exists |
✓ | ✓ | — | ✓ | ✓ |
filled |
— | — | — | — | ✓ |
guidance is optional in all positions — it only appears when the schema entry defines it.
The recommended pattern for any agent filling notes on a schema-tagged item:
1. get_context(itemId=...) → read guidancePointer
2. manage_notes(upsert, ...) → fill the note using guidancePointer as instructions
3. Repeat until gateStatus.canAdvance: true
4. advance_item(trigger="start") → advance to next phase
When creating an item via manage_items or create_work_tree, the response includes expectedNotes with per-note guidance. Agents can use this to front-load all queue-phase notes immediately after creation without a separate get_context call:
manage_items(operation="create", items=[{ "title": "...", "tags": "feature-implementation" }])
// Response includes expectedNotes with guidance per entry
// Use each entry's guidance to write the corresponding note body immediatelyOrchestration hooks (e.g., SubagentStart) can inject guidancePointer into a subagent's system prompt before the agent begins work. This removes the need for the agent to call get_context itself:
System context injected by hook:
Item: abc-123 — Password Reset Feature (role: queue)
Required note: feature-summary
Guidance: Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency edges
between child tasks, and a pointer to where non-goals and alternatives are recorded.
The agent receives this context at session start and can proceed directly to manage_notes(upsert).
When dispatching a subagent to fill notes for a specific item, embed the guidance directly in the prompt:
Fill the "feature-summary" note for item abc-123.
Guidance from schema: "Cover the goal in 2-3 sentences, a findings->tasks mapping, dependency
edges between child tasks, and a pointer to where non-goals and alternatives are recorded."
Use manage_notes(operation="upsert") with itemId="abc-123", key="feature-summary", role="queue".
Status labels are human-readable strings automatically set on WorkItems during role transitions. They provide a display-friendly status name alongside the semantic role.
| Trigger | Status Label | Description |
|---|---|---|
start |
"in-progress" |
Item has begun active work. |
complete |
"done" |
Item finished successfully. |
block |
"blocked" |
Item is paused due to a dependency or hold. |
cancel |
"cancelled" |
Item was explicitly cancelled. |
cascade |
"done" |
Item auto-completed via cascade from children. |
resume |
(null) | Preserves the label from before the block. |
reopen |
(null/cleared) | Clears the label when reopening a terminal item. |
-
Resolution label — hardcoded for
cancel("cancelled") andreopen(null/cleared). Always wins when non-null. -
Config-driven label — resolved from
StatusLabelServicefor the trigger. Used when the resolution label is null. -
Resume behavior —
applyTransitionpreserves the pre-block label automatically.
Override default labels in .taskorchestrator/config.yaml:
status_labels:
start: "working"
complete: "finished"
block: "on-hold"
cancel: "abandoned"
cascade: "auto-completed"Triggers not listed in the config get no label override (null). If no status_labels section exists, the hardcoded defaults above are used.
-
advance_itemresponse — each successful result includes"statusLabel"when set. -
complete_treeresponse — each completed/cancelled item includes"statusLabel"when set. -
Cascade events — cascade results in both tools include
"statusLabel"when set. -
query_itemsresponses — thestatusLabelfield is included in item JSON when non-null.
A BLOCKS edge between item A and item B means: B cannot start until A reaches terminal (by default).
manage_dependencies(operation="create", dependencies=[
{ "fromItemId": "abc-123", "toItemId": "def-456", "type": "BLOCKS" }
])advance_item(trigger="start") on a blocked item will fail with a gate error listing the blocking items.
Create a linear chain in one call:
manage_dependencies(operation="create", pattern="linear", itemIds=["task-a", "task-b", "task-c"])
// task-a BLOCKS task-b, task-b BLOCKS task-cFan-out (one blocker, many dependents):
manage_dependencies(operation="create", pattern="fan-out", fromItemId="task-a", toItemIds=["task-b", "task-c", "task-d"])Fan-in (many blockers, one dependent):
manage_dependencies(operation="create", pattern="fan-in", fromItemIds=["task-a", "task-b"], toItemId="task-c")By default a dependency is satisfied when the blocking item reaches terminal. Use unblockAt to satisfy earlier:
| Value | Satisfied When Blocker Reaches |
|---|---|
queue |
Any role (immediately) |
work |
work, review, or terminal
|
review |
review or terminal
|
terminal |
terminal (default) |
manage_dependencies(operation="create", dependencies=[
{ "fromItemId": "abc-123", "toItemId": "def-456", "type": "BLOCKS", "unblockAt": "review" }
])get_blocked_items()Returns items blocked by unsatisfied dependencies and items explicitly in blocked role. Optionally scope to a subtree:
get_blocked_items(parentId="feature-uuid", includeAncestors=true)advance_item automatically cascades role transitions up the hierarchy in two situations:
Start cascade (QUEUE → WORK): When a child item transitions to WORK, the parent is automatically advanced from QUEUE to WORK (if it is still in QUEUE). This cascade continues up the ancestor chain.
Terminal cascade (all children → TERMINAL): When a child item reaches TERMINAL, if all siblings are also terminal, the parent is automatically advanced to TERMINAL. This cascade also continues up the ancestor chain.
Reopen cascade (child TERMINAL → QUEUE): When a child item is reopened under a terminal parent, the parent is automatically reopened to WORK. This only applies to the immediate parent — no recursion.
All cascade types appear in cascadeEvents in the response:
{
"cascadeEvents": [
{ "itemId": "feat-uuid", "title": "Auth Feature", "previousRole": "queue", "targetRole": "work", "applied": true }
]
}When completing a blocking item, the response includes unblockedItems:
{
"results": [{
"itemId": "abc-123",
"previousRole": "work",
"newRole": "terminal",
"trigger": "start",
"applied": true,
"unblockedItems": [
{ "itemId": "def-456", "title": "Downstream Task" }
]
}]
}Items in unblockedItems are now eligible to be started.
advance_item transitions and manage_notes upserts accept an optional actor claim that records who performed the action. This enables post-mortem analysis of multi-agent workflows.
advance_item(transitions=[{
"itemId": "abc-123",
"trigger": "start",
"actor": { "id": "impl-agent", "kind": "subagent", "parent": "orchestrator-1" }
}])The response echoes the actor claim. A verification record appears only when a real actor verifier (e.g. JWKS) is configured — with the default no-op verifier the field is omitted entirely:
{
"actor": { "id": "impl-agent", "kind": "subagent", "parent": "orchestrator-1" },
"verification": { "status": "verified", "verifier": "jwks" }
}Key behaviors:
-
Optional by default — omitting
actorproduces no error and no attribution data - Cascade transitions always have null actor (system-generated, not attributable)
- Note re-upsert replaces the actor (last-writer-wins semantics)
-
get_contextsession resume includes actor/verification on recent transitions -
query_notesincludes actor/verification on notes that have them
Actor claims are self-reported — the server trusts them as-is in Stage 1. To require actor claims on all write operations, enable actor authentication in .taskorchestrator/config.yaml (set actor_authentication.enabled: true). See Enforcing Actor Attribution in the API reference.
The query_items(operation="search") and query_notes(operation="search") operations both support
FTS5 full-text search when a query string is provided. Use these over list-mode filtering whenever
you are looking for items or notes by content rather than metadata.
See search-and-discovery.md for the architecture and score interpretation.
Find any item whose title or summary mentions "authentication":
query_items(operation="search", query="authentication token", scope={role="queue"}, limit=20)Find notes that discuss a specific topic across the entire workspace:
query_notes(operation="search", query="rate limiting strategy", limit=20)Scope the search to descendants of a known feature UUID using scope.ancestorId:
query_items(operation="search", query="database migration", scope={ancestorId="550e8400-e29b-41d4-a716-446655440000"})query_notes(operation="search", query="rollback plan", scope={ancestorId="550e8400-e29b-41d4-a716-446655440000"})This uses a recursive CTE under the hood — any descendant at any depth is included.
Identify all items that hold a dependency edge pointing AT a specific item — e.g., everything that blocks or relates to a requirement:
query_dependencies(operation="backlinks", itemId="550e8400-e29b-41d4-a716-446655440042")Narrow to one type — "what blocks REQ-42?":
query_dependencies(operation="backlinks", itemId="550e8400-e29b-41d4-a716-446655440042", type="BLOCKS")Each backlink entry returns { fromItemId, type, fromTitle }. Use query_items(get, id=fromItemId)
to load full details on any item of interest.
search returns references and snippets — NOT full note bodies. For full content, use a two-step
pattern:
Step 1 — find matching notes with snippet context:
query_notes(operation="search", query="acceptance criteria OAuth", scope={ancestorId="feature-uuid"}, snippet=true)Step 2 — fetch full body for any hit by note ID or by (itemId, key):
query_notes(operation="get", id="<note-uuid-from-hit>")Or list all notes for the owning item:
query_notes(operation="list", itemId="<itemId-from-hit>", role="queue")Why two steps? Snippets are ~32 tokens. Full note bodies can be thousands of tokens. Loading them selectively avoids token budget exhaustion in multi-note workspaces.
query_items(operation="search") supports filtering by semantic role across all status names:
query_items(operation="search", role="work")
// Returns all items currently in the work phasequery_items(operation="search", role="blocked", parentId="feature-uuid")
// Returns blocked items under a specific featureGet a complete picture of active work with zero follow-up calls:
get_context(includeAncestors=true)Returns active items (work/review) and blocked items, each with a full ancestor chain:
{
"activeItems": [
{
"id": "abc-123",
"title": "Implement reset endpoint",
"role": "work",
"ancestors": [
{ "id": "proj-001", "title": "Auth System", "depth": 0 },
{ "id": "feat-042", "title": "Password Reset Feature", "depth": 1 }
]
}
]
}Then get container-level counts:
query_items(operation="overview")Total: 2 calls. No sequential parent-walk needed.
Available on query_items(get), query_items(search), get_context, get_blocked_items, and get_next_item.
Each item gets an ancestors array ordered root-first (direct parent last). Root items get [].
query_items(operation="search", role="work", includeAncestors=true)get_next_item(limit=3, includeAncestors=true)Returns unblocked queue items ranked by priority (high first) then complexity (low first — quick wins first).
Scope to a subtree:
get_next_item(parentId="feature-uuid")The full schema for .taskorchestrator/config.yaml:
# Preferred — supports lifecycle, traits, default_traits
work_item_schemas:
<schema-key>:
lifecycle: <AUTO|MANUAL|AUTO_REOPEN|PERMANENT> # optional
notes:
- <note-entry>
traits: # optional list of trait keys to apply
- <trait-key>
default_traits: # optional — traits added to every item matching this schema
- <trait-key>
# Legacy — flat list under each key, still fully supported
note_schemas:
<schema-key>:
- <note-entry>
# Composable traits (reusable note bundles)
traits:
<trait-key>:
- key: <note-key>
role: <queue|work|review>
required: <true|false>
description: "<description>"
guidance: "<guidance>"Additional top-level keys (workflows, status, cascade) are supported but not covered in this guide.
| Field | Type | Required | Allowed Values | Description |
|---|---|---|---|---|
key |
string | yes | Any non-empty string | Note identifier. Must be unique within the schema. |
role |
string | yes |
queue, work, review
|
Phase gate this note belongs to. |
required |
boolean | yes |
true, false
|
If true, must be filled before start advances past this phase. |
description |
string | yes | Any string | Short description. Shown in get_context gate status output. |
guidance |
string | no | Any string | Longer authoring hint. Shown as guidancePointer in gate status. |
skill |
string | no | Skill name | Skill to invoke before filling. Shown as skillPointer. |
-
Type-first lookup — the item's
typefield is looked up directly inwork_item_schemas. If found, that schema is used. - Tag fallback — if no type match, the item's tags are checked against schema keys. First matching tag wins. Tags are matched as exact substrings within the comma-separated tags string.
-
Default schema — if neither type nor tags match, the
defaultschema is used (if defined). Items with no match at all advance freely.
work_item_schemas:
task-implementation:
lifecycle: AUTO
notes:
- key: acceptance-criteria
role: queue
required: true
description: "Testable acceptance criteria for this task."
- key: done-criteria
role: work
required: true
description: "What does done look like? How was it verified?"- Default:
.taskorchestrator/config.yamlrelative to the working directory. - Docker override: set
AGENT_CONFIG_DIRenvironment variable to the directory containing.taskorchestrator/.
docker run -e AGENT_CONFIG_DIR=/project -v "$(pwd)"/deploy/global-config/.taskorchestrator:/project/.taskorchestrator:ro task-orchestrator:devThe claim mechanism prevents race conditions between independent agents competing for the same work items. It is optional: single-orchestrator deployments that serialize work dispatch do not need claims.
Operators: see Fleet Deployment Guide for
degradedModePolicy,DATABASE_BUSY_TIMEOUT_MS, capacity planning, and a Claims Troubleshooting FAQ. The tool-levelclaim_itemreference lives in API Reference.
Agent implementers: the bundled Claude Code plugin under
claude-plugins/task-orchestrator/targets default-mode single-agent orchestration. Its skills, hooks, and output style assume the agent-owned phase-entry pattern (advance_itemcalled directly) — they do not referenceclaim_itemor coordinate claim-then-advance sequencing. If you are building agents for a claim-based fleet, treat the bundled plugin's behavior as undefined and build claim-aware skills/hooks against the MCP tool surface directly (claim_item,advance_item,get_next_item,query_items,get_context). See the Fleet Deployment Guide — Scope for the public contract.
Skip claims if:
- A single orchestrator dispatches work sequentially — it controls which agent gets which item.
- Your fleet has a natural partition (e.g., each agent handles a different feature tree).
Use claims when:
- 10 or more independent agents poll
get_next_itemandadvance_itemconcurrently. - You observe agents starting the same item twice (the
get_next_item → advance_itemrace). - Crash recovery requirements demand TTL-based automatic release.
The standard two-call pattern — discover then claim:
get_next_item() → find an unclaimed item
claim_item(claims=[{itemId, ttl}]) → claim it (auto-releases any prior claim by this agent)
outcome=success → proceed with advance_item + work
outcome=already_claimed → pick a different item (retryAfterMs available)
advance_item(trigger="start") → ownership enforced: actor must match claimedBy
... do work ...
claim_item(claims=[{itemId}]) → heartbeat: refresh TTL (if work > TTL/2 = 450s)
advance_item(trigger="complete") → ownership enforced at completion too
Single-claim-per-call:
claim_itemenforcesclaims.size ≤ 1. Calls withclaims.size > 1are rejected immediately with error codemulti_claim_not_supported, regardless of whether entries useitemIdorselectormode. Issue oneclaim_itemcall per item. The cap derives from the heartbeat write-budget assumption (one TTL refresh per agent per cycle); a futureclaim_heartbeatstable mitigation could re-evaluate the constraint. Thereleasesarray remains batchable.
The two-call pattern above has an inherent race: between get_next_item returning an item ID and claim_item(itemId=...) locking it, another agent can claim the same item. For high-concurrency fleets, selector mode eliminates the user-facing race window by resolving the filter and claiming the top match in a single MCP call. A much smaller server-side window between recommend and claim still exists and surfaces as already_claimed — typically rare in practice.
get_next_item and claim_item.selector accept identical filter shapes — the same tags, priority, type, complexityMax, createdAfter/Before, roleChangedAfter/Before, and orderBy parameters apply in both tools, backed by the same shared eligibility logic (NextItemRecommender).
Selector mode lifecycle:
claim_item(claims=[{ selector: { priority: "high", complexityMax: 4, orderBy: "oldest" }, ttlSeconds: 900 }],
actor={...}, requestId=<uuid>)
outcome=success, selectorResolved=true → proceed with advance_item + work (itemId is in the result)
outcome=no_match, kind=permanent → queue is empty for these filters; back off or wait
outcome=already_claimed → TOCTOU race (rare); retry immediately with a fresh requestId
advance_item(trigger="start") → ownership enforced: actor must match claimedBy
... do work ...
claim_item(claims=[{itemId}]) → heartbeat: refresh TTL (use the itemId from the selector result)
advance_item(trigger="complete") → ownership enforced at completion too
Fleet drain pattern using selector mode:
// Each agent calls this loop independently — no coordination needed
{
"claims": [{
"selector": { "orderBy": "oldest" },
"ttlSeconds": 900,
"claimRef": "worker-7-drain-loop"
}],
"actor": { "id": "worker-agent-7", "kind": "subagent" },
"requestId": "<fresh UUID per iteration>"
}orderBy: "oldest" provides fair-share FIFO draining: agents process items in creation order rather than racing to the same high-priority items. When no_match is returned, the queue is drained — the agent can idle, exit, or poll after a delay.
Selector hygiene and ancestor-claim filtering. In selector mode, items whose ancestor chain contains a live claim held by a different agent are automatically excluded from the eligible set. This sub-tree isolation protects in-progress feature orchestration from fleet drain workers picking up child items. Use specific selectors (parentId: null to restrict to top-level items, or tag filters such as tags: "feature") when your fleet operates at the top level; broader selectors like role: queue will naturally skip sub-items of in-progress features without returning errors. Items excluded by ancestor-claim filtering appear as no_match rather than surfacing information about the competing agent's identity. See Fleet Deployment Guide — Fleet Topology Patterns for recommended patterns.
claimRef (up to 64 chars) is echoed verbatim in every result and is useful for correlating claim results back to your agent's internal loop state without parsing itemId values.
Idempotency with selector mode. A (actor, requestId) cache hit replays the resolved response verbatim — the same itemId is returned, and the selector is not re-evaluated against fresh queue state. Use a fresh requestId per claim iteration, not per retry of the same iteration.
For long-running work (longer than the TTL — default 900s), re-call claim_item before the TTL expires to refresh it. Recommended cadence: TTL/2 = 450s for the 900s default — the same convention used by Consul, etcd, and other lease-based distributed systems.
Re-claiming an already-held item:
- Refreshes
claimExpiresAt(new TTL from now) - Preserves
originalClaimedAt(first claim timestamp is not overwritten)
Operators can inspect originalClaimedAt via get_context(itemId) to distinguish freshly-claimed items from long-running renewed work.
Heartbeat write overhead. Every re-claim is a row UPDATE on work_items. At 30 agents with TTL=900s, heartbeats produce approximately 4 writes/minute versus 30–60 writes/minute from real work transitions (~7% overhead). This is acceptable for v1. If writer contention from heartbeat traffic becomes a measured bottleneck, the mitigation is splitting heartbeats into a separate claim_heartbeats table (a non-breaking repository-layer change deferred to v1.5+).
advance_item(trigger="complete" | "cancel") transitions the role but does not clear the claim record. claimedBy, claimedAt, claimExpiresAt, and originalClaimedAt remain on the item until either the TTL elapses or claim_item(releases=[...]) is called.
This is harmless: terminal items reject new claims with the terminal_item outcome, so a residual claim on a completed item has no functional effect. reopen continues to enforce ownership against the original claim if the TTL has not elapsed. Well-behaved agents call claim_item(releases=[...]) after finishing work to make the audit trail explicit; it is not required for correctness.
There is no background reaper process. When an agent crashes or is killed, its claim expires naturally within the configured TTL (default 900s). Expired claims are filtered at read time:
-
get_next_item()(defaultincludeClaimed=false) excludes items with live claims but includes items with expired claims. -
query_items(claimStatus="expired")surfaces items with elapsed TTLs for operator inspection.
No manual cleanup is needed for correctness. Use get_context(itemId) to confirm whether a specific item's claim has expired (isExpired: true).
Multi-role fleets use get_next_item(role=...) to target different pipeline stages:
| Agent group | Call |
|---|---|
| Work-group (implementation) |
get_next_item() (default role=queue) |
| Review-group | get_next_item(role="review") |
| Triage-group | get_next_item(role="blocked") |
| Fleet health operator |
get_next_item(includeClaimed=true) — includes claimed items, shows isClaimed boolean |
get_next_item(includeClaimed=true) and query_items(claimStatus="expired") are the primary operator tools for debugging stale claims:
// Find all expired claims across the fleet
query_items(operation="search", claimStatus="expired")
// Diagnose a specific stalled item (full claim detail including identity)
get_context(itemId="uuid")
// Response includes claimDetail.claimedBy, claimDetail.isExpired, claimDetail.originalClaimedAtget_context(itemId) is the only mode that exposes claimedBy identity — all other surfaces expose at most a isClaimed: boolean.
Resource leasing lets a work item declare — via traits — that it needs exclusive or advisory
access to a named shared resource (a staging slot, a test database, a deploy credential, a
fleet-wide deployment mutex) before it enters the work phase. The server enforces this as a
gate inside advance_item, the same call agents already use to advance — there is no separate
acquire/release verb and no new MCP tool.
Full config syntax (
resources:on a trait, the optional top-level registry, merge semantics, the leaf-task-types-only rule) is inconfig-format.md. This section covers the runtime behavior and operating discipline.
A trait can declare one or more resources: — each a short opaque key (e.g. staging-db,
github-deploy-token) with a mode:
-
exclusive(default) — the server takes a lease on the key when the item enters WORK (viastartorresume— both re-enter WORK). At most one item holds an exclusive key at a time in v1 (the storage is semaphore-ready formaxHolders > 1later; admission is capped at 1 today). A second item entering WORK while the key is held gets rejected — see Contention below. -
advisory— no lease, no lock, no contention possible. The key is recorded into the transition'sconsumedCredentialsaudit trail (alongside any caller-suppliedcredentialRefs) purely for visibility — "this item touched this resource" — without serializing anything.
The single biggest modeling mistake to avoid: exclusive means "this item's work phase must
not overlap another item's work phase for this key" — reserve it for genuinely single-occupancy
resources (a staging slot only one deployment can hold, a test DB only one suite can migrate at a
time, a fleet-wide "one deployment in flight" mutex). A credential that is safe for several agents
to use concurrently (most read-only API tokens, most service credentials with their own
rate-limit budget) should be declared advisory, not exclusive — tagging it exclusive just
because it's "a credential" needlessly serializes unrelated work. Use advisory for "record that
this item touched this credential"; reserve exclusive for "this item must have this resource to
itself."
When an item enters WORK and an exclusive key it declares is already held, advance_item rejects
the transition with a transient error, not a gate block:
{
"itemId": "uuid",
"trigger": "start",
"applied": false,
"error": "Resource lease contended: staging-db",
"errorKind": "transient",
"errorCode": "resource_unavailable",
"retryAfterMs": 30000,
"contendedResources": ["staging-db"]
}This is deliberately shaped differently from a gate-blocked failure (missingNotes / write-more-notes)
because the fix is different: wait and retry, or work a different item — not "fill in more
context." contendedResources names the contended keys only; the current holder's identity is
never disclosed on this path, by design (see Guarantees below). Agents and Ralph-style loop
workers should treat resource_unavailable the same way they treat already_claimed from
claim_item: release any claim on the item and move to a different item, never spin-retry the same
item against the same lock.
On the REST surface, the same contention maps to 409 resource_unavailable with a Retry-After
header (whole seconds, rounded up) — see api-rest.md §10/§14.
An item that declares no resources: can never produce resource_unavailable — the check
short-circuits before any lease-repository access, so the overwhelmingly common case (items with no
resource traits) pays zero extra cost and sees byte-identical behavior to a deployment without this
feature at all.
Resource leasing gives you a real exclusivity guarantee at a specific moment, not a continuous invariant, and not fairness. Read this section before relying on it for anything safety-critical.
What it DOES guarantee:
-
A precondition at WORK entry, not a continuous invariant. At the instant an item's
start/resumetransition acquires an exclusive key, no other item holds that key. The lease is not re-checked or re-validated for the remainder of the item's time in WORK — it holds the key until it leaves WORK (complete, cancel, block/hold, reopen, or the TTL elapses), full stop. Both exit paths release leases:advance_item(and the equivalent REST advance route) release inline on every WORK exit, andcomplete_tree— which applies completions/cancellations directly rather than throughadvance_item— independently releases leases on the same "leaving WORK" condition, so a batch completion viacomplete_treedoes not orphan leases until TTL expiry. -
The exclusivity subject is the work item, not the actor. Leases are keyed on
holder_item_id, not onacquired_by_actor_id(audit metadata only). This makes the guarantee independent of actor identity quality — it holds the same whether or notactor_authenticationis configured, and regardless of how many processes share a self-reported actor id like"orchestrator". -
A single-DB arbiter. Admission is enforced by one SQLite transaction (count-guard + insert, or
refresh) against the
resource_leasestable. There is no distributed coordination — this is not a substitute for a distributed lock service across multiple database instances. -
Keys are opaque labels, never secrets. A resource key (
staging-db,github-deploy-token) is a name, not a value — no credential material is ever stored by this feature. This is enforced by construction (the key/label charset is deliberately narrow), not by redaction — labels are intentionally NOT hidden from readers, since external verifiability is the point.
What it does NOT guarantee:
-
No fairness, no queueing, no waiting acquire. A contended item is rejected immediately with
resource_unavailable— it does not wait in line. Whichever retry happens to land after the holder releases wins; there is no FIFO ordering across contending items. If you need fair ordering, build it in your retry/dispatch logic, not in this feature. -
Crash recovery is TTL expiry or an explicit ADMIN force-release — nothing automatic beyond
that. If a holder crashes mid-WORK, the lease sits until its TTL elapses (default 3600s,
operator-configurable up to a max of 86400s per resource) or an operator calls
DELETE /api/v1/resources/leases/{key}(ADMIN capability — seefleet-deployment.md). There is no background reaper/sweeper process actively watching for crashed holders. - Identity governs early release, not exclusion. Because leases are item-keyed (not actor-keyed), any successful transition that moves the holding item out of WORK releases the lease — there is no per-actor "only the acquiring actor can release" check. This is a deliberate simplification, not an oversight: the exclusivity guarantee above is about the item, not about who is allowed to touch it.
-
One item = one concurrent actor is an assumed modelling obligation. Item-keyed exclusivity
arbitrates between items, not between actors working inside the same item — two sessions
operating under one work item share its lease, and the schema cannot detect that violation (the
server sees transitions, not actions). Identity still matters, one level up, in how you cut
items: model concurrent actors as separate work items at actor granularity. In fleet
deployments,
claim_itemis the opt-in enforcement of exactly this assumption — it binds one verified actor to an item with a TTL, and an agent can hold its item claim and the item's resource leases simultaneously by design. The release-side corollary of the same assumption: any actor's work-exit transition on the shared item releases the lease for all of them. -
The kill switch is read per request, not once at startup.
RESOURCE_LEASES_ENFORCED=falsedisables acquisition (releases still always run) — and because the pipeline is constructed fresh peradvance_item/POST .../advancecall, flipping this env var takes effect on the next call, not after a server restart. Do not assume the usual "env vars need a restart" convention applies here. -
No selector-level awareness.
get_next_itemandclaim_item's selector mode do not know about resource contention — they can still hand an agent an item that will immediately reject withresource_unavailableonstart. This is a known, deliberately deferred gap (see Non-Goals in the implementation plan) — treatresource_unavailableas a normal, expected outcome to handle in your retry loop, not a bug.
Every lease acquire/refresh/release/steal/force-release also writes to an append-only
resource_lease_history table — one row per hold INTERVAL, not per event — answering "who held
resource R at time T" after the fact, which the live resource_leases table (current holder
only) cannot: it still has the answer once a lease has already been released, stolen by another
item, or force-released by an operator. Query it via
GET /api/v1/resources/leases/history?key=&at=&limit= (see api-rest.md §14) —
omit at for a recent-activity feed (newest-first, default 100 rows, max 500); pass at as an
ISO-8601 instant to ask "who held this at exactly this moment" (an open interval is bounded by its
own expiresAt, so a crashed holder's interval reads as "not held" once its TTL lapses, even though
the operator hasn't force-released it yet). Holder/closer actor identity is ADMIN-only, same
redaction rule as the live list. History rows carry no foreign key to the holder work item and
deliberately survive its deletion — this is an audit trail, not a live-state mirror. There is no
pruning or retention policy in v1: the table is append-only and grows with lease-event cardinality.
| Goal | Tool Call |
|---|---|
| Check what to work on next | get_next_item(includeAncestors=true) |
| See all active work | get_context(includeAncestors=true) |
| See container overview | query_items(operation="overview") |
| Check gate before advancing | get_context(itemId="uuid") |
| Advance to next phase | advance_item(transitions=[{itemId, trigger:"start"}]) |
| Fill a note | manage_notes(operation="upsert", notes=[{itemId, key, role, body}]) |
| Guidance consumption |
get_context(itemId="uuid") — guidancePointer gives instructions for filling the first missing required note |
| Find blocked items | get_blocked_items(includeAncestors=true) |
| Create a dependency chain | manage_dependencies(operation="create", pattern="linear", itemIds=[...]) |
| Cancel an item | advance_item(transitions=[{itemId, trigger:"cancel"}]) |
| Reopen a terminal item | advance_item(transitions=[{itemId, trigger:"reopen"}]) |
| Filter by phase | query_items(operation="search", role="work") |
| Claim an item (fleet, ID mode) | claim_item(claims=[{itemId, ttlSeconds:900}], actor={id, kind}, requestId=<uuid>) |
| Claim next eligible (selector) | claim_item(claims=[{selector:{orderBy:"oldest"}}], actor={id, kind}, requestId=<uuid>) |
| Release a claim | claim_item(releases=[{itemId}], actor={id, kind}, requestId=<uuid>) |
| Heartbeat (refresh TTL) |
claim_item(claims=[{itemId}], actor={id, kind}, requestId=<uuid>) — same as claim, TTL refreshed |
| Find expired claims | query_items(operation="search", claimStatus="expired") |
| Diagnose stalled claim |
get_context(itemId="uuid") — returns claimDetail with claimedBy
|
| Filter next item by tag | get_next_item(tags="task-implementation", priority="high") |
| FIFO queue drain | get_next_item(orderBy="oldest") |
| Record a consumed credential/resource | advance_item(transitions=[{itemId, trigger:"start", credentialRefs:["vault:prod-db-password"]}]) |
| Diagnose a stuck resource lease |
get_context(itemId="uuid") — resourceLeases block; or GET /api/v1/resources/leases (ADMIN sees holder actor) |
| Force-release a stuck lease (operator) |
DELETE /api/v1/resources/leases/{key} (ADMIN capability) |
Getting Started
Integration Guides
- Overview
- Bare MCP
- CLAUDE.md-Driven
- Note Schemas
- Plugin: Skills & Hooks
- Output Styles
- Self-Improving Workflow
Reference
Operations
Project