Stellar ControlMission control · by Stellar Systems v0.1.0

API Guide

Using the API

Base URL, identity, request conventions, idempotency and the main flows of the HTTP API.

The HTTP API is how people and programs drive Stellar Control: the CLI, the web console and the editor all go through it. It submits direct telecommands, launches and follows runs, answers their questions, acknowledges alarms, receives passes, schedules runs, transfers files, and describes the topology and its state. This page explains its conventions and walks through the main flows; the API Reference lists every operation with its schemas.

Base URL and versions#

Every API instance of a cell serves the API under /v1, on api.listen (0.0.0.0:8080 by default): http://localhost:8080/v1/… on a development machine. In a deployment, a reverse proxy terminates HTTPS and WSS in front of it, one sub-domain per cell. The CLI takes the base URL with --api or STELLAR_API.

  • Stateless. The API resolves each request against the current compiled configuration and publishes it on NATS. Any instance answers any request; instances of the same host share their port (SO_REUSEPORT).
  • One version. Every route is under /v1. An unknown path under /v1 answers 404 api::unknown-route, never a page of the web console served beside it.
  • Its own description. GET /v1/openapi.json returns the OpenAPI 3.1 document of the API, generated from its code; the reference of this site is rendered from it.
  • Editor. /v1/editor/… is relayed to the web editor service when api.editor_url is set (404 editor::disabled otherwise); its routes are the editor's. See Web Editor.

Identity#

Callers identify themselves in one of two ways:

WayHeadersAccepted
OIDC tokenAuthorization: Bearer <JWT>Everywhere; checked against the key set of the provider (auth.jwks_file), iss, aud, expiry. Roles are read at auth.roles_claim.
Declared identityX-Stellar-User: <name>, X-Stellar-Role: operator,supervisorWhere the environment of the run allows it: no human orchestration, or identity: declared

A token wins over a declared identity. What each environment requires, and which requests need an identity (launching a run, answering, controlling a run, commanding an alarm, validating a schedule…), is described in Identity and Roles. Refusals are 401 (auth::invalid-token, auth::jwt-required, api::anonymous) or 400 api::anonymous for a command that needs a name.

Shell
# Declared identity, in an environment that accepts it
curl -H 'X-Stellar-User: alice' -H 'X-Stellar-Role: operator' http://localhost:8080/v1/runs

# OIDC token
curl -H "Authorization: Bearer $STELLAR_TOKEN" http://localhost:8080/v1/runs

Requests and replies#

  • Bodies are JSON (Content-Type: application/json), except POST /v1/uploads (raw bytes) and GET /v1/runs/{id}/report (HTML). Unknown fields in a request body are refused (400 api::invalid-body, or 422 with the code of the domain).
  • Quantities are written as in run requests and procedures: "28 V", "30 min", "47.3 deg", "64 kbps". They are converted into the unit expected, and refused when not compatible.
  • Times are RFC 3339 in UTC; identifiers of telecommands and runs are ULIDs.
  • Errors answer a status and {"errors": [{code, message, help}]}: see Error Codes.
  • 503 api::unavailable means the bus (NATS) is unavailable; 503 api::no-configuration that no compiled configuration has been published yet (stellar compile --publish).

Asynchronous commands: 202#

Commands that the MCS carries out over time answer 202 Accepted once the request is resolved and published, not when it is done:

Request202 bodyFollow with
POST /v1/tc{id, duplicate}GET /v1/tc/{target}/{id}/events (WebSocket)
POST /v1/targets/{target}/files/refresh{id, duplicate}the same
POST /v1/runs{run, ir, warnings}GET /v1/runs/{id}/events (WebSocket), GET /v1/runs/{id}
POST /v1/runs/{id}/answersemptythe log of the run (answered or answer_refused)
POST /v1/runs/{id}/suspend, …/resume, …/abortemptythe log (suspended, continued, finished)
POST /v1/alarms/{target}/{alarm}/{action}emptyGET /v1/alarms/watch (WebSocket)

A 202 on a telecommand says nothing of its outcome: a telecommand can still be REJECTED, fail to encode or to be sent, or fail its verification. Follow its events.

Idempotency#

  • Telecommands. POST /v1/tc takes an optional id: a ULID chosen by the client. It becomes the Nats-Msg-Id of the submission, so a retry with the same id (after a timeout, a lost reply) is de-duplicated by JetStream and answers {"id": …, "duplicate": true}: nothing new is sent. Without id, the API generates one, and a retry is a new telecommand.
  • Passes. A pass is written by identifier: sending it again refines it (upsert). A replace_window snapshot can be sent again as is.
  • Schedules. A schedule not fired yet is replaced when created again under the same id.
  • Transfers. Creating the download of a file already being transferred returns the existing transfer. An upload content is stored under its SHA-256: storing it twice gives the same hash.

Main flows#

Submit a telecommand and follow it#

Shell
curl -X POST http://localhost:8080/v1/tc \
  -H 'Content-Type: application/json' -H 'X-Stellar-User: alice' \
  -d '{"target": "sim-1", "telecommand": "set_anode_voltage", "instance": "TCU1",
       "args": {"voltage": "100 V"}}'
# 202 {"id": "01JA2Y8S5V5E6F7G8H9J0K1M2N", "duplicate": false}

websocat ws://localhost:8080/v1/tc/sim-1/01JA2Y8S5V5E6F7G8H9J0K1M2N/events
# {"tc":"01JA2Y…","state":"PENDING","at":"…"}
# {"tc":"01JA2Y…","state":"ENCODED","at":"…"}
# {"tc":"01JA2Y…","state":"SENT","at":"…"}
# {"tc":"01JA2Y…","state":"VERIFIED","at":"…"}

The telecommand is written alone when its name is unique in the platform, else component.name; instance names the instance of a multi-instance component. link chooses a link other than the default one, environment the environment whose link parameters apply (see Direct Telecommands and Values).

Python
import httpx, json, websockets, asyncio

async def send():
    async with httpx.AsyncClient(base_url="http://localhost:8080",
                                 headers={"X-Stellar-User": "alice"}) as api:
        reply = (await api.post("/v1/tc", json={
            "target": "sim-1", "telecommand": "tcu.ping", "instance": "TCU1",
        })).raise_for_status().json()
    url = f"ws://localhost:8080/v1/tc/sim-1/{reply['id']}/events"
    async with websockets.connect(url) as events:
        async for text in events:          # closes on the final state
            event = json.loads(text)
            print(event["state"], event.get("detail", ""))

asyncio.run(send())

Launch a run and answer it#

The body of POST /v1/runs is a run request, as written for stellar run:

Shell
curl -X POST http://localhost:8080/v1/runs \
  -H 'Content-Type: application/json' -H 'X-Stellar-User: alice' \
  -d '{"run": "Hot standby test", "environment": "AIT",
       "targets": {"sat": "sim-1", "psu": "psu-sim-1"},
       "inputs": {"tcu": "TCU2", "bus_voltage": "28 V"}}'
# 202 {"run": "01JA2Z…", "ir": "9f3c…"}

library names the library when several have a procedure of that name; plan binds the run to a pass (usually set by the scheduler). A request that does not resolve answers 422 with every error found; a run refused by the passes 409 (see Running Procedures).

Follow the log with GET /v1/runs/{id}/events (WebSocket), or poll the state:

Shell
curl http://localhost:8080/v1/runs/01JA2Z…
# {"run": "01JA2Z…", "procedure": "Hot standby test", "step": "Hot standby test / Power the PPU",
#  "suspended": false, "pending": {"path": "…", "kind": "ask", "prompt": "Power the PPU?"}}

When pending is set, answer it with the path of the step asking:

Shell
# A confirmation
curl -X POST http://localhost:8080/v1/runs/01JA2Z…/answers -H 'X-Stellar-User: alice' \
  -H 'Content-Type: application/json' -d '{"path": "…", "accepted": true}'
# A typed answer (ask … as f32 V into measured)
curl … -d '{"path": "…", "accepted": true, "value": "27.9 V"}'
# A decision after a failure: replay, skip, fail or abort
curl … -d '{"path": "…", "accepted": true, "value": "replay"}'

Control a run with POST /v1/runs/{id}/suspend, …/resume (body {"choice": "replay"}, "skip" or "abort") and …/abort; get its HTML report with GET /v1/runs/{id}/report. See Questions, Decisions and Control.

Feed passes#

A feeder declared in passes.feeders writes the passes of its source:

Shell
curl -X POST http://localhost:8080/v1/passes/replace_window \
  -H 'Content-Type: application/json' -H 'X-Stellar-User: fds-feeder' \
  -d '{"source": "fds", "target": "sat1-fm",
       "from": "2026-10-02T00:00:00Z", "to": "2026-10-03T00:00:00Z",
       "passes": [{"id": "gs-a-20261002T1014-sat1", "target": "sat1-fm", "station": "gs-a",
                   "aos": "2026-10-02T10:14:32Z", "los": "2026-10-02T10:24:05Z",
                   "max_elevation": "47.3 deg", "source": "fds", "status": "predicted"}]}'
# 200 {"upserted": ["gs-a-20261002T1014-sat1"], "cancelled": []}

See Writing a Pass Feeder.

Download a file#

Shell
# List the on-board directory, then read it
curl -X POST http://localhost:8080/v1/targets/sat1-fm/files/refresh -H 'X-Stellar-User: alice'
curl http://localhost:8080/v1/targets/sat1-fm/files

# Download file 12 (its generation as last listed), with a priority
curl -X POST http://localhost:8080/v1/transfers -H 'Content-Type: application/json' \
  -H 'X-Stellar-User: alice' -d '{"target": "sat1-fm", "file_id": "12", "priority": 5}'
# 201 {"target": "sat1-fm", "file_id": "12", "generation": 3, "state": "REQUESTED", …}

curl http://localhost:8080/v1/transfers/sat1-fm/12/3

A file never listed is refused (422 transfer::not-listed). An upload starts from a run: store the content with POST /v1/uploads (raw body, at most 256 MiB), which answers its SHA-256, and give that hash as the file input of the run. See File Transfers.

Read the state of the system#

RouteWhat
GET /v1/topologyTargets, links as declared and as bound, readiness with reasons, leases
GET /v1/instances, GET /v1/instances/{kind}/{instance}Registered drivers, transports, gateways, connectors and components, their health and bindings
GET /v1/targets/{target}/catalogueComponents, measures and telecommands of a target
GET /v1/targets/{target}/valuesCurrent values of a target
GET /v1/proceduresProcedures to launch, with their roles and inputs
GET /v1/runs?limit=NRecent runs
GET /v1/alarms?target=…&all=trueAlarms not NORMAL or shelved (all with all)
GET /v1/passes, GET /v1/schedules, GET /v1/schedule-rulesPasses, schedules and rules
GET /v1/connectorsOutput connectors and the lag of their consumers

Python client#

The Python SDK has a client of this API for runs of procedures: Client.launch and Client.execute post the run request, Run.events() follows the WebSocket of its log (taking a lost connection up again), Run.wait(on_ask=…) answers questions and decisions as they come, and Run.suspend, resume, abort, status and report call the routes above. A refusal raises ApiError with the codes of the error body.

Python
from stellar_mcs import Client

async with Client("http://localhost:8080", "alice") as mcs:
    result = await mcs.execute(
        "Hot standby test",
        environment="AIT",
        targets={"sat": "sim-1", "psu": "psu-sim-1"},
        inputs={"tcu": "TCU2", "bus_voltage": "28 V"},
        on_ask=lambda question: True,
    )

stellar generate client adds typed functions per procedure on top of it. See API client and Code Generators.

Following over NATS#

A person or a program following many runs or alarms can subscribe to NATS directly with credentials of follow-up: POST /v1/auth/nats exchanges an OIDC token and a user nkey for a NATS JWT that may only subscribe to stellar.run.evt.>, stellar.alarm.evt.> and _INBOX.> (stellar auth nats-creds <file> writes the credentials file). See NATS Accounts and Credentials.

The reference#

The API Reference is generated from GET /v1/openapi.json and groups the operations by tag: telecommands, values, runs, alarms, passes, schedules, files, monitoring, auth, simulation and meta. Load the document in any OpenAPI tool to generate a client:

Shell
curl -o openapi.json http://localhost:8080/v1/openapi.json

Stellar Control · v0.1.0

↑↓ to moveEnter to open