Skip to content
HTTP API

HTTP API

Sortie embeds an HTTP server that exposes a JSON API, an HTML dashboard, health probes, and Prometheus metrics, all on a single port.

Server configuration

The HTTP server starts by default on 127.0.0.1:7678 with no flags required.

Override the port: pass --port <N> when launching Sortie:

sortie --port 9090 WORKFLOW.md

Override the bind address: pass --host <ADDR> for container deployments:

sortie --host 0.0.0.0 WORKFLOW.md

Workflow config: set server.port and server.host in the WORKFLOW.md front matter extensions:

---
server:
  port: 9090
  host: "0.0.0.0"
# ... rest of config
---

CLI flags take precedence over extension keys. Port 0 disables the server entirely (no TCP listener, no Prometheus metrics). --host must be a parseable IP address; DNS hostnames are not accepted.

When the default port (7678) is already occupied and no port was explicitly requested, Sortie logs a warning and starts without the HTTP server. When an explicit port is in use, Sortie exits with code 1.

The HTTP server is not started in --dry-run mode. Changing the port or host requires a restart: there is no hot-rebind.

For the full server extension schema, see WORKFLOW.md configuration reference. For Prometheus metric definitions, see Prometheus metrics reference.


GET /: HTML dashboard

Server-rendered HTML page showing real-time system state. Auto-refreshes in the browser.

curl http://localhost:7678/

The dashboard displays running sessions (identifier, state, turn count, duration, last event, tokens), the retry queue (identifier, attempt, due-in, error), summary cards (running count, retrying count, available slots, total tokens), uptime, version, aggregate runtime and token totals, and a run history table of completed sessions.

Returns text/html. This is not a JSON endpoint.

Run history entries

The run history table lists recently completed sessions. Each entry contains:

FieldTypeDescription
identifierstringTracker-assigned issue identifier (e.g., "PROJ-123").
attemptintegerOne-based retry attempt number.
statusstringTerminal outcome: "succeeded", "failed", "cancelled", "ci_failed", "needs_person", or "budget_stopped". "budget_stopped" is a session the per-issue token ceiling cancelled while it was still running; error then carries the token figures behind the stop.
workflow_filestringPath to the workflow definition used for this run.
started_atstringFormatted start timestamp.
completed_atstringFormatted completion timestamp.
errorstring or nullError message when the run did not succeed. null when status is "succeeded".
turns_completedintegerNumber of agent turns completed before exit.
review_metadataobject or nullSelf-review outcome. null when self-review was not configured or did not run.

review_metadata structure

When self-review is enabled and runs, review_metadata captures the full audit trail:

FieldTypeDescription
enabledbooleantrue when self-review was configured and ran.
total_iterationsintegerNumber of review iterations completed.
final_verdictstringLast verdict: "pass", "iterate", or "none".
cap_reachedbooleantrue when the iteration cap was reached without a "pass" verdict.
iterationsarrayPer-iteration records (see below).

Each element in iterations:

FieldTypeDescription
iterationinteger1-based iteration number.
diff_size_bytesintegerSize of the diff in bytes before truncation.
diff_truncatedbooleantrue when the diff was truncated to max_diff_bytes.
verification_resultsarrayOutcome of each verification command (see below).
verdictstringParsed verdict from the agent: "pass", "iterate", or empty when unparseable.
verdict_rawstringRaw JSON content of the verdict file. Omitted when the file was absent.
verdict_parse_errorstringNon-empty when the verdict file existed but could not be parsed, or when it was absent. Omitted otherwise.

Each element in verification_results:

FieldTypeDescription
commandstringThe shell command that was executed.
exit_codeintegerProcess exit code. 0 on success; -1 when the command could not be started or timed out.
stdoutstringCaptured standard output, truncated to 65536 bytes.
stderrstringCaptured standard error, truncated to 65536 bytes.
duration_msintegerWall-clock execution time in milliseconds.
timed_outbooleantrue when the command exceeded the verification timeout.
execution_errorstringNon-empty when the command could not be started (binary not found, permission denied). Omitted when the command ran, regardless of exit code.

Example review_metadata for a session that passed on the second iteration:

{
  "enabled": true,
  "iterations": [
    {
      "iteration": 1,
      "diff_size_bytes": 4520,
      "diff_truncated": false,
      "verification_results": [
        {
          "command": "go test ./...",
          "exit_code": 1,
          "stdout": "",
          "stderr": "--- FAIL: TestExample (0.00s)",
          "duration_ms": 3400,
          "timed_out": false
        },
        {
          "command": "go vet ./...",
          "exit_code": 0,
          "stdout": "",
          "stderr": "",
          "duration_ms": 820,
          "timed_out": false
        }
      ],
      "verdict": "iterate"
    },
    {
      "iteration": 2,
      "diff_size_bytes": 4800,
      "diff_truncated": false,
      "verification_results": [
        {
          "command": "go test ./...",
          "exit_code": 0,
          "stdout": "",
          "stderr": "",
          "duration_ms": 3100,
          "timed_out": false
        },
        {
          "command": "go vet ./...",
          "exit_code": 0,
          "stdout": "",
          "stderr": "",
          "duration_ms": 790,
          "timed_out": false
        }
      ],
      "verdict": "pass"
    }
  ],
  "total_iterations": 2,
  "final_verdict": "pass",
  "cap_reached": false
}

review_metadata is persisted as JSON in the review_metadata column of the run_history SQLite table. Query it directly when the dashboard view is insufficient:

sqlite3 .sortie.db "SELECT review_metadata FROM run_history WHERE review_metadata IS NOT NULL ORDER BY started_at DESC LIMIT 1" | python3 -m json.tool

GET /api/v1/state: System state

Returns a full runtime snapshot: running sessions, retry queue, aggregate totals, and rate limits.

curl http://localhost:7678/api/v1/state

Response

{
  "generated_at": "2026-03-26T14:30:00Z",
  "counts": {
    "running": 2,
    "retrying": 1,
    "budget_exhausted": 1
  },
  "running": [
    {
      "issue_id": "abc123",
      "issue_identifier": "MT-649",
      "state": "In Progress",
      "session_id": "session-abc-001",
      "turn_count": 7,
      "last_event": "turn_completed",
      "last_message": "",
      "started_at": "2026-03-26T14:10:12Z",
      "last_event_at": "2026-03-26T14:29:59Z",
      "workspace_path": "/tmp/sortie_workspaces/MT-649",
      "tokens": {
        "input_tokens": 12500,
        "output_tokens": 3200,
        "total_tokens": 15700,
        "cache_read_tokens": 8400
      },
      "model_name": "<model-id-reported-by-the-agent>",
      "api_request_count": 12,
      "requests_by_model": {
        "<model-id-reported-by-the-agent>": 12
      },
      "tool_time_percent": 34.7,
      "api_time_percent": 51.2,
      "tokens_measured": true,
      "usage_arrival": "incremental",
      "usage_attribution": "per_model",
      "tokens_pending": false,
      "api_requests_measured": true
    }
  ],
  "retrying": [
    {
      "issue_id": "def456",
      "issue_identifier": "MT-650",
      "attempt": 3,
      "due_at": "2026-03-26T14:35:00Z",
      "error": "agent exited with code 1"
    }
  ],
  "budget_exhausted": [
    {
      "issue_id": "ghi789",
      "issue_identifier": "MT-651",
      "reason": "session_budget",
      "used_sessions": 3,
      "budget_sessions": 3,
      "used_tokens": null,
      "budget_tokens": 0,
      "unmeasured_sessions": null,
      "exhausted_at": "2026-03-26T14:12:00Z"
    }
  ],
  "agent_totals": {
    "input_tokens": 45000,
    "output_tokens": 18200,
    "total_tokens": 63200,
    "cache_read_tokens": 31500,
    "seconds_running": 2847.3,
    "unmeasured_sessions": 3,
    "running_unreported": 1,
    "running_non_reporting": 0
  },
  "rate_limits": {},
  "active_estimated_cost_usd": 1.47,
  "cost_unpriced_running": 0
}

Field notes

running[] entries:

FieldDescription
display_identifierHuman-facing identifier when the tracker distinguishes it from issue_identifier. Omitted when empty.
turn_countNumber of turns this session has run: the coding turns plus any review and fix turns from self-review. Advances by one the instant a turn starts, the same way for every agent kind, and resets to 0 at the start of each attempt. agent.max_turns caps only the coding turns, so this figure can run higher once self-review is active.
tokensNested object with input_tokens, output_tokens, total_tokens, and cache_read_tokens for this session. total_tokens is input_tokens + output_tokens; cache_read_tokens is a subset of input_tokens, never an addition to it. Each member is an integer or null, and the four are null together, exactly when tokens_measured is false.
tokens_measuredfalse until the coding agent reports token usage for this session, including before the first turn begins; that is what makes the members of tokens null rather than 0. true once any usage figure has been reported. Stays false for the life of a session whose usage_arrival is "none", whatever its runtime sends.
workspace_pathAbsolute filesystem path to the issue’s workspace directory.
model_nameLLM model in use. Omitted when unknown, and when usage_arrival is "none".
api_request_countCount of LLM API requests, one per token_usage event received during this session. Integer or null, and null exactly when api_requests_measured is false.
requests_by_modelBreakdown of API requests per model. Omitted when api_requests_measured is false, when usage_attribution is anything other than "per_model", or when the breakdown is empty.
tool_time_percentPercentage of elapsed wall-clock time spent in tool execution. null when not yet computed.
api_time_percentPercentage of elapsed wall-clock time spent waiting on API calls. null when not yet computed.
usage_arrivalWhen this session’s token figures arrive, frozen at dispatch from the agent kind, its configuration, and whether the session runs over SSH. "incremental" (one figure per LLM API request, while the turn is still running), "turn_end" (at most one figure per turn, after the turn’s work is over), "none" (no figure is ever produced), or "" when the kind declares nothing.
usage_attributionWhat this session’s token figures attribute to, frozen alongside usage_arrival. "per_model" (a figure names the model that produced it), "session_total" (figures are session-level totals with no model), "none" (there is no figure to attribute), or "" when the kind declares nothing.
tokens_pendingtrue only when usage_arrival is "turn_end", tokens_measured is true, and the turn whose figure is still to settle has not ended. The counts in tokens then exclude the turn in progress rather than being final.
api_requests_measuredtrue exactly when api_request_count is non-null. It requires usage_arrival to be "incremental", and then either a figure already counted or no turn yet begun. A "turn_end" session is never measured, because its counter settles at most once per turn rather than once per request; an "incremental" session with nothing counted is not measured either once its first turn has begun, whatever its agent kind declares.

The same row on a session that has measured nothing, showing only the fields that differ:

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

model_name and requests_by_model are absent from that row rather than empty. A null figure is the absence of a measurement, not a measurement of zero: a consumer aggregating figures across rows must skip a null rather than add it as 0.

budget_exhausted[] entries: Issues held out of dispatch by a per-issue budget ceiling (agent.max_sessions or agent.max_tokens).

FieldDescription
reasonsession_budget or token_budget: which ceiling stopped dispatch.
used_sessions, budget_sessionsCompleted sessions for the issue against the configured agent.max_sessions.
used_tokens, budget_tokensMeasured cumulative tokens against the configured agent.max_tokens. used_tokens is null only on a session_budget entry, and there only when the token ceiling has not been evaluated for the issue: agent.max_tokens is 0, or reading the issue’s token spend failed.
unmeasured_sessionsCount of the issue’s sessions whose agent reported no token usage. null exactly when used_tokens is null.
exhausted_atWhen the hold began.

agent_totals: Cumulative across all sessions since Sortie’s database was created, carried over when Sortie restarts; a session whose coding agent has reported no token usage, running or completed, adds nothing to its four token counts. seconds_running includes elapsed time from currently active sessions, not only completed ones. Three further fields disclose, by reason, how many sessions those four counts leave out:

FieldDescription
unmeasured_sessionsEnded sessions whose token usage was never recorded. Persisted, so it survives a restart. An upgrade backfills it from existing run history, but only as far back as Sortie has distinguished a measured run from an unmeasured one; an older run reads as measured and is not counted, even one that actually reported nothing.
running_unreportedCurrently running sessions whose agent kind reports usage but has not reported a figure yet.
running_non_reportingCurrently running sessions whose usage_arrival is "none".

active_estimated_cost_usd: Estimated total cost across currently running sessions, computed from configured token rates and each running session’s agent adapter kind. Sessions whose tokens_measured is false are excluded. Omitted when token rates are not configured or no running session both matches a configured rate and has reported token usage. This is a presentation-layer estimate, not provider billing data.

cost_unpriced_running: Count of running, measured sessions that active_estimated_cost_usd leaves out because their agent kind has no price in token_rates. Present, zero included, exactly when at least one agent kind resolves to a usable rate; omitted otherwise, which includes token_rates being absent, empty, or every entry failing validation. Its presence alone tells a consumer whether cost pricing is configured at all, independent of whether any session currently prices.

rate_limits: Reserved for future use. Currently an empty object.

Status codes

CodeMeaning
200 OKSnapshot returned.
503 Service UnavailableOrchestrator state snapshot could not be produced.

GET /api/v1/{identifier}: Issue detail

Returns issue-specific runtime and debug details. The {identifier} path parameter is the issue identifier (e.g., MT-649), not the internal issue ID.

curl http://localhost:7678/api/v1/MT-649

Response (running issue)

{
  "issue_identifier": "MT-649",
  "issue_id": "abc123",
  "status": "running",
  "workspace": {
    "path": "/tmp/sortie_workspaces/MT-649"
  },
  "attempts": {
    "restart_count": 0,
    "current_retry_attempt": 0
  },
  "running": {
    "issue_id": "abc123",
    "issue_identifier": "MT-649",
    "state": "In Progress",
    "session_id": "session-abc-001",
    "turn_count": 7,
    "last_event": "turn_completed",
    "last_message": "Working on tests",
    "started_at": "2026-03-26T14:10:12Z",
    "last_event_at": "2026-03-26T14:29:59Z",
    "workspace_path": "/tmp/sortie_workspaces/MT-649",
    "tokens": {
      "input_tokens": 12500,
      "output_tokens": 3200,
      "total_tokens": 15700,
      "cache_read_tokens": 8400
    },
    "model_name": "<model-id-reported-by-the-agent>",
    "api_request_count": 12,
    "requests_by_model": {
      "<model-id-reported-by-the-agent>": 12
    },
    "tool_time_percent": 34.7,
    "api_time_percent": 51.2,
    "tokens_measured": true,
    "usage_arrival": "incremental",
    "usage_attribution": "per_model",
    "tokens_pending": false,
    "api_requests_measured": true
  },
  "retry": null,
  "budget_exhausted": null,
  "recent_events": [],
  "last_error": null,
  "tracked": {}
}

Response (retrying issue)

When an issue is in the retry queue rather than actively running, status is "retrying", running is null, and retry is populated:

{
  "issue_identifier": "MT-650",
  "issue_id": "def456",
  "status": "retrying",
  "workspace": null,
  "attempts": {
    "restart_count": 2,
    "current_retry_attempt": 3
  },
  "running": null,
  "retry": {
    "issue_id": "def456",
    "issue_identifier": "MT-650",
    "attempt": 3,
    "due_at": "2026-03-26T14:35:00Z",
    "error": "agent exited with code 1"
  },
  "budget_exhausted": null,
  "recent_events": [],
  "last_error": "agent exited with code 1",
  "tracked": {}
}

Response (budget-exhausted issue)

When an issue has neither a running session nor a pending retry, but is held out of dispatch by a per-issue budget ceiling, status is "budget_exhausted", running and retry are both null, and budget_exhausted is populated:

{
  "issue_identifier": "MT-651",
  "issue_id": "ghi789",
  "status": "budget_exhausted",
  "workspace": null,
  "attempts": {
    "restart_count": 0,
    "current_retry_attempt": 0
  },
  "running": null,
  "retry": null,
  "budget_exhausted": {
    "issue_id": "ghi789",
    "issue_identifier": "MT-651",
    "reason": "session_budget",
    "used_sessions": 3,
    "budget_sessions": 3,
    "used_tokens": null,
    "budget_tokens": 0,
    "unmeasured_sessions": null,
    "exhausted_at": "2026-03-26T14:12:00Z"
  },
  "recent_events": [],
  "last_error": null,
  "tracked": {}
}

Field notes

FieldDescription
statusOne of "running", "retrying", or "budget_exhausted". Derived from which queue the issue appears in; running takes precedence over retrying, which takes precedence over budget_exhausted.
workspaceContains path when the issue has an active workspace. null for retrying and budget-exhausted issues, or when the workspace path is unknown.
attempts.restart_countHow many times this issue has been restarted (attempt minus one, floored at zero).
attempts.current_retry_attemptThe current attempt number. 0 for running and budget-exhausted issues that haven’t retried.
runningFull running entry (same shape as entries in /api/v1/state), or null.
retryFull retry entry, or null.
budget_exhaustedFull budget-exhausted entry (same shape as entries in the budget_exhausted array on /api/v1/state), or null.
recent_eventsReserved for future use. Currently an empty array.
last_errorMost recent error message from the retry queue, or null.
trackedReserved for future use. Currently an empty object.

Status codes

CodeMeaning
200 OKIssue found and returned.
404 Not FoundIdentifier not present in the running set, the retry queue, or the budget-exhausted set. The issue may have completed, or it may not exist.
503 Service UnavailableOrchestrator state snapshot could not be produced.

POST /api/v1/refresh: Trigger poll cycle

Queues an immediate poll and reconciliation cycle. Useful for CI integrations that push issues and want Sortie to pick them up without waiting for the next poll interval.

curl -X POST http://localhost:7678/api/v1/refresh

Response (202 Accepted)

{
  "queued": true,
  "coalesced": false,
  "requested_at": "2026-03-26T14:30:05Z",
  "operations": ["poll", "reconcile"]
}

coalesced: true means a refresh was already pending when your request arrived. The request was not lost. It merged with the existing pending signal. You don’t need to retry.

Response (409 Conflict, draining)

If Sortie is shutting down, the refresh is rejected:

{
  "queued": false,
  "coalesced": false,
  "requested_at": "2026-03-26T14:30:05Z",
  "operations": []
}

Status codes

CodeMeaning
202 AcceptedRefresh queued (or coalesced with a pending refresh).
405 Method Not AllowedUsed a method other than POST.
409 ConflictServer is draining; refresh rejected.

GET /livez: Liveness probe

Lightweight liveness check for container orchestrators. Returns 200 when the process is alive, 503 when draining.

curl http://localhost:7678/livez

Response (200 OK)

{
  "status": "pass"
}

Response (503, draining)

{
  "status": "fail"
}

GET /readyz: Readiness probe

Deep readiness check that validates database connectivity, preflight configuration, and workflow loading. Use this for Kubernetes readiness probes or load balancer health checks.

curl http://localhost:7678/readyz

Response (200 OK)

{
  "status": "pass",
  "version": "1.19.0",
  "uptime_seconds": 3742.8,
  "checks": {
    "database": "pass",
    "preflight": "pass",
    "workflow": "pass"
  }
}

Response (503, one or more checks failed)

{
  "status": "fail",
  "version": "1.19.0",
  "uptime_seconds": 3742.8,
  "checks": {
    "database": "pass",
    "preflight": "fail",
    "workflow": "pass"
  }
}

Each check is independent. status is "pass" only when every individual check passes.

CheckWhat it validates
databaseSQLite database is accessible and responds to a ping.
preflightDispatch preflight validation is passing (agent binary exists, workspace root is writable, etc.).
workflowWorkflow file has been successfully loaded at least once.

Status codes

CodeMeaning
200 OKAll checks pass.
503 Service UnavailableOne or more checks failed, or server is draining.

GET /metrics: Prometheus metrics

Standard Prometheus text exposition format. Available on the same port as all other endpoints when the HTTP server is enabled.

curl http://localhost:7678/metrics

Returns text/plain with Prometheus metric families. For the full metric catalog (names, labels, types, PromQL examples, and cardinality model), see Prometheus metrics reference.


Error envelope

All JSON API errors use a consistent structure:

{
  "error": {
    "code": "issue_not_found",
    "message": "issue identifier \"XYZ-999\" not found in current state"
  }
}

Error codes

CodeHTTP StatusMeaning
issue_not_found404The requested issue identifier is not in any active queue.
snapshot_unavailable503The orchestrator could not produce a state snapshot.
method_not_allowed405The HTTP method is not supported on this endpoint.
internal_error500Unexpected server error (e.g., JSON serialization failure).

Method enforcement

Every endpoint enforces its allowed HTTP method. Sending the wrong method returns 405 Method Not Allowed with an Allow header indicating the correct method, and a JSON error envelope, not plain text.

curl -X DELETE http://localhost:7678/api/v1/state
{
  "error": {
    "code": "method_not_allowed",
    "message": "method DELETE is not allowed on this endpoint"
  }
}

The response includes the header Allow: GET (or Allow: POST for the refresh endpoint).

Endpoint summary

MethodPathDescriptionContent-Type
GET/HTML dashboardtext/html
GET/livezLiveness probeapplication/json
GET/readyzReadiness probeapplication/json
GET/api/v1/stateFull system state snapshotapplication/json
GET/api/v1/{identifier}Per-issue detailapplication/json
POST/api/v1/refreshTrigger immediate poll cycleapplication/json
GET/metricsPrometheus metricstext/plain

Was this page helpful?