Skip to content

2. The endpoints

Seven, and the editor needs exactly one of them to get in.

GET    /api/stages             the specs of the stages this caller may use
GET    /api/meta               what this backend can run  (optional)
GET    /api/secrets            the NAMES of the secrets in the environment
POST   /api/run                {pipeline, vars, mode, delay, secrets} -> {id, state}
GET    /api/run/<id>           the state of the run
GET    /api/run/<id>/events    the event stream (SSE), ?from=N
POST   /api/run/<id>/control   {action: "step"|"resume"|"pause"|"stop"|"delay"}
POST   /api/run/<id>/vars      {set: {...}, drop: [...]}

Nothing about them is StageFlow-specific except the shapes; the example uses FastAPI because it is short, and the protocol is written up in the editor's own backend guide.

The registry, served

@router.get("/stages")
async def stages() -> dict:
    from stageflow import get_stages
    return {"stages": {name: cls.get_specs() for name, cls in get_stages().items()}}

get_specs() is the parsed docstring from step 1, and it needs no argument here on purpose: the prose comes back in every language the build has, as a {locale: text} mapping, and the editor picks one for its reader. There is nothing to negotiate at this endpoint — the specs are fetched once and the language is chosen afterwards, so choosing here would only mean being asked again (Localization).

This is the one endpoint the editor cannot open without — it is also what the connection screen probes, so "connected" means the thing really answered and really allows this origin, not that the address looked like a URL.

A run is a real Session

pipeline = Pipeline.from_dict(payload["pipeline"])
pipeline.validate()          # answer a bad graph from the request, not from a thread

debugger = StepDebugger(mode=mode, delay=delay, on_event=bus.push)
session = Session(
    id=run_id,
    pipeline=pipeline,
    context=Context(vars=start_vars),
    event_handler=lambda event: bus.push(telemetry_to_dict(event)),
    debugger=debugger,
)

A real Session, which is the whole point: the debugger shows what will happen because it is what happens. StepDebugger is the core's own (Step debugging) — it exposes the stop point, the frame and the ability to accept an edit to it, and the endpoints are a thin wrapper over its thread-safe methods.

Each run gets a thread with an event loop of its own, because the HTTP handlers live in the server's. Commands go in through the debugger; events come back out through a log.

The event log, and why it is a log

@router.get("/run/{run_id}/events")
async def run_events(run: CurrentRun, start: FromEvent = 0) -> StreamingResponse:
    return StreamingResponse(
        sse_lines(run.bus, start),
        media_type="text/event-stream; charset=utf-8",
        headers={"X-Accel-Buffering": "no"},   # a buffering proxy holds the tokens back
    )

A log rather than a queue, and every event carries its own index. A subscriber arrives over a separate HTTP request after the run started, and without history it would miss the beginning — which in debugging is the interesting part. ?from=N is the same mechanism used for something else: the editor reads the stream with fetch rather than EventSource (so it can send a credential), and resumes at the event after the last one it saw when a connection drops.

Secrets: names, never values

@router.get("/secrets")
async def secrets() -> dict:
    return {"names": env_secret_names(), "source": "env"}

The browser learns that OPENAI_API_KEY exists. The value is substituted into the starting frame on the server and scrubbed back out of every event on the way to the page, so a key does not return through the debug log. The full story is on Secrets.

Saying what you can run

@router.get("/meta")
async def meta() -> dict:
    from stageflow import capabilities
    return {"api": 1, **capabilities()}

Optional, and two lines, and it removes a whole class of confusion. The editor mirrors the core's node registry as it stood when the editor was built, so an editor newer than your backend would offer a node your runs reject — as Unknown node type: 'map', halfway through, naming neither cause nor cure. capabilities() answers with the registry itself, so a name absent from it is exactly a name a run would refuse, and the editor greys it out instead of guessing.

A backend that does not serve /meta is not second-guessed: nothing is marked, everything works, and the editor says the version is unknown.

CORS, twice

The editor is on another origin, so every answer needs Access-Control-Allow-Origin and the preflight of a JSON POST needs answering. One more case looks identical from the page and is not: the hosted editor is served over https and your backend answers on 127.0.0.1, which Chrome calls a private-network request and refuses unless the preflight is answered with Access-Control-Allow-Private-Network: true.

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["Content-Type", AUTH_HEADER],
    allow_private_network=True,
)

AUTH_HEADER is step 5 arriving early: a header the browser has not been told to allow is a request that never leaves the page.


At this point you have a working backend. The next three steps are about letting somebody else use it.

Next: what may be composed.