Compose Agents

An Assembly lets several participants answer through the same familiar calls as one Agent. This guide grows the customer-support example through each coordination style, then shows how to nest and construct assemblies dynamically.

Call every assembly the same way

Callers do not need a branch for each implementation:

entrypoint = urgent? ? EscalationWorkflow : CustomerSupportAgent
run = entrypoint.ask(question)

Agent, Workflow, Swarm, and Graph all answer through the Assembly calling style. They differ in how they coordinate work, not in how your application calls them.

Use a Workflow for explicit application logic

A Workflow’s perform method is ordinary Ruby. Inside it, invoke prepares a child call. Read .output when you need an intermediate answer. Return the final invoke call untouched so its response can stream to the caller.

class ResponseWorkflow < LittleGhost::Workflow
  private

  def perform
    research = invoke(ResearchAgent).output

    invoke CustomerSupportAgent, input: <<~PROMPT
      #{input.text}

      Verified research:
      #{research}
    PROMPT
  end
end

run = ResponseWorkflow.ask("Why is transfer 481 pending?")
run.response

Every participant passed to invoke can be an Agent or another Assembly. By default, each child receives the caller’s history and application context. Pass history: [], context: {}, or redacted values when a child should see less.

Each child Agent keeps its own prompt view. The Workflow supplies request-specific input; it does not replace that Agent’s reusable system instructions.

The last child is special because its events become the Workflow’s public stream. Return that invoke without consuming it:

# Wrong: this returns a String after consuming the final invocation.
def perform
  invoke(CustomerSupportAgent).output
end

# Right: this returns the lazy invocation itself.
def perform
  invoke CustomerSupportAgent
end

The first version produces a failed top-level Run whose error is ProtocolError. Use .output only when Ruby needs an intermediate answer before choosing the next step.

Choose a branch in Ruby

Each branch should end with its final unconsumed invocation:

class RoutedResponseWorkflow < LittleGhost::Workflow
  private

  def perform
    route = invoke(TriageAgent, as: :triage).output

    if route == "billing"
      invoke BillingAgent, as: :billing_response
    else
      invoke CustomerSupportAgent, as: :general_response
    end
  end
end

as: gives the child a readable participant name in steps, trajectories, and telemetry. It does not change which Assembly runs.

Run independent work in parallel

Use parallel when several inputs can be processed independently:

class InvestigationWorkflow < LittleGhost::Workflow
  private

  def perform
    findings = parallel(
      invoke(LedgerResearchAgent),
      invoke(PolicyResearchAgent),
      max_concurrency: 2
    )

    invoke CustomerSupportAgent, input: <<~PROMPT
      #{input.text}

      Findings:
      #{findings.join("\n")}
    PROMPT
  end
end

max_concurrency limits how many calls run at once. Each one gets its own copy of the workflow context. Cancellation still depends on the provider or tool noticing its token or deadline.

Use a Swarm for specialist handoffs

A Swarm keeps one Agent active at a time. You decide which specialists it may hand work to:

class ProblemSolverSwarm < LittleGhost::Swarm
  member TriageAgent
  member BillingAgent
  member AccountAgent

  start TriageAgent
  handoff TriageAgent, to: [BillingAgent, AccountAgent]
  handoff BillingAgent, to: TriageAgent
  handoff AccountAgent, to: TriageAgent

  max_steps 10
  max_handoff_repeats 2
end

The active model sees a handoff tool listing the members it may choose next. LittleGhost accepts only the routes you declared. max_steps limits total member executions. max_handoff_repeats limits how often the same directed handoff, such as triage to billing, may repeat.

Swarm members must be Agents, so each transition stays a direct model-to-model handoff. Caller history and application context are opt-in for each member. Handoff messages come from a model; never treat them as permission to read data or perform an action.

Opt in only for a member that needs the data:

member AccountAgent, history: true, context: true

Intermediate model text stays out of the caller-facing stream, leaving one coherent public answer. The next member still receives the handoff, and the result keeps a bounded summary of the journey.

Use a Graph for guided routes

A Graph names the possible stops and the routes between them. Start with a conditional route before adding parallel branches:

class SupportFlowGraph < LittleGhost::Graph
  node :triage, TriageAgent
  node :billing, BillingAgent
  node :general, CustomerSupportAgent
  node :respond, CustomerSupportAgent

  start :triage

  edge :triage, :billing do |state|
    state.result(:triage).output == "billing"
  end
  edge :triage, :general
  edge :billing, :respond
  edge :general, :respond
  finish :respond
end

SupportFlowGraph.validate!

Conditions and input mappers read an immutable Graph::State. At most one conditional route may match. If several match, LittleGhost raises AssemblyRoutingError instead of guessing which one wins. One unconditional edge can catch the request when none match.

Graph nodes start without caller history or application context. The start node receives the original input. By default, each downstream node receives the original task plus its immediate predecessor results. Use an input mapper to replace or redact that data before it moves to a provider or participant that should see less.

Opt in when a node needs caller context:

node :account_lookup, AccountLookupAgent, context: true

Run bounded parallel paths

Give one node several unconditional edges when its result should start independent branches. LittleGhost finds their first unambiguous common successor and waits for every branch before running it:

class InvestigationGraph < LittleGhost::Graph
  node :triage, TriageAgent
  node :ledger, LedgerResearchAgent
  node :policy, PolicyResearchAgent
  node :respond, CustomerSupportAgent

  start :triage
  edge :triage, :ledger
  edge :triage, :policy
  edge :ledger, :respond
  edge :policy, :respond
  finish :respond
end

Set max_concurrency on the Graph to bound every parallel group. An edge with an array target can declare the group explicitly and override that bound:

max_concurrency 4
edge :triage, [:ledger, :policy], max_concurrency: 2
edge [:ledger, :policy], :respond

Join parallel branches

An array source declares a wait-for-all convergence. Use it when the common successor cannot be inferred or when the convergence needs its own input mapper. Parallel groups cannot nest. validate! raises ConfigurationError when inference has no single convergence, finds competing routes at a branch boundary, or encounters overlapping or nested groups.

The first nodes in a parallel group receive the original task and the source result. The convergence target receives the original task and each immediate predecessor result in declaration order. LittleGhost labels them as context:

Original Task:
Why is transfer 481 pending?

Inputs from previous nodes:

From ledger:
The ledger entry is awaiting settlement.

From policy:
Pending transfers usually settle within two business days.

Map inputs between nodes

An input mapper replaces this default with the exact value returned by the mapper. Put it on an edge to control one transition, or on a node to control every route into that target. A selected edge or edge-group mapper takes precedence over the target node mapper:

edge :triage, :ledger, input: lambda { |state|
  "Investigate this transfer:\n#{state.result(:triage).output}"
}

Conditions and mappers receive a copied, frozen Graph::State, so they cannot change the running Graph. Use state.input for the original request, state.results for completed nodes, and state.incoming_results for the immediate predecessors. The LittleGhost::Graph::State API reference lists every routing value.

Conditions and mappers are application code. Their state includes copies of caller history and application context, even when the destination node does not receive those values.

Use an explicit array-source edge when a fan-in needs one mapper:

edge [:ledger, :policy], :respond, input: lambda { |state|
  JSON.generate(state.incoming_results.transform_values(&:output))
}

Control data crossing branches

By default, the original request and complete source output go to every parallel branch. An input mapper can replace the branch input, and a redaction assembly before the fan-out can narrow the source output. Use those options when a participant or provider should receive only part of the data.

Recover and review

An error edge can send an expected failure to a recovery Assembly. Call validate! before the first run.

Once the topology grows, InvestigationGraph.to_mermaid returns Mermaid diagram source for the routes you declared. Render it in a Mermaid-aware editor or documentation page when a picture makes the graph easier to review.

Make retries safe

Workflow calls, Swarm members, and Graph nodes can set timeouts and retries. Use them for work that can safely be attempted again:

invoke(
  ResearchAgent,
  timeout: 15,
  retries: 2,
  retry_on: [LittleGhost::ProviderError],
  retry_delay: 0.25
)

A timeout asks the running code to stop; it cannot forcibly end arbitrary Ruby or provider work. A retry repeats the whole child step. Retry only selected failures, and make sure repeated external actions are safe.

Retries start at zero. When retries is greater than zero, retry_on must list the exception classes that are safe to try again. LittleGhost does not retry every failure by default.

Watch every agent in an assembly

Follow each participant while a composite assembly runs by handling its contextual :agent_stream events. These events arrive alongside the coherent public answer and assembly lifecycle events:

stream = SupportFlowGraph.stream_ask("Why was I charged twice?")

run = stream.each do |event|
  next unless event.type == :agent_stream

  source = event.data.fetch(:source)
  agent_event = event.data.fetch(:event)
  participant = source.assembly_path.last&.participant || source.agent_id

  case agent_event.type
  when :invocation_start
    routed_input = event.data.fetch(:input)
    render_input(participant, routed_input)
  when :text_delta
    publish_progress(participant, agent_event.data.fetch(:text))
  when :invocation_stop
    record_result(participant, agent_event.data.fetch(:result))
  end
end

run.completed? # => true

source.agent_id identifies the Agent class, source.agent_path distinguishes managed subagents, and source.operation_id groups one invocation. source.assembly_path lists the enclosing Workflow, Swarm, or Graph steps from the outside inward.

The routed input and inner event are copied and frozen before they reach the observer, so changing an event can’t affect the running assembly. Parallel participants can interleave. Events from each Agent retain their order, and LittleGhost never calls the stream block concurrently.

The contextual wrapper arrives before the corresponding ordinary event. An assembly’s final Agent therefore appears through both projections. Filter for :agent_stream when building an all-agent view, or handle ordinary events when rendering only the final answer. Pass include_agent_events: false when a composite assembly caller only wants the ordinary public stream. Standalone Agent streams keep their ordinary events by default and accept include_agent_events: true when source metadata is useful.

The AG-UI adapter ignores contextual wrappers. Translate them explicitly if an AG-UI client should receive participant activity.

Safety note: A composite stream can include inputs, reasoning, Tool arguments and results, errors, and output from every participant. Check that the destination may see the complete Run, or filter the events before sending or storing them.

Inspect what the assembly did

A composite result remembers the steps it took. trajectory lets you explore them:

run = InvestigationGraph.ask("Why is transfer 481 pending?")
trajectory = run.result.trajectory

trajectory.each { |step| puts "#{step.participant}: #{step.status}" }
trajectory.transitions
ledger = trajectory.find { |step| step.participant == "ledger" }
policy = trajectory.find { |step| step.participant == "policy" }
trajectory.concurrent?(ledger.id, policy.id)

Step outputs and buffered events have size limits. Use your application’s instrumentation when you need deeper diagnostics.

Compose assemblies inside assemblies

Workflow and Graph participants accept any Assembly definition:

class ResolutionGraph < LittleGhost::Graph
  node :investigate, InvestigationWorkflow
  node :resolve, ProblemSolverSwarm

  start :investigate
  edge :investigate, :resolve
  finish :resolve
end

An Assembly can also become an Agent tool:

class SupportCoordinatorAgent < LittleGhost::Agent
  assembly_as_tool InvestigationGraph,
    name: "investigate_support_request",
    preserve_context: false
end

The nested assembly receives the parent Tool’s current working state. That state may include values restored from a Session. preserve_context controls conversation history only: when it is false, working state still passes to the nested assembly. A nested Tool that reads private data or performs a write should check values established for the current request or checked again after loading.

Reach for builders when definitions are dynamic

Classes are the preferred form in application code. Use a builder when runtime configuration decides the nodes or routes:

graph = LittleGhost::GraphBuilder.new(
  id: "support_flow",
  description: "Routes customer support requests"
)

graph.node :triage, TriageAgent
graph.node :respond, CustomerSupportAgent
graph.start :triage
graph.edge :triage, :respond
graph.finish :respond
graph.validate!

run = graph.ask("Where is my order?")

Each builder uses the same declarations as its matching class. The builder stays editable, but each run gets a fixed copy of its current definition. Later edits affect later runs. Ruby callbacks still see any application objects they captured.

Continue with Skills when an Agent should discover focused instructions and supporting resources only when a task needs them.