Skip to Content
DocsWorkflowComposition with Agent

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

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.


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_action

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

Last updated on