Composition with Agent
You can put an Agent inside a Workflow (as one of its steps), and
you can give an Agent a whole Workflow as a single tool. Use the
first when your code controls the overall flow but one step needs a
model’s judgment; use the second when the model leads but should be
able to run a fixed procedure. Either way, user_id partitioning,
telemetry spans and audit-log entries flow through, whichever one
wraps the other.
Direction 1: Agent inside a Workflow
Pass an Agent instance to any place a step is accepted (add_node,
Workflow.chain([...]), Workflow.route(classifier, {...}),
Workflow.parallel([...])). The framework calls .run(input).output
under the hood and threads the live RunContext
(user_id / session_id / metadata) through.
from loomflow import Agent, Workflow
billing = Agent("Handle billing.", model="claude-opus-4-7", tools=[...])
tech = Agent("Handle tech.", model="claude-opus-4-7", tools=[...])
async def classify(text: str) -> str:
return (await Agent(
"Reply 'billing' or 'tech'.", model="claude-haiku-4-5",
).run(text)).output
support = Workflow.route(classify, {"billing": billing, "tech": tech})
result = await support.run("My card was charged twice.", user_id="alice")What flows through automatically:
user_id: Every nestedAgent.runseesuser_id="alice", memory partitions on it, audit entries attribute to it.session_id: The workflow’s session id flows into the agent runs. Useful for trace correlation.metadata: The run’smetadatareaches Agent nodes, nested Workflow nodes andas_toolworkflows too.- Telemetry parent span: The agent’s
loom.run/loom.turn/loom.toolspans nest under the workflow’sloom.workflow.stepspan automatically. - Audit entries: Workflow
step_started/ agentrun_started/ agenttool_call/ agentrun_completed/ workflowstep_completedall land in the same audit log under the sameuser_id. - Interruptions: An Agent node that comes back interrupted (its
max_turns, a budget, a guardrail) stops the workflow there.result.interruptedisTrue,result.interruption_reasonreads"<node>:<reason>", and no downstream node runs on the partial answer. SeeWorkflowResult. (Before 0.17 the run carried on and the next node got''.)
Direction 2: Workflow inside an Agent
Call wf.as_tool() to get a Tool an Agent can invoke. The whole
workflow runs as one tool call from the agent’s perspective.
from loomflow import Agent, Workflow
# A deterministic refund workflow
async def validate_refund(req: str) -> dict: ...
async def authorize(req: dict) -> dict: ...
async def execute(auth: dict) -> str: ...
refund_flow = Workflow.chain(
[validate_refund, authorize, execute],
name="refund_flow",
)
# Open-ended customer-support agent that can call it
agent = Agent(
"You are a customer-support agent. When the user asks for a refund, "
"use the `refund_flow` tool — it handles the deterministic "
"validation / authorization / execution chain end-to-end.",
model="claude-opus-4-7",
tools=[refund_flow.as_tool()],
)
result = await agent.run(
"I want a refund for invoice #INV-2026-0042.",
user_id="alice",
)The agent sees one tool: refund_flow(input: str) -> str. The agent
decides when to call it; the workflow handles what happens once
called. This is the canonical “deterministic core wrapped in
LLM-driven UX” pattern.
wf.as_tool(): the API
def as_tool(
self,
*,
name: str | None = None,
description: str | None = None,
input_arg: str = "input",
) -> Tool: ...| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | None | None (uses self.name) | Tool name shown to the model. |
description | str | None | None (auto-generated) | Tool description. The default is f"Run the {self.name!r} workflow."; override for something the model can use to decide when to call. |
input_arg | str | "input" | Names the single string parameter the tool accepts. The framework forwards that value to wf.run(...) and returns result.output. |
The returned Tool is a regular Tool. Passes through permissions,
hooks, audit, sandboxes like any other tool.
Bidirectional example
A common shape: an outer Workflow handles deterministic gating, an inner Agent does the open-ended work, a sub-Workflow handles the deterministic action:
from loomflow import Agent, Workflow
# Inner action workflow (deterministic)
async def authorize(req: dict) -> dict: ...
async def execute(auth: dict) -> str: ...
action = Workflow.chain([authorize, execute], name="action")
# Middle Agent (open-ended, can call the action)
agent = Agent(
"Decide whether to invoke the action workflow.",
model="claude-opus-4-7",
tools=[action.as_tool()],
)
# Outer Workflow (deterministic gating)
async def gate(req: dict) -> dict: ...
async def log_completion(result: str) -> str: ...
gated = Workflow.chain(
[gate, agent, log_completion],
name="gated_action",
)
result = await gated.run(req, user_id="alice")The trace shows three nested workflow spans, an agent loom.run
span in the middle, and a sub-workflow span inside the agent’s tool
call. All under one user_id. This Agent has no name=, so
chain names its node after its position: step_1 here. Give it a
name and the node takes it.
What gets tagged in telemetry
loom.workflow.step (gate) (outer Workflow)
loom.workflow.step (step_1) (the Agent node)
└── loom.run (Agent)
├── loom.turn (turn 1)
│ ├── loom.model.complete
│ └── loom.tool (tool=action, from action.as_tool())
│ └── loom.workflow.step (authorize) (inner Workflow)
│ loom.workflow.step (execute)
└── loom.turn (turn 2)
└── loom.model.complete
loom.workflow.step (log_completion)These are the names the in-memory, console and file sinks record.
Exported through OTelTelemetry, the agent’s spans take their
GenAI names: loom.run arrives as invoke_agent,
loom.model.complete as chat {model} and loom.tool as
execute_tool action, each with the loom.* name in its
loom.span.name attribute. loom.turn and loom.workflow.step keep
their names. See GenAI semantic conventions.
Workflow-emitted spans carry the attribute pattern="workflow", so
dashboards can split traces by control-flow type.
Audit attribution
Every entry in a composed run carries the same user_id. With one
audit log passed to the agent and both workflows, the trail for the
bidirectional example above is:
step_started workflow=gated_action node=gate user_id=alice
step_completed workflow=gated_action node=gate user_id=alice
step_started workflow=gated_action node=step_1 user_id=alice
run_started model=... architecture=react user_id=alice (the agent)
tool_call tool=action user_id=alice
step_started workflow=action node=authorize user_id=alice (sub-workflow)
step_completed workflow=action node=authorize user_id=alice
step_started workflow=action node=execute user_id=alice
step_completed workflow=action node=execute user_id=alice
tool_result tool=action ok=true user_id=alice
run_completed turns=2 interrupted=false user_id=alice
step_completed workflow=gated_action node=step_1 user_id=alice
step_started workflow=gated_action node=log_completion user_id=alice
step_completed workflow=gated_action node=log_completion user_id=aliceA workflow writes per-node step_* entries (plus step_failed when a
node raises); it doesn’t write entries for the run as a whole. Same
user_id on every line; the HMAC signature covers user_id, so
tampering breaks verification. See Audit log attribution.
On a durable runtime
When Agent nodes use a journaled runtime (SqliteRuntime,
PostgresRuntime, …), each one runs as a child run of the workflow
run:
- Each node journals under its own namespace, so one node’s tool
result is never replayed into another node, even when both share
the workflow’s
session_id. - Parallel Agent nodes run together. Fan-out branches that share a runtime and session don’t wait for each other.
- A workflow isn’t durable on its own. Running it again starts fresh; its Agent nodes don’t replay the earlier run’s tool calls.
- As a tool, it’s replayed with the agent. A workflow called
through
as_tool()inside a durable agent run journals under that run, so resuming the agent replays the workflow’s Agent nodes too.
See Runs and epochs.
Agent and Workflow are siblings, not subclasses. Neither
inherits from the other. Composition works because both implement
the same internal call shape (async run(input, ...) → result with .output). Either can be wrapped by the other; the framework
detects the type and dispatches correctly.