Skip to Content
WorkflowThe @step decorator

The @step decorator

from loomflow import step

@step marks an async def as a named step, so that when it runs inside a Workflow it gets its own loom.workflow.step span (when the workflow has telemetry=) and its own audit entries (when it has audit_log=). Use it to see the helper functions a workflow node calls as separate, timed steps in your trace and audit log. Control flow stays as plain if/await/return; outside a workflow the decorator does nothing.

@step rejects sync functions at decoration time. Applying @step to a regular def raises TypeError immediately on import, naming the offending function and listing both fixes:

  1. Add async to the def.
  2. Drop @step and pass the plain function directly: Workflow.chain and .route already accept sync callables and dispatch them to a worker thread.

The error fires at module import, before a workflow has started.


Signature

def step( fn: Callable[..., Awaitable[Any]] | None = None, *, name: str | None = None, ) -> Any: ...

Use as either @step (uses the function’s __name__) or @step(name="custom-step-name").

ParameterTypeDefaultDescription
fnCallable[..., Awaitable[Any]] | NoneNoneThe function being decorated. Provided implicitly when used as @step without parentheses.
namestr | NoneNoneOverride the step name shown in telemetry and audit entries. Defaults to fn.__name__.

What it does

When called during a Workflow.run / Workflow.stream, the decorated function:

  1. Reads the workflow’s telemetry and audit log from the live run context.
  2. Opens a loom.workflow.step span tagged with step=<name>, user_id, session_id, pattern="workflow" (when the workflow has telemetry=).
  3. Writes a step_started audit entry, runs the wrapped function, then writes step_completed, or step_failed with the error if it raised (when the workflow has audit_log=). Each entry’s payload names the workflow and the step ({"workflow": ..., "step": ...}) and carries the run’s user_id.
  4. Closes the span with the result / exception.

Everywhere else (called directly, from a unit test, from an agent’s tool, or inside a workflow with neither telemetry nor an audit log), the decorator is transparent and the function runs with no overhead. That makes @step safe to leave on functions that are also called outside a workflow.

@step doesn’t journal the step, so a durable runtime never replays it: it runs again whenever it’s called.


Example

from loomflow import Agent, step classifier = Agent("Classify the request.", model="gpt-4.1-mini") billing = Agent("Handle billing.", model="gpt-4.1-mini") tech = Agent("Handle tech.", model="gpt-4.1-mini") @step async def classify(text: str) -> str: return (await classifier.run(text)).output @step async def respond(text: str, label: str) -> str: handler = billing if label == "billing" else tech return (await handler.run(text)).output # Plain Python control flow with a span on each step: async def handle(text: str) -> str: label = await classify(text) return await respond(text, label)

Called directly (await handle("hello")), no spans fire. Run it as a workflow node with telemetry wired and the classify / respond spans appear in your trace, nested under the node’s own span:

from loomflow import Workflow from loomflow.observability import InMemoryTelemetry telemetry = InMemoryTelemetry() wf = Workflow.chain([handle], telemetry=telemetry) await wf.run("My invoice is wrong") print([s.attributes["step"] for s in telemetry.spans()]) # ['classify', 'respond', 'handle']

Naming

The decorator preserves the wrapped function’s __name__ and __qualname__ so introspection helpers (Sphinx autodoc, debuggers) see the original identity. The wrapped function is also accessible via fn.__wrapped__ for unit tests that want to bypass the span and audit entries:

@step async def classify(text: str) -> str: ... # In a test: result = await classify.__wrapped__("hello") # skips span and audit

Custom step name

@step(name="ml-classifier-v2") async def classify(text: str) -> str: ...

The span will be tagged step=ml-classifier-v2 regardless of the function’s __name__. Useful when:

  • The function name is auto-generated or unstable (factory-built closures).
  • You want to keep the trace name decoupled from refactors.
  • You’re A/B-testing two variants under the same logical name.

@step or a Workflow node?

You wantUse
A graph the framework can run, introspect and visualizeWorkflow (any of chain / route / parallel / explicit builder)
Finer-grained spans and audit entries for the helpers a node calls@step on those helpers, inside a Workflow with telemetry or an audit log

Every workflow node already gets its own span and its own step_started / step_completed audit entries (payload node=). @step adds them for the work inside a node (payload step=), so you can see which helper was slow or failed.


Telemetry attributes

Each loom.workflow.step span carries:

AttributeDescription
stepThe step name (function name or name= override).
user_idFrom the active RunContext. Tagged for per-tenant filtering.
session_idThe active session id (workflow / agent run).
patternAlways "workflow". Lets dashboards filter workflow vs agent traces.

The span is a child of whatever span was active when the function ran, so a @step called from a workflow node nests under that node’s loom.workflow.step span.

@step doesn’t make a function a Workflow node. A @step function is just a function with span and audit instrumentation. To put it in a graph, pass it to Workflow.chain([fn]) / add_node(name, fn). The decorator and the graph builder are independent. Use either, both, or neither, depending on what you need.

Last updated on