Composition with Agent
Workflow and Agent are peer primitives that compose in both
directions. The same observability spine, user_id partition,
telemetry spans, audit log. Flows through whichever shape wraps
whichever.
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) 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.- 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.
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.
What gets tagged in telemetry
loom.workflow.run (outer Workflow)
└── loom.workflow.step (gate)
└── loom.workflow.step (agent_node)
└── loom.run (Agent)
└── loom.turn (turn 0)
│ └── loom.tool (action_as_tool)
│ └── loom.workflow.run (inner Workflow)
│ └── loom.workflow.step (authorize)
│ └── loom.workflow.step (execute)
└── loom.turn (turn 1)
└── loom.model.stream
└── loom.workflow.step (log_completion)The pattern span attribute is "workflow" for workflow-emitted
spans and "agent" for agent-emitted spans, so dashboards can split
traces by control-flow type.
Audit attribution
Every entry in a composed run carries the same user_id. The full
audit trail for the bidirectional example above:
workflow_started workflow=gated_action user_id=alice
step_started node=gate
step_completed node=gate
step_started node=agent_node
run_started agent=... user_id=alice (sub-run)
tool_call tool=action user_id=alice
workflow_started workflow=action user_id=alice (sub-workflow)
step_started node=authorize
step_completed node=authorize
step_started node=execute
step_completed node=execute
workflow_completed workflow=action
tool_result tool=action ok=true
run_completed agent=...
step_completed node=agent_node
step_started node=log_completion
step_completed node=log_completion
workflow_completed workflow=gated_actionSame user_id on every line; HMAC signature covers user_id so
tampering breaks verification. See Audit log attribution.
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.