Skip to content
Agent Extensions

Agent Extensions

Agents running inside a Sortie session have two extension surfaces beyond the codebase and rendered prompt: a file-based signaling protocol and callable tools delivered over MCP. The file protocol lets the agent influence orchestration flow by writing a single file. The tools give the agent structured access to tracker data, session metadata, run history, and the issue’s token budget, plus an outbound notification path to a human operator.

See also: agent communication model for why two channels exist, environment variables reference for MCP server environment, WORKFLOW.md configuration for the agent section.


.sortie/status file protocol

The agent-to-orchestrator advisory signal. This is not a tool: it’s an out-of-band file written by the agent to tell the orchestrator “stop dispatching me.” No SDK, no network call, no runtime dependency. One shell command.

Path

.sortie/status relative to the workspace root.

Writing the file

mkdir -p .sortie && echo "blocked" > .sortie/status

Recognized values

ValueMeaning
blockedThe agent cannot proceed without human intervention.
needs-human-reviewWork is complete but requires human review before merging or closing.
no-change-neededThe requested outcome already held before the run started, and the agent made no change to reach it.

All three values suppress continuation retry and eventually release the issue claim, but they diverge at three points in the run: whether the self-review phase runs (blocked never enters it; needs-human-review and no-change-needed do, when self-review is enabled and the issue is still active), what each value means if written again inside that phase, and what happens to the issue on exit. blocked parks the issue where the dispatch drives issue state, or releases the claim otherwise; it performs no tracker transition. needs-human-review triggers a handoff transition to tracker.handoff_state when configured, the issue is still active, the dispatch drives issue state, and the handoff-evidence verdict permits it. no-change-needed triggers the same handoff transition under the same configuration and activity conditions, but targets tracker.no_change_state where that field is set (falling back to tracker.handoff_state otherwise) and is never withheld by the handoff-evidence verdict: a declaration that survives self-review always counts as work observed. See handoff evidence: declaring that nothing needed changing for the full mechanism, including what self-review confirmation requires and what happens with self-review disabled.

Orchestrator behavior

When Sortie detects a recognized value in .sortie/status, all three signals complete the current turn normally and break the turn loop: no further turns are attempted. From there they diverge.

blocked:

  1. Exits the worker run. The signal is excluded from the self-review phase by name, whatever self_review.enabled says.
  2. Performs no tracker transition.
  3. Where the dispatch drives issue state, parks the issue and holds it out of dispatch. See the parked-issue release rules for how a park lifts. Where the dispatch does not drive issue state (a session started by a label command), releases the claim instead.
  4. Does not schedule a continuation retry.

needs-human-review:

  1. Where self_review.enabled and the issue is still active, enters the self-review phase before exiting. A pending completion signal is consumed on entry; the phase reports its own outcome there and can still convert the exit to the blocked disposition.
  2. Exits the worker run.
  3. When tracker.handoff_state is configured, the issue is still active, the dispatch drives issue state, no terminal observation intervenes, and the handoff-evidence verdict permits it, performs the handoff transition.
  4. Releases the issue claim.
  5. Does not schedule a continuation retry.

If the handoff transition in step 3 fails (network error or permission denied), the orchestrator logs a warning and releases the claim without retry. The agent finished its work. Retrying would be wrong.

no-change-needed:

  1. Where self_review.enabled and the issue is still active, enters the self-review phase before exiting, on the same admission terms as needs-human-review. If the phase does not confirm the declaration (anything other than exactly one iteration ending on a pass verdict, with no failing verification result), the declaration is retracted, and the run exits as an ordinary normal exit: the handoff-evidence policy inspects the workspace for the verdict exactly as it would for a run with no declaration at all. On a deployment with self-review disabled, no such check runs and the declaration stands unverified.
  2. Exits the worker run.
  3. A declaration that stands always counts as work observed and is never withheld: when tracker.handoff_state is configured, the issue is still active, the dispatch drives issue state, and no terminal observation intervenes, performs the handoff transition to tracker.no_change_state where that field is set, or to tracker.handoff_state otherwise.
  4. Releases the issue claim, resets the consecutive handoff-absence count, and releases a park held for consecutive absences. Where tracker.handoff_evidence is off, no verdict is computed and neither the reset nor the park release happens; resolving the transition target is the declaration’s only effect there.
  5. Does not schedule a continuation retry.

A parked issue is released by one of three gestures: the tracker state changes to something other than the one it was parked in, the parking label is removed and confirmed gone, or a later run for the issue produces observable work. See the release rules for the confirmation guard and the query-filter caveat. A needs-human-review exit with no tracker.handoff_state configured performs no tracker write at all, so the issue is immediately eligible for re-dispatch on the next poll.

The full interaction between .sortie/status and tracker.handoff_state is documented in the A2O protocol specification.

Edge cases

ConditionBehavior
File absentNormal behavior: continue and retry as configured.
Unrecognized valueIgnored. Warning logged. Normal behavior continues.
Read errorTreated as absent. Warning logged. Never fails the worker run.
Symlink on .sortie/ or statusRejected as a symlink. Treated as absent. Warning logged.

Auto-injection

Sortie appends protocol instructions to the first-turn prompt automatically. The agent receives this text without any workflow author configuration:

If you determine that you cannot make further progress on this task without human
intervention, or if your work is complete and requires human review, or if you
determine that the requested outcome already held and you changed nothing, signal
the orchestrator by running the following, replacing STATUS with exactly one of
the three values below:

    mkdir -p .sortie && echo "STATUS" > .sortie/status

Use "blocked" when you cannot proceed. Use "needs-human-review" when your work is
complete and awaiting review. Use "no-change-needed" when the requested outcome
already held before you started and you made no change to reach it. Do not write
"no-change-needed" if you performed any work. Do not write this file during normal
productive work.

Continuation turns do not repeat the instructions. You can include your own instructions in prompt templates too. Duplicates are harmless.

During the self-review phase, a second injected instruction supersedes this one for the duration of the phase: it tells the agent to report through .sortie/review_verdict.json instead, that writing needs-human-review to .sortie/status there neither ends the phase nor substitutes for a verdict, and that blocked still ends the phase. This second instruction names only those two values; it says nothing about no-change-needed. In the loop itself, though, only blocked is read for anything: any other value written during the phase, no-change-needed included, is inert there the same way an in-phase needs-human-review is.

Cleanup and protection

Sortie deletes .sortie/status before each new dispatch, so a stale signal from a previous run cannot affect the new one.

Sortie deletes it again at each point in a run where it acts on a recognized value: when a completion signal admits the run to the self-review phase, and after every review turn and every fix turn inside that phase. Which value was read makes no difference at those points; blocked, needs-human-review, and no-change-needed are all removed. The read after a coding turn deletes nothing, so a recognized value written there stays on disk through teardown on a run that never enters the phase. Every deletion is best-effort and rejects a symlink the same way the read does; a deletion that fails is logged and changes nothing else about the run.

An absent or empty file therefore carries two meanings: the agent has written nothing, or Sortie has already acted on what it wrote. What an after_run hook or a later cat finds is a value Sortie has not acted on.

Sortie writes .sortie/.gitignore (containing *) before any session data reaches disk. This prevents credentials in .sortie/mcp.json from being committed and blocked by GitHub Push Protection.

Full specification

The complete normative spec lives in agent-to-orchestrator-protocol.md in the main repo.


Execution channel

Sortie delivers tools to agents via an MCP stdio server running as a sidecar process. Whether a given session reaches it depends on the agent kind and on where the session runs.

Before each agent session, the worker generates .sortie/mcp.json inside the workspace directory. This file declares the sortie-tools MCP server entry with the absolute path to the sortie binary, the workflow path, and session environment variables. What each adapter does with it differs; see delivery by agent kind.

The agent runtime spawns sortie mcp-server as its own child process. The orchestrator worker does not manage the MCP server lifecycle. Any MCP-compatible agent can call tools without adapter-specific integration.

Session context (issue ID, workspace path, database path, credentials) flows to the MCP server via the env block in .sortie/mcp.json. Credentials (SORTIE_* variables from the orchestrator process) are explicitly included in this block. They do not rely on process inheritance. See MCP server environment for the full variable table.

If the agent block belonging to the session’s own agent kind specifies mcp_config, Sortie merges the file it names with the sortie-tools entry. The operator’s config must not use the reserved server name sortie-tools. The merge happens before the session starts, so an unreadable path or a config declaring sortie-tools fails the attempt whether or not the adapter goes on to forward the result.

Sortie also appends tool documentation to the first-turn prompt for discoverability alongside MCP tools/list. That advertisement is written only for a session that has a channel; a session without one is told nothing about tools. If the agent calls an unrecognized tool name, the MCP server returns an error response and continues the session. It does not stall or crash.

Delivery by agent kind

The worker writes .sortie/mcp.json for every agent kind. Getting its servers to the runtime is the adapter’s part, and there are three outcomes.

Agent kindSession reaches the toolsHow the servers are delivered
claude-codeLocal and SSHThe generated file’s path on --mcp-config. See Claude Code adapter reference.
copilot-cliLocal and SSHThe generated file’s path on --additional-mcp-config as @<path>. See Copilot CLI adapter reference.
codexLocal launch onlyThe runtime accepts no config path, so the generated servers are re-expressed as configuration overrides on the app-server command line. See Codex adapter reference.
opencodeLocal launch onlyThe runtime accepts no config path, so the generated servers are re-expressed as the runtime’s own configuration document, delivered in the turn’s environment. See OpenCode adapter reference.
kiroNeverThe backend profile gate disables MCP under API-key authentication, so there is nothing to deliver to. See Kiro adapter reference.
agent-client-protocolLocal launch only, and only for a server the runtime’s own handshake supportsThe runtime accepts no config path, so the generated servers are re-expressed on session/new. An HTTP server is withheld when the handshake does not advertise HTTP MCP support. See Agent Client Protocol adapter reference.

The three local launch only kinds withhold delivery on an SSH launch deliberately. For codex the route left is the local ssh command line, which would put the configuration’s credential values on an argument list any other user of the orchestrator host can read. Each of the three carries the generated servers on a local launch and on no other. A remote codex, opencode, or agent-client-protocol session reaches no tool, and its first-turn prompt names none.

A session that reaches no tools receives no advertisement either, whichever row it falls in. That is what keeps the prompt and the channel consistent: Sortie does not name a tool it cannot deliver.

For a kind whose adapter delivers the configuration in no form at all, an mcp_config value in that kind’s own block cannot reach the agent. The worker still reads that file and merges its servers into the generated copy, so an unreadable path or a file declaring a sortie-tools server still fails the attempt, and what the merge produces goes nowhere. sortie validate reports that combination as an agent.mcp_config warning naming the kind. Separately, it reports any kind with no channel as an agent.kind.no_tool_channel warning. Both leave the configuration valid, and the run proceeds.


tracker_api

Read and write access to the configured issue tracker (Jira, GitHub Issues, file-based). The agent does not need its own API key. Sortie uses the tracker credentials from WORKFLOW.md. All operations are scoped to the configured tracker.project; the agent cannot access issues in other projects.

tracker_api is a Tier 2 tool: it requires an external dependency (a tracker API with valid credentials and project). Sortie registers the tool only when a valid tracker configuration with credentials and project is present in WORKFLOW.md.

Input schema

The tool accepts a JSON object with these fields:

FieldTypeRequiredDescription
operationstringAlwaysOne of: fetch_issue, fetch_comments, search_issues, transition_issue
issue_idstringfetch_issue, fetch_comments, transition_issueThe tracker-internal issue ID
target_statestringtransition_issueThe target state name (e.g., "In Review")

No additional fields are accepted. Unknown fields produce an invalid_input error.


Operations

fetch_issue

Retrieves a single issue by its tracker-internal ID. Returns the full issue record.

Request:

{"operation": "fetch_issue", "issue_id": "abc123"}

Response data:

{
  "id": "abc123",
  "identifier": "PROJ-42",
  "title": "Add retry logic to webhook handler",
  "description": "The webhook handler currently fails silently...",
  "state": "In Progress",
  "priority": 2,
  "labels": ["backend", "reliability"],
  "assignee": "alice",
  "issue_type": "Bug",
  "url": "https://mytracker.example.com/browse/PROJ-42",
  "branch_name": "PROJ-42-retry-logic",
  "parent": {"id": "parent-1", "identifier": "PROJ-40"},
  "comments": [
    {
      "id": "c1",
      "author": "bob",
      "body": "Confirmed in prod.",
      "created_at": "2026-03-25T10:00:00Z"
    }
  ],
  "blocked_by": [],
  "created_at": "2026-03-20T09:00:00Z",
  "updated_at": "2026-03-25T14:30:00Z"
}

Fields that have no value in the tracker return null (for priority, parent, comments) or "" (for string fields). labels and blocked_by return [] when empty.


fetch_comments

Retrieves comments for a specific issue.

Request:

{"operation": "fetch_comments", "issue_id": "abc123"}

Response data:

[
  {
    "id": "c1",
    "author": "alice",
    "body": "Looks good overall.",
    "created_at": "2026-03-25T10:00:00Z"
  },
  {
    "id": "c2",
    "author": "bob",
    "body": "Needs a test for the edge case.",
    "created_at": "2026-03-25T11:30:00Z"
  }
]

Each comment contains id, author, body, and created_at (ISO-8601 timestamp).


search_issues

Lists active-state issues in the configured project. No parameters beyond operation.

Request:

{"operation": "search_issues"}

Response data:

[
  {
    "id": "abc123",
    "identifier": "PROJ-42",
    "title": "Add retry logic",
    "state": "To Do",
    "...": "..."
  },
  {
    "id": "def456",
    "identifier": "PROJ-43",
    "title": "Fix flaky test",
    "state": "To Do",
    "...": "..."
  }
]

Each entry has the same shape as a fetch_issue response, with one exception: blocked_by can be null instead of []. This operation lists tracker candidates directly and does not run the per-issue blocker read the dispatch loop performs before starting a session, so on a tracker that cannot carry blockers with its candidate list, an issue whose dependencies have not been read yet reports null rather than an empty list. On Gitea, every search_issues entry reports blocked_by: null, because that read never happens on this path. On GitHub, an entry reports [] when the tracker’s own dependency count already proves the issue has no dependencies, and null otherwise. fetch_issue on the same issue always reads the dependencies route directly and returns [] or a populated array, never null. Jira, Linear, and the file adapter are unaffected: their candidate lists already carry a resolved blocked_by. Only issues matching the configured active_states are returned: the candidates for dispatch, not every issue in the project.


transition_issue

Moves an issue to a new state.

Request:

{
  "operation": "transition_issue",
  "issue_id": "abc123",
  "target_state": "In Review"
}

Response data:

{"transitioned": true}

The target_state value must match a valid state name in the tracker. If the transition is not allowed by the tracker’s workflow rules, the tool returns a tracker_payload_error.


Response envelope

All tracker_api responses use a consistent JSON envelope. This is the same envelope every built-in tool returns; the per-tool sections below show each tool’s data payload and its error kinds.

Success:

{
  "success": true,
  "data": { "..." : "..." }
}

The data field contains the operation-specific payload shown in each operation section above.

Failure:

{
  "success": false,
  "error": {
    "kind": "tracker_auth_error",
    "message": "authentication failed: invalid API key"
  }
}

The kind field is a machine-readable category. The message field is a human-readable description.


Error kinds

KindMeaning
invalid_inputMalformed request: missing required field, unknown field, or unparseable JSON.
unsupported_operationThe operation value is not one of the four recognized operations.
project_scope_violationThe requested issue belongs to a different project than the configured tracker.project.
tracker_transport_errorNetwork or connection failure reaching the tracker API. Also returned on request cancellation or deadline exceeded.
tracker_auth_errorAuthentication failure (HTTP 401/403). The tracker API key is invalid or lacks permissions.
tracker_api_errorTracker API error: rate limiting, 5xx server errors, or other non-200 responses.
tracker_not_foundThe requested issue does not exist (HTTP 404).
tracker_payload_errorMalformed response from the tracker, or an invalid state transition.
internal_errorUnexpected internal failure. If you see this, report a bug.

For retry behavior and operator actions for each tracker error kind, see the error reference.


Project scoping

The tool enforces that all operations target issues within tracker.project from WORKFLOW.md. If the agent passes an issue ID that resolves to a different project, the tool returns a project_scope_violation error before performing any mutation.

This is a defense-in-depth measure. The primary access control is the tracker adapter’s own API scoping: JQL project filter for Jira, repository scope for GitHub. The tool-level check catches edge cases where the API key happens to have cross-project access.

When tracker.project is empty (e.g., the file-based tracker), project scoping is disabled.


sortie_status

Read-only session metadata. The agent calls this tool to check how many turns remain, how long the session has been running, and how many tokens have been consumed. It reads a local file only, with zero external calls.

sortie_status is a Tier 1 tool: no external dependencies. Registered when SORTIE_WORKSPACE is set in the MCP server environment.

Input schema

No parameters. The agent sends an empty JSON object:

{}

How it works

The tool reads .sortie/state.json, a file the worker writes at session start, at the start of each turn, and again whenever a measurement arrives: on a token usage event, on any event carrying a non-zero usage payload, or on a turn’s result carrying a measurement. The tool validates the file before reading: symlinks are rejected, and files larger than 4 KiB are refused.

Response fields

The fields below are returned under data in the standard success envelope:

FieldTypeDescription
turn_numberintegerCurrent turn within the session.
max_turnsintegerConfigured agent.max_turns.
turns_remainingintegermax_turns - turn_number, clamped to 0.
attemptinteger or nullRetry/continuation attempt number. null on first run.
session_duration_secondsfloatWall-clock time since session started (millisecond precision).
tokensobjectToken usage counters for the current session. Its four members are integer or null, and they are null together, exactly when tokens_measured is false.
tokens_measuredbooleanWhether the session’s token figures are a measurement. true before the first turn begins and once a figure has reached the worker; false from the start of turn 1 until one does. Stays false for the life of a session whose agent kind reports no token usage, whatever its runtime sends.

Token usage fields:

FieldTypeDescription
input_tokensinteger or nullTotal input tokens consumed.
output_tokensinteger or nullTotal output tokens generated.
total_tokensinteger or nullSum of input and output tokens.
cache_read_tokensinteger or nullTokens served from prompt cache.

Zeros beside tokens_measured: true are themselves a measurement, and they arise two ways: a session that has not begun a turn, whose zeros are proven because nothing has run, and a runtime that measured the work and found it cost nothing. A state file that carries figures and no tokens_measured field reads as tokens_measured: false, and its figures are not reported.

Example response

Success:

{
  "success": true,
  "data": {
    "turn_number": 3,
    "max_turns": 20,
    "turns_remaining": 17,
    "attempt": null,
    "session_duration_seconds": 142.537,
    "tokens": {
      "input_tokens": 45000,
      "output_tokens": 12000,
      "total_tokens": 57000,
      "cache_read_tokens": 8000
    },
    "tokens_measured": true
  }
}

Success, no measurement yet (the data fields that differ):

{
  "tokens": {
    "input_tokens": null,
    "output_tokens": null,
    "total_tokens": null,
    "cache_read_tokens": null
  },
  "tokens_measured": false
}

Error (state file not yet written):

{
  "success": false,
  "error": {
    "kind": "state_unavailable",
    "message": "state file unavailable: open .sortie/state.json: no such file or directory"
  }
}

The failure shape is the same structured envelope every built-in tool uses.

Error kinds

KindMeaning
state_unavailableThe state file is absent, a symlink, oversized, or unreadable.
state_malformedThe state file is present but unparseable: malformed JSON or an invalid started_at.

workspace_history

Read-only access to prior run history for the current issue. The agent calls this tool to see what happened in previous attempts: whether they succeeded, failed, were cancelled, or failed CI. Useful for avoiding repeated mistakes on retry.

workspace_history is a Tier 1 tool: queries the local SQLite database in read-only mode, no external calls. Registered when both SORTIE_DB_PATH and SORTIE_ISSUE_ID are set and the database can be opened in read-only mode. If the database open fails, the MCP server continues without this tool (non-fatal).

Input schema

No parameters. The agent sends an empty JSON object:

{}

How it works

The tool opens the Sortie SQLite database (SORTIE_DB_PATH) with the ?mode=ro URI parameter and queries the run_history table filtered by the current issue (SORTIE_ISSUE_ID). Returns up to 10 entries, newest first.

Response fields

Returned under data in the standard success envelope:

FieldTypeDescription
issue_idstringThe issue ID this history belongs to.
entriesarrayUp to 10 most recent completed run attempts, newest first.

Per entry:

FieldTypeDescription
attemptintegerAttempt number at time of run (1-based).
agent_adapterstringWhich agent adapter was used (e.g., claude-code).
started_atstringISO-8601 timestamp.
completed_atstringISO-8601 timestamp.
statusstringTerminal status: succeeded, failed, cancelled, ci_failed, needs_person, or budget_stopped. needs_person marks a run that stopped because the agent asked for a decision only a person could give; it is distinct from failed and takes no retry. budget_stopped marks a run the per-issue token ceiling stopped in flight; it is distinct from cancelled, which covers a stall, a terminal tracker state, and shutdown.
errorstring or nullError message if failed; null on success.

Example response

Success with prior runs:

{
  "success": true,
  "data": {
    "issue_id": "42",
    "entries": [
      {
        "attempt": 2,
        "agent_adapter": "claude-code",
        "started_at": "2026-03-30T14:20:00Z",
        "completed_at": "2026-03-30T14:35:12Z",
        "status": "failed",
        "error": "agent turn 3: agent: turn_timeout: turn exceeded the configured 3600000 ms bound; the adapter's own report follows: context deadline exceeded"
      },
      {
        "attempt": 1,
        "agent_adapter": "claude-code",
        "started_at": "2026-03-30T13:00:00Z",
        "completed_at": "2026-03-30T13:45:30Z",
        "status": "succeeded",
        "error": null
      }
    ]
  }
}

No prior runs:

{
  "success": true,
  "data": {
    "issue_id": "42",
    "entries": []
  }
}

Error:

{
  "success": false,
  "error": {
    "kind": "query_failed",
    "message": "query failed: database is locked"
  }
}

The failure shape is the same structured envelope every built-in tool uses.

Error kinds

KindMeaning
query_failedThe history query failed.

cost_budget

Read-only token accounting for the current issue. The agent calls this tool to check cumulative token spend across all of the issue’s sessions and the remaining budget, then decide whether to skip an expensive step, return partial work, or hand off before the token ceiling cancels the session it is running in or blocks the next one. Where sortie_status reports token usage for the current session (read from .sortie/state.json), cost_budget reports cumulative spend across every session for the issue (read from SQLite) and compares it against the configured budget.

cost_budget is a Tier 1 tool: queries the local SQLite database in read-only mode, no external calls. Registered when both SORTIE_DB_PATH and SORTIE_ISSUE_ID are set and the database can be opened in read-only mode. That is the same condition as workspace_history, and the two share the same read-only connection. If the database open fails, the MCP server continues without both tools (non-fatal). When SORTIE_DISPATCH_ID is also set, the reading includes the running session’s recorded spend; without it, only completed sessions count.

Input schema

No parameters. The agent sends an empty JSON object:

{}

How it works

The tool sums total_tokens across the issue’s run_history rows (one per completed session) and adds the running session’s recorded total from session_metadata. The orchestrator updates session_metadata incrementally during the session, throttled to at most one write per issue every two seconds and driven by token usage events, so the running number stays current. That total is added only when the stored dispatch ID matches SORTIE_DISPATCH_ID, so a stale row from an earlier dispatch is never counted.

At session exit, Sortie clears the row’s dispatch ID before recording the finished run in run_history, so a completed session’s row is never mistaken for one still running.

A session whose coding agent reported no token usage is recorded as unmeasured: its spend is unknown, not zero, so it adds nothing to used_tokens and unmeasured_sessions counts it.

Run-history rows written before the token columns existed (migration 011) read as zero, so spend recorded before the upgrade is invisible to the budget. Rows written before the measurement flag existed (migration 012) count as measured, because their provenance is not recoverable.

Response fields

The fields below are returned under data in the standard success envelope:

FieldTypeDescription
used_tokensintegerCumulative total_tokens across the issue’s completed sessions, plus the running session’s recorded spend.
budget_tokensintegerThe configured agent.max_tokens. 0 means unlimited.
remaining_tokensinteger or nullbudget_tokens - used_tokens, floored at 0. null when the budget is unlimited, so the agent can tell “no limit” from “nothing left”.
used_sessionsintegerCompleted sessions for the issue. The running session is not counted. Unmeasured sessions still count here, because agent.max_sessions counts sessions rather than spend.
budget_sessionsintegerThe configured agent.max_sessions. 0 means unlimited.
unmeasured_sessionsintegerCompleted sessions whose coding agent reported no token usage. used_tokens excludes them rather than counting them as zero spend.
used_tokens_completebooleanfalse when unmeasured_sessions is above 0, when no dispatch ID was supplied, or when no session record matches the supplied dispatch ID. true otherwise. On false, treat used_tokens as a lower bound and remaining_tokens as an upper bound.

used_tokens includes the running session while used_sessions excludes it. The asymmetry is deliberate: a session is either finished or not, tokens accrue continuously, and a reading that ignored in-flight spend would be useless at exactly the moment the agent consults it.

The orchestrator enforces the same ceiling against a fresher figure than this one. used_tokens carries the running session’s spend as last written to session_metadata, at most one write per issue every two seconds, while the check that stops a session in flight adds that session’s live in-memory total instead. The reading an agent gets back therefore trails the enforced figure by up to one write interval, and never leads it. When the sum reaches a non-zero budget_tokens, the running session is cancelled and the next re-dispatch for the issue is blocked. See how to control agent costs for the enforcement behavior and budget strategy.

Example response

Success with a configured budget:

{
  "success": true,
  "data": {
    "used_tokens": 384000,
    "budget_tokens": 1000000,
    "remaining_tokens": 616000,
    "used_sessions": 2,
    "budget_sessions": 5,
    "unmeasured_sessions": 0,
    "used_tokens_complete": true
  }
}

Success with an unlimited budget:

{
  "success": true,
  "data": {
    "used_tokens": 384000,
    "budget_tokens": 0,
    "remaining_tokens": null,
    "used_sessions": 2,
    "budget_sessions": 5,
    "unmeasured_sessions": 0,
    "used_tokens_complete": true
  }
}

Success with an incomplete reading:

{
  "success": true,
  "data": {
    "used_tokens": 384000,
    "budget_tokens": 1000000,
    "remaining_tokens": 616000,
    "used_sessions": 3,
    "budget_sessions": 5,
    "unmeasured_sessions": 1,
    "used_tokens_complete": false
  }
}

Error:

{
  "success": false,
  "error": {
    "kind": "query_failed",
    "message": "query failed: database is locked"
  }
}

The failure shape is the same structured envelope every built-in tool uses.

Error kinds

KindMeaning
query_failedThe budget query failed.

notify_operator

Real-time notification to the operator’s configured channels. The agent calls this tool to escalate a decision it should not make alone, report progress on a long task, or flag a blocker, without terminating the session. Sending a notification changes nothing in orchestration: no retry suppression, no tracker transition, no claim release. To tell the orchestrator to stop, the agent writes .sortie/status; see the agent communication model for how the two surfaces relate.

notify_operator is a Tier 2 tool: it makes outbound HTTP POST calls to operator-configured endpoints. Sortie registers the tool only when the notifications list in WORKFLOW.md configures at least one backend (webhook or slack); an empty or absent list leaves the tool unregistered, so the agent is never offered a tool it cannot use. An invalid backend (unknown kind, missing endpoint URL, a secret that resolved to the empty string) is a fatal MCP server startup error, never a partial registration.

Input schema

The tool accepts a JSON object with these fields:

FieldTypeRequiredDescription
severitystringYesOne of: info, warning, critical
titlestringYesNon-empty short summary
bodystringYesNon-empty notification detail
categorystringNoOne of: decision_needed, progress, blocked, completed, other

No additional fields are accepted. Unknown fields, trailing content, out-of-enum values, and an empty title or body all produce an invalid_input error. The agent supplies only the message; every envelope field below is system-owned and absent from the schema.

How it works

Each accepted call produces one notification with two layers. The agent supplies the message (severity, title, body, optional category). The tool fills the envelope from session context the agent cannot set or forge: a generated UUID notification_id, an RFC3339 UTC timestamp, a source identifying the Sortie instance (the hostname), the issue_id and identifier, a dispatch_id, a session_id, the attempt (null on the first run), and the dispatch-frozen agent kind from SORTIE_SESSION_AGENT_KIND.

dispatch_id and session_id come from different places and change on different schedules. dispatch_id arrives once, in the tool server’s own environment (SORTIE_DISPATCH_ID), and stays fixed for every notification sent by that dispatch, including every retry and continuation of a resumed session. session_id is not an environment variable: the worker writes the session id it has accepted from the agent runtime into a .sortie/dispatch.json record, and the tool re-reads that record on every call, accepting the value only when the record’s dispatch_id matches its own. Until the worker has accepted a session id, session_id is empty; an agent kind whose runtime never reports one leaves it empty for the whole dispatch. A record left behind by a different dispatch is rejected the same way a missing record is, so a tool server process that outlives its own dispatch never reports a session id that belongs to someone else.

Delivery goes to every configured backend in configuration order and stops at the first backend that fails, which yields a send_failed error. Partial delivery across backends is not reported in this version. Each backend call carries a 10-second timeout, so a slow endpoint cannot stall the turn indefinitely.

Calls are capped per sortie mcp-server process. The effective cap is the highest non-zero max_per_session across the configured backends, falling back to 20 when every entry is 0 or unset; 0 selects the default, never unlimited. A call past the cap returns rate_limited and sends nothing. The counter counts accepted tool calls, not per-backend sends, and increments only after every backend succeeded, so a failed call does not consume the cap. The counter lives in memory in that one process: an agent runtime that keeps one tool server running for the whole session shares one count across it, but a runtime that starts a fresh tool server process for each turn starts a fresh count with each turn, and a session_id change never resets it either way.

The backends never log or echo the endpoint URL, the request body, or the response body. Delivery failures surface as fixed categories (timeout, connection failure, unauthorized (HTTP <code>), rate limited (HTTP 429), server error (HTTP <code>), unexpected response (HTTP <code>)) in the send_failed message, so a secret-bearing webhook URL never reaches a log or the agent.

What each backend delivers

The webhook backend posts the notification as a single JSON object with generic field names. Any 2xx response counts as success:

{
  "notification_id": "3f8a2c1d-9b4e-4f6a-8c2d-1e7b5a9d0c3f",
  "timestamp": "2026-06-11T14:03:05Z",
  "source": "build-host-01",
  "issue_id": "abc123",
  "identifier": "PROJ-42",
  "dispatch_id": "C5SHAUWY3XNYELVKFV46X6B2UP",
  "session_id": "session-abc-001",
  "attempt": 2,
  "agent": "claude-code",
  "severity": "critical",
  "title": "Decision needed: breaking schema change",
  "body": "Fixing this bug requires dropping a column other services may read. Need a human decision before proceeding.",
  "category": "decision_needed"
}

attempt is null on the first run and a number afterwards. session_id is "" until the worker has accepted one from the agent runtime, and stays "" for the whole dispatch when the agent kind never reports one. category is omitted when the agent did not set one. This outbound webhook backend is unrelated to tracker webhooks: Sortie has no inbound webhook receiver and discovers tracker state only by polling, so the word describes an outbound POST here and nothing else.

The slack backend posts a Slack incoming-webhook body whose text field renders the message with the severity uppercased:

{"text": "[CRITICAL] Decision needed: breaking schema change\nFixing this bug requires dropping a column other services may read. Need a human decision before proceeding."}

The Slack rendering carries only the message. The envelope (issue key, dispatch ID, session ID) does not appear in the Slack text.

Response envelope

Success:

{
  "success": true,
  "data": {
    "delivered": 2,
    "notification_id": "3f8a2c1d-9b4e-4f6a-8c2d-1e7b5a9d0c3f"
  }
}

data.delivered is the number of backends that accepted the notification; on success it equals the number of configured backends.

Failure:

{
  "success": false,
  "error": {
    "kind": "send_failed",
    "message": "notification delivery failed: timeout"
  }
}

Error kinds

KindMeaning
invalid_inputMalformed request: unknown or trailing fields, an out-of-enum severity or category, or an empty title or body.
rate_limitedThe tool server process’s notification cap is reached. Nothing was sent.
send_failedA backend returned a transport failure, a non-2xx response, or an unparseable response. The message is a redacted category and never echoes the URL, request body, or response body.
backend_unavailableNo backend could be resolved at execution time. Defensive: normal operation registers the tool only when a backend is configured.

Response format summary

Every tool uses the same response envelope; each tool’s section above documents what goes in data. This table shows the shape at a glance:

ToolSuccess formatError format
tracker_api{"success": true, "data": {...}}{"success": false, "error": {"kind": "...", "message": "..."}}
sortie_status{"success": true, "data": {...}}{"success": false, "error": {"kind": "...", "message": "..."}}
workspace_history{"success": true, "data": {...}}{"success": false, "error": {"kind": "...", "message": "..."}}
cost_budget{"success": true, "data": {...}}{"success": false, "error": {"kind": "...", "message": "..."}}
notify_operator{"success": true, "data": {...}}{"success": false, "error": {"kind": "...", "message": "..."}}

All tools provide structured error.kind values for programmatic handling. The Tier 1 tools (sortie_status, workspace_history, cost_budget) share a small closed set (state_unavailable, state_malformed, query_failed) because their only failure mode is local state that is missing or unreadable; the Tier 2 tools (tracker_api, notify_operator) carry broader kind sets covering transport, auth, rate-limit, and input failures.


Using tools in prompt templates

Sortie appends tool documentation to the first-turn prompt automatically. You don’t need to reproduce schemas or describe the tools’ existence. Both the prompt text and MCP tools/list reach a session that has an execution channel, and neither reaches one that does not (see delivery by agent kind). Task-specific guidance you write yourself is not gated that way: it renders into the prompt whatever kind the session runs, so phrase it conditionally if a workflow can dispatch to a kind with no channel.

You can add task-specific guidance about when to use tools in your prompt template. Write this in natural language:

You have access to Sortie tools via MCP. Use them to:
- Check related issues with the tracker_api tool (search_issues operation)
- Check your remaining turns with the sortie_status tool
- Review prior run history with the workspace_history tool
- Check cumulative token spend and remaining budget with the cost_budget tool
- Escalate a decision to a human or report progress with the notify_operator tool (when notifications are configured)
- Transition the issue when done with the tracker_api tool (transition_issue operation)

Do not include JSON tool call syntax in prompt templates. An agent with an MCP client calls tools through it, not by writing JSON into the prompt. Natural language instructions are sufficient: the schemas travel with the advertisement.

For detailed patterns and worked examples, see how to use agent tools in prompts.


See also

Was this page helpful?