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:

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.

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:

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:

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.

What to remember

Where this shows up