Workflows as services
Why registering a workflow in Flux gives you a REST endpoint, an MCP tool, and a CLI subcommand without writing protocol code.
Most Python services start with a function and accumulate scaffolding around it. You write the function, then the FastAPI handler that calls it, then the request and response models so the handler can validate input, then the route registration, then the OpenAPI tags so it appears in /docs, then a typed client so the rest of your codebase doesn’t hand-roll HTTP calls. Then the same dance for the next function.
Flux takes a different position. Once you decorate a function with @workflow and register it with a server, the workflow is reachable over REST, via the CLI, and from the Python SDK without any of that scaffolding. The function is the API.
The decision in one sentence
When you register a workflow with flux workflow register my_workflow.py, the server stores the workflow in its catalog and that registration is enough — the existing generic routes immediately serve the workflow under its qualified name, and the CLI and SDK reach it through the same routes. You do not write a handler. You do not write a route file. You do not name an endpoint.
What registration actually does
Look at what flux/server.py exposes. The run route is registered once, at server startup:
@api.post("/workflows/{namespace}/{workflow_name}/run/{mode}")
async def workflows_run_ns(
namespace: str,
workflow_name: str,
input: Any = Body(None),
mode: str = "async",
...
):
The path captures the namespace, the workflow name, and the mode (sync, async, or stream) as positional segments. There is no per-workflow handler. Inside the handler, the server reads the workflow from the catalog by namespace and name, builds an ExecutionContext, and dispatches it to a worker. The same handler serves every workflow you have ever registered.
Registration itself is a POST to /workflows with the source file:
curl -X POST http://localhost:8000/workflows -F "file=@my_workflow.py"
The server parses the source, finds every @workflow.with_options(...)-decorated function, persists each one into the workflows table via WorkflowCatalog.save, and records the namespace, name, version, and metadata. Metadata includes the workflow’s docstring (extracted by AST in extract_workflow_description) and the input JSON schema (extracted by extract_workflow_input_schema when the workflow’s input type is a Pydantic model). That’s the entire publication step.
Once the catalog row exists, the workflow is reachable:
- REST.
POST /workflows/{namespace}/{name}/run/{mode}runs it.GET /workflows/{namespace}/{name}/status/{execution_id}polls it. The full route surface is documented in Running workflows from the REST API. - CLI.
flux workflow run namespace/name --input '...'shells out to the same route. The CLI is a generic Click application defined influx/cli.py; there is no code generation, no per-workflow CLI command. Names are arguments. - SDK.
flux.client.FluxClientwraps the REST API. You instantiate it once and call any registered workflow by name.
The schemas
The decorator does not require you to write request and response models. The server derives the input schema from the workflow’s first parameter type.
If your workflow is declared as async def ingest(ctx: ExecutionContext[IngestInput]) and IngestInput is a Pydantic BaseModel, the catalog extracts IngestInput.model_json_schema() and stores it on the workflow row at registration time. That schema is then available through GET /workflows/{namespace}/{name} — clients can fetch it to understand what the workflow expects.
If the input type is plain (a dict, a str, no type at all), the run endpoint accepts whatever you POST and passes it through. There is no validation layer in the middle.
The output side is simpler still: the workflow’s return value is serialized via Flux’s JSON encoder and included in the execution response. There is no separate response model declaration.
For a more elaborate input/output contract, see the API contract section below.
What the reader gives up
The single-handler model is opinionated and that opinion has costs.
- No custom request handlers. You cannot intercept a workflow run to rewrite the request, attach custom middleware, or perform business-specific validation before the workflow function fires. The handler is fixed and serves every workflow uniformly.
- No per-workflow middleware or rate limits. The standard FastAPI middleware stack (auth, logging, CORS) runs against the generic route. There is no hook to say “rate-limit
billing/invoiceat 100 requests/minute but leaveanalytics/reportalone.” - No custom URL shape. The URL is
/workflows/{namespace}/{workflow_name}/run/{mode}. You don’t pick the path. (If you want a cleaner URL shape, the Workflow Services feature lets you alias workflows under/services/{service}/{workflow}and run a standalone service process at root-level URLs — but that’s a layer on top of the same model.)
What you get in exchange is that the surface area of “publishing a workflow” collapses to one decorator and one POST. There is no parallel scaffolding to keep in sync with the workflow code; there is no second source of truth for what the workflow accepts and returns.
Per-workflow authorization is still available. The run handler calls await auth_service.authorize(identity, namespace, workflow_name, ...), which checks the workflow:{namespace}:{workflow_name}:run permission against the caller’s role bindings. You can scope a role to a single workflow, an entire namespace, or all workflows. That happens through configuration, not code.
MCP exposure
Flux ships an MCP server that you start with flux start mcp. It bridges Model Context Protocol clients (Claude Desktop, agents, anything implementing the MCP spec) to the Flux HTTP API.
The MCP server registers a fixed set of tools: list_workflows, get_workflow_details, execute_workflow_async, execute_workflow_sync, get_execution_status, cancel_execution, and so on (the full list is in Running workflows from MCP). It does not register one tool per workflow. An MCP client that connects sees a generic toolset and calls workflows by name through execute_workflow_async("ingest", {"source": "s3://..."}, "default").
If you want one MCP tool per workflow — so an agent can call your workflow by its qualified name with strongly-typed arguments derived from the input schema — that is what Workflow Services gives you. Create a service with flux service create billing --namespace billing --mcp and each workflow in billing gets five dedicated MCP tools ({name}, {name}_async, resume_{name}, resume_{name}_async, status_{name}). If the input type is a Pydantic model, the tool exposes its fields as discrete parameters.
The split:
- System-wide MCP (
flux start mcp): one server, fixed tools, workflows called by name. Good for general-purpose AI clients managing many workflows. - Per-service MCP (
flux service create ... --mcp): one tool per workflow, with typed parameters. Good for agent-driven invocation of a specific workflow surface.
Either way, you write zero MCP protocol code. The contract between Flux and MCP is the workflow definition itself.
The contract
The decorator is the API spec. Specifically:
namespaceandnameon@workflow.with_optionsbecome the URL path segments.workflow.with_options(name="ingest", namespace="billing")is reachable at/workflows/billing/ingest/run/{mode}. If you don’t passnamespace, the default value"default"is used.- The workflow’s input type (the
TinExecutionContext[T]) is the request body schema. IfTis a Pydantic model, the JSON schema is published with the workflow metadata atGET /workflows/{namespace}/{name}. - The workflow’s return value is the response body.
- The docstring is the API description, extracted via AST and stored alongside the workflow.
This is the closest Python gets to “the endpoint is the function.” There is no separate API spec to drift from the implementation, because there is no separate API spec.
A consequence: if you rename a workflow, you rename its URL. If you change its input type, you change its request body shape. The decorator is load-bearing. Treat it the way you’d treat a package name — bumping a workflow’s name or namespace is a breaking change.
Operational implications
A few operational properties shape day-to-day work, even though the details belong to the Operate section.
- Versioning is built in. Re-registering the same workflow file creates a new version row. The run handler accepts a
?version=Nquery parameter; without it, the latest version is used. Clients can pin to a specific version without coordinating with the workflow author. - Auth scoping is per-workflow. The permission
workflow:{namespace}:{name}:rungrants or denies access at the granularity of one workflow, even though all workflows share a handler. Wildcards (workflow:billing:*:run) cover namespaces. - OpenAPI is generated automatically. FastAPI’s
/docslists the workflow run/status/cancel endpoints. The workflow metadata — input schemas, docstrings — is available through the catalog endpoints, which clients can introspect. - Schedules and services compose with the same definition.
@workflow.with_options(schedule=cron("0 9 * * *"))creates a<workflow>_autoschedule on registration.flux service createaliases a workflow under a stable service URL. Both build on the same registered definition.
What to remember
- A workflow becomes a REST endpoint, a CLI subcommand, and an SDK call the moment you register it with a Flux server. You don’t write protocol code.
- The route surface is generic: one handler at
/workflows/{namespace}/{workflow_name}/run/{mode}serves every registered workflow. The handler reads the workflow from the catalog by name. - The decorator is the API spec. Name, namespace, input type, return type, and docstring are the publication contract.
- MCP exposure is two-tiered: the system-wide
flux start mcpserver registers a fixed toolset; per-service MCP (created withflux service create ... --mcp) gives one typed tool per workflow. - You give up custom handlers, per-workflow middleware, and custom URL shapes. You get one decorator and one POST in exchange.
Where this shows up
- Running workflows from the REST API — the REST surface in practice: register, run, stream, cancel.
- Running workflows from MCP — the system-wide MCP server with its fixed toolset.
- Workflows as services — the service layer that gives you stable URLs and per-workflow MCP tools.
- System architecture — where the server fits in the larger picture: server, workers, scheduler, catalog.
- Defining workflows — the decorator API that is the contract.