Skip to Content
WorkflowComposition with Agent

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 nested Agent.run sees user_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’s metadata reaches Agent nodes, nested Workflow nodes and as_tool workflows too.
  • Telemetry parent span: The agent’s loom.run / loom.turn / loom.tool spans nest under the workflow’s loom.workflow.step span automatically.
  • Audit entries: Workflow step_started / agent run_started / agent tool_call / agent run_completed / workflow step_completed all land in the same audit log under the same user_id.
  • Interruptions: An Agent node that comes back interrupted (its max_turns, a budget, a guardrail) stops the workflow there. result.interrupted is True, result.interruption_reason reads "<node>:<reason>", and no downstream node runs on the partial answer. See WorkflowResult. (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: ...
ParameterTypeDefaultDescription
namestr | NoneNone (uses self.name)Tool name shown to the model.
descriptionstr | NoneNone (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_argstr"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=alice

A 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.

Last updated on