Deployment¶
A project is deployed from its application: what listens, what runs on
a schedule, on which graph. Declare it in Python where a reader sees
the graph, the door and the bound objects together, and let
operonx.toml point at it:
# app/main.py
from operonx.app import Application, Service, asgi, env, http, websocket
APP = Application(
"callbot",
services=[
Service("call", websocket("/ws/call", port=env("WS_PORT", 9922), workers=4),
graph=ws_callbot_pipeline, # (script_data, agent_type)
session="per_connection", max_inflight=4000,
on_session=open_call, on_close=close_call,
on_startup=[startup.warmup]), # in each call worker, nowhere else
Service("admin", asgi("/", port=env("HTTP_PORT", 9923)), app=admin.app),
],
jobs=[nightly],
)
if __name__ == "__main__":
APP.serve() # every listener, in the shape it declares
operonx serve, operonx run and the studio find the file, then read
the object.
- The graph's signature is the door's contract. What
on_sessionbuilds (RunRequest.inputs) must be exactly the graph's runtime parameters; anything else is refused at the door, naming what is missing and what is not a parameter. There is no second list to keep in step. - A door op says what it is.
@op(door="ingress")/@op(door="egress")on an op that reads or writes the session; the built-iningress()/egress()declare it the same way. The studio draws them as doors; the service names none. - The process is shaped by the listeners.
workers=4on a listener runs it as four worker processes (each loads the application again fromoperonx.toml, so it compiles its own engines); a listener with one worker runs in the main process.on_startup=on a service runs in that listener's workers only — the model warmed for the call workers is not warmed again for the admin port.on_startup=on theApplicationruns for every listener.
[[serve]] blocks in operonx.toml are deprecated (1.19) and removed
in 2.0: loading one warns once, naming the file. Move each block to a
Service(...) in app/main.py and set [project] app. [project],
[resources], [tracing], [studio] and [[graph]] stay in the file.
A scheduled job runs inside the server too: Job(..., schedule=schedule(at="07:00",
port=...)) puts its clock on that port (see Jobs and runbooks).
pip install "operonx[serve]"
operonx serve --list # what would run, and where
operonx serve # every listener, each with its workers
operonx run --list # every job of the application (declared in Python)
operonx run nightly # what the deployment's cron calls
The same from Python — for a process that runs uvicorn itself, a test client, or a tool that wants the three lists:
from operonx.app import Application
app = Application.find() # the nearest operonx.toml
app.serve(only=["call"]) # block, serving
asgi = app.asgi(port=9923) # one listener's ASGI app
app.run_sync("nightly") # a job or runbook, with its record
app.describe() # graphs, services, jobs — plain data
A request–reply graph needs no door ops: on http, webhook and
schedule its parameters are filled from the body and query, and its
outputs are the reply. ingress and egress are the door ops of a graph
that handles many items in one run (a call on a websocket); they read the
session the transport minted, so the same graph is served on one day and
run over a file by a job on the next. See Jobs and runbooks.
Every door reads what its caller sends the same way. An HTTP body, a
webhook body and a websocket text frame are JSON by default, so one graph
served on HTTP and on a websocket gets the same item for the same
payload; a websocket bytes frame stays bytes. A body that is not JSON is
answered 400 before a run starts, and a websocket frame that is not
JSON gets {"error": ...} back. codec = "text" (or
websocket(..., codec="text")) passes text through instead. HTTP and
webhook replies carry the run's x-operonx-trace-id header.
One door, several graphs: variants¶
A door whose graph differs by caller — one turn graph per agent, one pipeline per tenant tier — declares the variants instead of threading a "which am I" input through every op or declaring one service per kind:
Service(
"call",
websocket("/ws/call", port=9922),
graph=call_flow, # a module-level @graph
max_inflight=4000,
on_session=open_call,
variants={
"educa_hr": {"turn": educa_hr_turn, "config": "agents/educa_hr/prompts.yaml"},
"ahamove": {"turn": ahamove_turn, "config": "agents/ahamove/prompts.yaml"},
},
)
Each variant fixes some of the @graph's parameters at build time. A bound
value that reads as module:attr is loaded, anything else is a literal. operonx serve compiles one engine
per variant at boot, and on_session picks one per session:
A door with variants has no default. A session that names none, or a
name the door does not declare, is refused at the door with the declared
names in the log — no run is minted. Application.graphs lists one
graph per variant (build[educa_hr], build[ahamove]) under the one
service, each compilable on its own. Worked example:
examples/python/ex18_variants (declared in Python, app.py).
A src/ layout¶
A project that keeps its packages under src/ says so once, and every
module:attr in the manifest resolves from there:
Application.bootstrap() puts each root on sys.path; operonx serve,
operonx run and the studio all go through it.
Configuration¶
The server honours the standard Operonx setup:
.envfor credentials.resources.yamlfor model and consumer configs.bootstrap()at startup.
For Kubernetes / containerised deployments, mount resources.yaml and
provide credentials through the platform's secret store rather than a
file-based .env.
Production checklist¶
- Configure a persistent path for the local trace consumer (or skip it and use Langfuse / OTEL).
- Give every stream listener a
max_inflight; the manifest refuses one without. - Mount your health and admin routes as a
kind = "asgi"entry, beside the graphs. - Pin model versions in
resources.yaml— never referencelatest. - Watch the Tracing backend for token-cost and latency drift.
Where to go next¶
- Architecture overview — internals.