Stability & API Surface

September 10, 2026 · View on GitHub

Issue-Orchestrator is an early-beta 0.x project. This page is the single source of truth for what you can depend on and what may change under you: every public-facing surface, its stability tier, and the release mechanics that back those promises.

Per-surface usage documentation lives with each surface (linked below). This page owns the inventory and the policy.

The short version

While the version starts with 0., the public API is not stable. Any surface on this page may change in a minor release, except the SSE event envelope, whose schema version makes a breaking change detectable at runtime. Every 0.x release is published as a GitHub pre-release so that instability is visible from the releases page, not just from prose.

Stability tiers

TierWhat it means during 0.x
VersionedThe payload itself carries a schema version, so a consumer can detect a version it does not understand at runtime instead of misparsing it. A breaking change bumps that version.
ContractedThe shape is owned by a checked-in schema artifact and a drift test fails when code and artifact disagree, so a breaking change is visible in review. The payload carries no runtime version — you find out at review time, not at request time.
SupportedIntended for external use and documented. May change between minor versions; changes are called out in release notes.
RetiredThe capability is gone. The name is still accepted so an old script gets a clear pointer to the replacement instead of an "invalid choice" error, but every invocation fails. Nothing to migrate to a newer flag — migrate off the command.
ExperimentalUsable, but names, arguments, and return shapes may change or be removed in any release without notice. Do not build automation you cannot re-point.
InternalNot for third-party use. No compatibility promise of any kind, including within a patch release. Reachable does not mean supported.
First-party coupledShips from this repo and is expected to move in lockstep with the Python package. Version skew is not supported.

Only Versioned promises runtime detectability. Contracted is deliberately weaker and is the honest label for everything generated from a committed schema artifact but served without a version field.

Surface inventory

SurfaceWherePublic?Tier during 0.x
Config YAML schemainfra/settings_schema.py, Configuration ReferenceYesSupported
CLI (issue-orchestrator …)entrypoints/cli_parser.pyYesSupported (per-command tiers below)
Agent completion contracts (coding-done, reviewer-done)entrypoints/cli_tools/Yes (agent-facing)Supported
MCP server tools (orchestrator.*)entrypoints/mcp_server.pyYesExperimental
Repository Engine dashboard event stream (GET /api/events)entrypoints/web.py, events/sse_envelope.pyYesVersioned
Other SSE streams (Control API, Control Center repo status)entrypoints/control_api.py, entrypoints/control_api_repo_routes.pyNoInternal
Schema-backed SSE payloads and view modelscontracts/public/, contracts/public.pyYesContracted (subset listed below)
All other SSE event payloadsevents/catalog.pyYesExperimental
Contracted HTTP routesdocs/api/ui-openapi.jsonYesContracted
All other /api/* and /control/* routesentrypoints/control_api.py, entrypoints/web.pyNoInternal
Python package (import issue_orchestrator)src/issue_orchestrator/NoInternal
Plugin entry pointsinfra/hooks/hookspec.py, infra/ai_keys.pyYesExperimental
VS Code extension ↔ packagepackages/vscode, VS Code IntegrationFirst-partyFirst-party coupled

Config YAML schema — supported

.issue-orchestrator/config/modes/<mode>/<name>.yaml is the primary way you configure the orchestrator, and it is a supported surface. The schema is generated from infra/settings_schema.py into the Configuration Reference, and a drift test (tests/unit/test_settings_schema.py) keeps the two in sync.

During 0.x, keys may be added, renamed, or moved between minor versions. Unknown keys are rejected rather than ignored, so a renamed key fails loudly at startup instead of silently doing nothing. Run issue-orchestrator doctor after upgrading.

CLI — supported

issue-orchestrator --help is authoritative for flags. The top-level command set is declared as data in CLI_COMMAND_SURFACE (entrypoints/cli_parser.py), and this table is the same set with the tier each command carries:

CommandGroupTier
startRuntimeSupported
statusRuntimeSupported
attachRuntimeRetired
switchRuntimeRetired
dashboardRuntimeRetired
outputRuntimeRetired
pauseRuntimeSupported
resumeRuntimeSupported
tech_leadRuntimeSupported
health-reviewRuntimeSupported
reconcile-case-filesRuntimeSupported
refreshRuntimeSupported
restartRuntimeSupported
setupSetupSupported
initSetupSupported
verifySetupSupported
setup-hooksSetupSupported
setup-guardrailsSetupSupported
authCredentialsSupported
keysCredentialsSupported
doctorDiagnosticsSupported
auditDiagnosticsSupported
traceDiagnosticsSupported
demoDiagnosticsSupported
executor-statusDiagnosticsSupported
test-resetDevelopmentInternal
e2e-resetDevelopmentInternal

Supported command names are stable within a minor version; flags may change between minor versions. Prefer --config and --set path=value over positional coupling in scripts.

attach, switch, dashboard, and output are Retired: the parser still accepts them, but each one prints where to go instead and exits non-zero. They predate the web dashboard and the session recordings that replaced them. Do not treat a Retired command as working — if a script calls one, it is already failing.

test-reset and e2e-reset operate on test and E2E state and carry no compatibility promise of any kind — they are reachable, not supported.

tests/unit/test_cli.py::TestRetiredCommandStubs pins these tiers to the handlers: it invokes every Retired command and requires a non-zero exit, and it fails if any command declared otherwise is in fact a failing stub. The tier cannot drift from the behavior again.

The console scripts installed by the package:

ScriptAudienceTier
issue-orchestratorOperatorsSupported
issue-orchestrator-mcpMCP clients (VS Code)Experimental
coding-doneCoding/rework agentsSupported
reviewer-doneReview agentsSupported
exchange-respondReview-exchange agentsExperimental
prepush-checkAgents and git hooksSupported
lane-runValidation-lane callers, in any repoExperimental
verify-agent-sandboxGuardrail verificationInternal

lane-run is installed as a console script so a repository with no Python environment of its own can dispatch a validation lane without naming this package's interpreter by absolute path — see HTCondor Validation Lanes. It is Experimental because the flag surface has already moved once: --request-cpus, --priority and the rest became .issue-orchestrator/lanes.yaml rows, and a test now asserts those flags do not exist. The exit codes are the part worth depending on, and tests/unit/test_console_script_entry_points.py drives the declared entry point the way the generated wrapper does to keep them honest.

Agent completion contracts — supported

coding-done and reviewer-done are how agents report intent; the orchestrator validates that intent as untrusted input and decides what happens next. They are a supported, agent-facing surface: prompts and target-repo guardrails depend on them, so the subcommand names (completed, blocked, needs_human, approved, changes_requested) are stable within a minor version, while flags may gain options between minors. See AGENT_PROTOCOL.md.

MCP server tools — experimental

The MCP server (issue-orchestrator-mcp, stdio transport only) exposes these tools. Names, arguments, and return shapes may change in any 0.x release.

ToolPurpose
orchestrator.statusCurrent orchestrator status
orchestrator.startStart the orchestrator for the configured repo
orchestrator.stopStop the orchestrator
orchestrator.pausePause issue claiming
orchestrator.resumeResume issue claiming
orchestrator.refreshForce an immediate issue refresh
orchestrator.shutdownShut down; force=True also requires confirm=True
orchestrator.snapshotFull state snapshot
orchestrator.stateUnified dashboard state
orchestrator.urlsDashboard and API URLs
orchestrator.doctorRun diagnostics
orchestrator.session.worktreeWorktree path for an issue's session
orchestrator.session.manifestRun manifest for an issue's session
orchestrator.session.phasesPhase history for an issue's session
orchestrator.session.claude_logAgent log tail
orchestrator.session.orchestrator_logOrchestrator log for the session
orchestrator.session.killKill an issue's session
orchestrator.session.focusFocus the session terminal
orchestrator.reposList registered repos
orchestrator.repos.startStart the orchestrator for a repo path
orchestrator.repos.stopStop the orchestrator for a repo path

The registered set is declared as data in MCP_TOOLS (entrypoints/mcp_server.py) and drift-tested against this table by tests/unit/test_public_api_surface_docs.py.

Two deliberate omissions, not oversights: there is no tool that types free-form text into a running agent session (a prompt-injection primitive), and the transport is stdio only. Detailed usage documentation is tracked separately in issue #6463; see VS Code Integration for client setup today.

Repository Engine dashboard event stream — versioned

This tier applies to exactly one endpoint: GET /api/events as served by a Repository Engine's dashboard app, implemented by entrypoints.web.events. That is the stream the browser dashboard consumes and the one the contracted payload schemas describe. It is the only surface that promises runtime version detection, and the model the others should grow toward.

This repository serves three SSE endpoints, and only the first is public:

EndpointRouteScopeTier
issue_orchestrator.entrypoints.web.events/api/eventsRepository Engine dashboardVersioned
issue_orchestrator.entrypoints.control_api.events/api/eventsControl API (Control Center)Internal
issue_orchestrator.entrypoints.control_api_repo_routes.control_events/control/eventsControl Center repo statusInternal

The two /api/events rows are different implementations at the same path on different apps, which is exactly why the promise is bound to the handler and not to the path. The dashboard app registers its own routes before mounting the Control API, so on an engine dashboard /api/events is the versioned stream; on the Control Center it is the internal EventHub stream, whose wire object (event_id, type, issue_key, payload) carries no envelope version and exists for test automation. /control/events streams Control Center repo-status snapshots and is likewise internal.

tests/unit/test_public_api_surface_docs.py discovers every SSE endpoint in the source, requires this table to match that set exactly, and asserts that the row tiered Versioned is the handler whose module actually applies the envelope — so the public row cannot drift onto an unversioned stream, and a new stream cannot be added without classifying it.

Every event on the versioned stream carries schema, the EVENT_SCHEMA_VERSION from events/catalog.py. A breaking change to the envelope bumps it, so a consumer can refuse a version it does not understand instead of misparsing it.

That is a guarantee rather than a convention because the field is applied by a single owner at the broadcast boundary — events/sse_envelope.py, called from broadcast_event() — not by each producer. Events emitted as plain dictionaries (the observer, direct broadcasts such as startup_complete) get the same envelope as events built through EventContext, because nothing reaches a subscriber without passing through that function. tests/unit/test_sse_envelope.py covers both producer paths end to end and fails if any other code starts enqueueing to subscribers directly.

run_id and tick_id are narrower, and deliberately so: they identify an orchestrator run and control tick, which a direct broadcast like startup_complete has no meaningful value for. They are added by EventContext.enrich() in events/context.py and are present on control-loop events only. Do not assume every event has them — read them when present, and key on schema for version handling.

Most event names come from the EventName enum (events/catalog.py, guarded by tests/unit/test_event_catalog.py). Two direct broadcasts are the exception and use literal names not in the enum: startup_complete and shutdown_requested. Both have committed payload schemas, so they are listed below with the rest of the contracted set.

Consumers should react to events and contract fields, never to log text. Logs are for humans and change freely.

SSE payloads and dashboard view models — contracted

The Contracted tier applies to the payload shapes that have a committed schema artifact — which is a selected subset of the event stream, not all of it:

  • Pydantic contracts in contracts/public.py are the source of truth (PUBLIC_CONTRACTS).
  • Generated JSON Schema artifacts are committed under contracts/public/ (regenerate with python scripts/generate_public_contracts.py).
  • tests/unit/test_public_contract_schemas.py fails when the code and the committed schemas disagree, so a payload change cannot ship silently.

These SSE events have a committed payload schema:

EventPayload schema
session.startedcontracts/public/sse.session.started.json
session.completedcontracts/public/sse.session.completed.json
orchestrator.pausedcontracts/public/sse.orchestrator.paused.json
orchestrator.resumedcontracts/public/sse.orchestrator.resumed.json
queue.changedcontracts/public/sse.queue.changed.json
dependency.blockedcontracts/public/sse.dependency.blocked.json
dependency.unblockedcontracts/public/sse.dependency.unblocked.json
stale.in_progress_detectedcontracts/public/sse.stale.in_progress_detected.json
stale.in_progress_clearedcontracts/public/sse.stale.in_progress_cleared.json
stale.persistent_detectedcontracts/public/sse.stale.persistent_detected.json
history.reconciledcontracts/public/sse.history.reconciled.json
startup_completecontracts/public/sse.startup_complete.json
shutdown_requestedcontracts/public/sse.shutdown_requested.json
validated_work.disposition_observedcontracts/public/sse.validated_work.disposition_observed.json

Non-SSE payloads on the same tier: dashboard.view_model, timeline.issue, and stack.dependency_gate_view.

tests/unit/test_public_api_surface_docs.py asserts this table equals the sse.* entries of PUBLIC_CONTRACTS exactly, in both directions.

Every other event on the stream is Experimental. The EventName catalog has well over a hundred entries; the ones above are the payloads that have been promoted to a committed contract. The rest still arrive inside the versioned envelope — that part of the promise is unconditional — but per the Experimental tier both their payload fields and their names may change or disappear in any release. Only the events listed above have a name and shape you can hold the project to. If you depend on another one, say so on an issue and it can be promoted.

No payload on this tier carries a version field of its own. A breaking change is visible in the schema artifact diff during review; it is not detectable by a client at runtime. That is why these are Contracted and not Versioned — pin to a release, and read the artifact diff when you upgrade.

HTTP routes — a contracted subset, internal remainder

Two HTTP scopes exist, and they are not the same thing:

  • Control Center — the local UI shell that manages repository engines. Listens on :19080 by default and serves the /control/* routes plus the authenticated /api/control-center/* namespace.
  • Repository Engine — one long-lived runtime per repository, serving its own browser dashboard and the remaining /api/* routes. Its ports come from ui.web_port and ui.control_api_port (0 = auto-assign a free port), so do not hardcode them; ask the CLI (issue-orchestrator status) or the orchestrator.urls MCP tool. Each engine dashboard also mounts the /control/* routes, so reaching a route says nothing about its scope.

The contracted HTTP surface is exactly the path set in docs/api/ui-openapi.json. That document is the canonical schema: contracts/ui_openapi_models.py is generated from it, tests/unit/test_ui_openapi_generated.py fails when the two disagree, and the ui_openapi_routes quality guardrail fails when a contracted route goes missing or stops using its generated response model.

PathScope
/api/completion/submissionsRepository Engine
/api/control-center/repositories/{repo_key}/engines/{instance_key}/stop-validated-work-ownerControl Center
/api/control-center/repositories/{repo_key}/validated-workControl Center
/api/issues/{issue_number}/resumeRepository Engine
/api/validated-work/intakeRepository Engine
/api/dialog/blocked-issuesRepository Engine
/api/dialog/configRepository Engine
/api/dialog/debugRepository Engine
/api/dialog/doctorRepository Engine
/api/dialog/infoRepository Engine
/api/dialog/phase/{issue_number}Repository Engine
/api/dialog/session-diagnostics/{issue_number}Repository Engine
/api/dialog/validation-failure/{issue_number}Repository Engine
/api/e2e-run-detail/{run_id}Repository Engine
/api/e2e-run/{run_id}/issue-detail/{issue_number}Repository Engine
/api/e2e-run/{run_id}/test-outputRepository Engine
/api/e2e-runs/recentRepository Engine
/api/issue-detail/{issue_number}Repository Engine
/api/issue-rowsRepository Engine
/api/retrospective-reviewRepository Engine
/api/retrospective-review/preflightRepository Engine
/api/tech-lead/rework-proposalsRepository Engine
/api/tech-lead/runsRepository Engine
/api/view-modelRepository Engine
/api/view-model-snapshotRepository Engine
/control/e2e/run/{run_id}/timelineControl Center
/control/setup/detectControl Center
/control/setup/github-auth/store-personal-tokenControl Center
/control/setup/github-auth/verifyControl Center
/control/setup/previewControl Center
/control/setup/prereqsControl Center
/control/setup/saveControl Center
/control/tools/worktrees/cleanupControl Center

tests/unit/test_public_api_surface_docs.py asserts this table equals the OpenAPI path set exactly, so contracting a new route cannot leave it classified as internal by prose.

Every other /api/* and /control/* route is internal, with one carve-out. The exception is the Repository Engine dashboard's GET /api/events, which is public and Versioned — see the SSE stream table. It is absent from the OpenAPI contract because that document describes JSON request/response operations, not an event stream; its payload shapes are contracted separately under contracts/public/.

The uncontracted remainder — including the other two SSE endpoints — is how the Control Center, the supervisor, and orchestrator-managed agents drive a running engine: bearer-token authenticated, and routes, payloads, and auth semantics change whenever the internal lifecycle needs them to. Reachable is not supported. For third-party automation, use the CLI or the MCP tools, not an uncontracted route.

Note that info.version in the OpenAPI document describes the document, not the responses: it is not delivered on the wire and a client cannot read it off a response. There is no surface-wide HTTP contract version — no field that every contracted response carries and that a breaking change would bump. That is precisely what separates Contracted from Versioned here.

Individual payloads may still version themselves; E2ETimelineEventPayload carries timeline_schema_version, for example. That is a per-payload detail, not a surface guarantee, and it does not make the HTTP surface Versioned. tests/unit/test_public_api_surface_docs.py checks for the surface-wide form — a version field common to every contracted response — and fails if one appears, so this section gets promoted rather than quietly under-selling itself.

Python package — internal

import issue_orchestrator is not a supported API. Module layout follows the hexagonal boundaries described in Internal Architecture and is refactored freely. The supported programmatic entry points are the CLI, the completion tools, and (experimentally) the MCP server.

Plugin entry points — experimental

Two entry point groups let external packages extend the orchestrator:

  • issue_orchestrator.plugins — pluggy plugins implementing the hook spec in infra/hooks/hookspec.py.
  • issue_orchestrator.ai_provider_keys — provider API-key metadata, so optional packages can contribute key names without hardcoding them in core.

Both are real extension points and both are experimental: hook signatures may change while the port set is still settling.

VS Code extension — first-party coupled

The extension in packages/vscode drives the Python package through issue-orchestrator-mcp. Because it depends on the experimental MCP surface, run the extension built from the same commit as the installed Python package. Version skew between an older extension and a newer package (or the reverse) is not supported and is the first thing to rule out when extension commands fail. See VS Code Integration.

Release mechanics

SemVer, and 0.x means what SemVer says it means. Per semver.org clause 4, a 0.y.z version exists for initial development and the public API should not be considered stable. Concretely, during 0.x:

  • Minor (0.10.00.11.0) may break any surface on this page. Config keys may be renamed, CLI flags may change, MCP tools may disappear.
  • Patch (0.10.00.10.1) is reserved for fixes that do not intend to break a documented surface.
  • The Versioned SSE envelope is the one surface whose breakage is detectable at runtime: a breaking envelope change bumps EVENT_SCHEMA_VERSION, so a client can reject a version it does not understand.
  • Contracted surfaces can still break in a minor. What they guarantee is that the break is reviewable: the committed schema artifacts change in the same diff, and the drift tests fail if they do not. Read the artifact diff when you upgrade — a client cannot detect the change at runtime.

Every 0.x release is a GitHub pre-release. make release VERSION=v0.11.0 publishes with gh release create … --prerelease, so 0.x tags carry the pre-release badge and do not claim the "Latest" pointer. This is derived from the version itself (major 0), not from an operator remembering a flag. The first 1.0.0 release publishes as a normal release.

SemVer pre-release identifiers (v0.11.0-beta.1) are not supported by the release tooling today; it requires a stable X.Y.Z version so that package metadata, the lockfile, and the tag cannot drift apart. The 0. prefix plus the GitHub pre-release marking is how instability is signalled during 0.x.

The two-step operator flow (make release-pr, then make release) is in Release Process.

Path to 1.0

Dropping the leading 0 is a promise, so it waits on the experimental surfaces graduating:

  1. MCP tools become supported — the tool set stops moving, arguments and return payloads are contract-typed and drift-tested the way the SSE payloads already are, and usage documentation exists (#6463).
  2. Config schema stops renaming keys — additive-only within a major, with a documented deprecation path for anything that must move.
  3. CLI flags stabilize — command and flag names become additive-only within a major, and the Retired command stubs are deleted outright.
  4. Plugin hook signatures stabilize — the port set settles enough that third-party plugins survive a minor upgrade.
  5. Contracted HTTP payloads become Versioned — the contracted route set carries a version a client can read at runtime, rather than only a schema artifact a human can diff.

Surfaces marked Internal stay internal after 1.0; they are not on the list because stability there is not a goal.

Keeping this page honest

This inventory is enforced, not aspirational. The tables marked with an <!-- inventory:… --> comment are parsed by tests/unit/test_public_api_surface_docs.py and compared for exact set equality against the code, in both directions:

TableCompared against
inventory:cli-commandsCLI_COMMAND_SURFACE — name, group, and tier
inventory:console-scripts[project.scripts] in pyproject.toml
inventory:mcp-toolsMCP_TOOLS in entrypoints/mcp_server.py
inventory:http-routesthe path set in docs/api/ui-openapi.json
inventory:sse-payloadsthe sse.* entries of PUBLIC_CONTRACTS
inventory:sse-streamsevery endpoint returning EventSourceResponse
inventory:tiersevery tier any inventory table uses

So adding a surface fails the build until it is classified here, removing one fails the build until it is deleted from here, and a tier this page invents but never defines fails too. Mentioning a name in prose does not classify it — only a row in the anchored table counts. Relative links on this page are checked to resolve as well, so a moved file cannot leave a dangling promise behind.