The @step decorator
from loomflow import stepDecorate 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:
- Add
asyncto thedef(the function gets telemetry / audit / journaling). - Drop
@stepand pass the plain function directly ,Workflow.chainand.routealready 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").
| Parameter | Type | Default | Description |
|---|---|---|---|
fn | Callable[..., Awaitable[Any]] | None | None | The function being decorated. Provided implicitly when used as @step without parentheses. |
name | str | None | None | Override 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:
- Reads the active telemetry from the context.
- Opens a
loom.workflow.stepspan tagged withstep=<name>,user_id,session_id,pattern="workflow". - Executes the wrapped function inside the span.
- 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 spanCustom 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 want | Use |
|---|---|
Observability on a few awaits, control flow stays as Python | @step |
| A graph the framework can introspect / visualize | Workflow (any of chain / route / parallel / explicit builder) |
| Both. Explicit graph + telemetry on functions inside it | Build 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:
| Attribute | Description |
|---|---|
step | The step name (function name or name= override). |
user_id | From the active RunContext. Tagged for per-tenant filtering. |
session_id | The active session id (workflow / agent run). |
pattern | Always "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.