Skip to Content
DocsWorkflowThe @step decorator

The @step decorator

from loomflow import step

Decorate any async def and it picks up telemetry + audit + journal hooks automatically. Control flow stays as if/await/return. You don’t have to learn a graph DSL just to get observability on a few agent calls.

@step rejects sync functions at decoration time (0.9.13+). 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 (the function gets telemetry / audit / journaling).
  2. Drop @step and pass the plain function directly , Workflow.chain and .route already accept sync callables and dispatch them to a worker thread.

Earlier versions silently wrapped the function and the workflow failed deep inside the runner with 'str' can't be used in 'await' expression. A cryptic error that didn’t name the cause. The new behaviour fails at module import, not after 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. Defaults to fn.__name__.

What it does

When called inside a live RunContext (set by Workflow.run or by an enclosing Agent.run), the decorated function:

  1. Reads the active telemetry from the context.
  2. Opens a loom.workflow.step span tagged with step=<name>, user_id, session_id, pattern="workflow".
  3. Executes the wrapped function inside the span.
  4. Closes the span with the result / exception.

Outside any context, OR when the active telemetry is NoTelemetry, the decorator is transparent. The function runs with no overhead. This is the graceful-fallback property: @step is safe to leave on functions that may be called from a unit test or directly by a user.


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 + free observability: async def my_workflow(text: str) -> str: label = await classify(text) return await respond(text, label)

When called via await my_workflow("hello") directly, no telemetry fires (no RunContext is live). When wrapped in a Workflow or an Agent’s tool dispatch, the per-step spans appear automatically in your trace.


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:

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

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.

When the decorator vs. when Workflow?

You wantUse
Observability on a few awaits, control flow stays as Python@step
A graph the framework can introspect / visualizeWorkflow (any of chain / route / parallel / explicit builder)
Both. Explicit graph + telemetry on functions inside itBuild a Workflow and use @step on the bodies of the functions you pass to add_node

The two layer cleanly. @step is “I just want spans”; Workflow is “I want a structured graph the framework owns.”


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 inside a Workflow.run nests under loom.workflow.run, while a @step inside an Agent.run (called from a tool, say) nests under loom.run.

@step doesn’t make a function a Workflow node. A @step function is just a function with span 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