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 for its text or structured answer, or .result for the complete RunResult. Return an invocation to select its answer as the Workflow result. You can inspect that answer first, or return a value that Ruby computes from intermediate answers. Every participating Agent publishes live progress regardless of how you access or select results.
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.
When one child should supply the final answer, return its invocation:
def perform invoke CustomerSupportAgent end
To inspect an answer before selecting it, keep the invocation, read its output, and return the same invocation after your checks. For example, given a ReviewAgent whose structured result includes an approved boolean:
class ReviewedResponseWorkflow < LittleGhost::Workflow private def perform candidate = invoke(CustomerSupportAgent) review = invoke(ReviewAgent, input: candidate.output, history: [], context: {}).output review.fetch("approved") ? candidate : invoke(EscalationAgent) end end
Returning the candidate does not repeat the work or replay its text. The final result preserves its conversation, state, and structured value. Usage and steps include both the candidate and the review. Repeated .result or .output reads reuse the completed result. An invocation must belong to the workflow returning it and must complete successfully.
You can watch each Agent while the Workflow runs, including while an answer is being reviewed. Reviewing an answer does not hide its progress. Choose which participants your audience may see, as described in Watch every agent.
Cancellation and deadlines apply before each child runs and before the Workflow returns its answer. They also apply if a checkpoint callback cancels the run or finishes after the deadline.
When Ruby should compute the caller-visible result, consume every child and return the computed value:
class EvidenceWorkflow < LittleGhost::Workflow private def perform findings = parallel( invoke(LedgerResearchAgent), invoke(PolicyResearchAgent), max_concurrency: 2 ) {"findings" => findings} end end
A returned String becomes the Workflow’s textual response. Arrays, mappings, numbers, and booleans become a structured result available through RunResult#output, including when the Workflow is exposed with assembly_as_tool. Computed values appear in the final result, not as synthetic text-delta events. Direct structured results must be JSON-compatible and stay within LittleGhost’s structured-result size, depth, and complexity limits. Return an explicit value: nil remains a ProtocolError, which catches forgotten returns.
Choose a branch in Ruby
Each branch should end with its final invocation or a directly computed value:
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 by handling source-tagged :agent_stream events. Every Run uses this same progress channel, from one root Agent to nested assemblies and subagents. Lifecycle events and the final result remain separate:
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.
An Agent or Assembly invoked as a Tool within the Run adds a :tool step to that path. The step identifies the invoking Agent and Tool name, and its descendants retain the boundary. Tool-invoked Agents therefore remain distinct from the root Agent even when their managed-subagent path is /root.
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.
Agent progress appears only through :agent_stream, without duplicate raw events. Returning an invocation selects its result; it does not replay that Agent’s progress. Handle the terminal Run event or read run.result for the complete answer and aggregate usage. Pass include_agent_events: false to omit all Agent progress without changing execution, lifecycle events, or results.
The AG-UI adapter displays root Agent progress by default. Supply its source_filter: callable when a client should see selected assembly participants.
Safety note: Every Run stream can include inputs, reasoning, Tool arguments and results, errors, and private output from nested participants. The example above observes the complete Run. Before sending or storing events for a narrower audience, select allowed sources and filter their fields. Result selection does not authorize disclosure of a participant’s progress.
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)
The trajectory retains step outputs, not a transcript of streaming events. Step outputs and final results 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.