Skip to content

Configure a handler step

A handler step calls one trusted async Python function registered by the host. Use it for deterministic normalization, planning, policy, or projection that is clearer and safer in code. Workflow YAML can select a registered name but cannot import or execute arbitrary Python.

Implement the async contract

A handler receives immutable JSON inputs and a StepContext, then returns a StepOutcome. Register closed input and output schemas around it:

from collections.abc import Mapping

from foliqant.adapters.handlers import HandlerRegistration
from foliqant.core.execution import StepOutcome
from foliqant.core.json import FrozenObject, freeze_json
from foliqant.ports.execution import StepContext

INPUT_SCHEMA = {
    "type": "object",
    "properties": {"message": {"type": "string"}},
    "required": ["message"],
    "additionalProperties": False,
}
OUTPUT_SCHEMA = {
    "type": "object",
    "properties": {
        "queue": {"type": "string", "enum": ["billing"]},
        "message": {"type": "string"},
    },
    "required": ["queue", "message"],
    "additionalProperties": False,
}


def schema(value: dict[str, object]) -> FrozenObject:
    frozen = freeze_json(value)
    assert isinstance(frozen, Mapping)
    return frozen


async def prepare_billing(inputs: FrozenObject, context: StepContext) -> StepOutcome:
    del context
    return StepOutcome({"queue": "billing", "message": inputs["message"]})


HANDLERS = {
    "prepare_billing": HandlerRegistration(
        handler=prepare_billing,
        input_schema=schema(INPUT_SCHEMA),
        output_schema=schema(OUTPUT_SCHEMA),
    )
}

The callback must be an async function or async callable. Both schemas must be confined valid JSON Schema objects. effect defaults to read; the current pipeline rejects registrations with effect: write.

Register before compilation

Pass registrations to prepare_application, because compilation checks every configured name and its schemas. Runtime hooks are supplied separately through RuntimePlugins when the application opens:

from pathlib import Path

from foliqant import RuntimePlugins, open_application, prepare_application

prepared = prepare_application(
    Path("config/settings.yaml"),
    handlers=HANDLERS,
)

async with open_application(
    prepared,
    environment={},
    plugins=RuntimePlugins(),
) as app:
    # await app.run(...)
    pass

RuntimePlugins holds trusted model and MCP runtime hooks; handler registrations remain part of the compiled application digest. Register the same handler set for validation, tests, and production startup.

Select the handler in YAML

# config/routed_intake/billing/prepare.step.yaml
type: handler
handler: prepare_billing
input:
  message:
    pointer: /payload/message

The keys under input must satisfy the registered input schema. Static checks reject provable missing, extra, or incompatible values; runtime validation covers dynamic data before the callback runs. For input {"message": "Please check my invoice"}, this example produces {"status": "completed", "result": {"queue": "billing", "message": "Please check my invoice"}} at /flows/billing/steps/prepare. The result is a JSON object validated against OUTPUT_SCHEMA. A deterministic handler does not need a model profile.

Return completion, review, or failure

StepOutcome(result) completes the step after the output schema validates. StepOutcome(result, needs_review=True) records the validated result and stops the flow for its unresolved route. A handler may also provide route_key, but flow routing still comes from configuration.

The runtime maps contract problems to stable errors:

Condition Result
Input fails the registered schema invalid_input
Return value is not StepOutcome, or its result fails the output schema invalid_output
Handler exceeds the root deadline timeout
Handler raises ServiceError That safe error is preserved
Handler raises another exception dependency_failure without raw exception text

Cancellation propagates. Use native async I/O; the runtime does not move a blocking handler to a worker automatically or prove that external work stopped. Handlers are in-process code and share the host's trust boundary. The default root run_timeout is 300 seconds and applies to handlers too; see execution limits.

The routing tutorial contains complete registered handlers. Test handlers with local inputs and assert both their StepOutcome and the public run_step record; see unit testing.