RubyLLM::Team

Several RubyLLM agents, one run you can audit. Team gives a multi-agent workflow the things you would otherwise hand-roll: named handoffs between agents, one call budget for the whole run, safe fan-out, and a trace showing the exact prompt every agent received.

CI Ruby License: MIT

It is a small library, not a framework. Your workflow stays ordinary Ruby.

Do I need this?

You do not need Team for one agent, or two agents in a straight line. Agent#ask is enough.

You start needing it at the point below — when several agents share work and you have to answer "which version did the editor actually review?" and "why did this run cost 40 calls?"

You are writing this by hand Team gives you
A hash of results, plus rules for which version is "current" Named artifact versions with explicit as: / from: handoffs
A counter so one runaway loop cannot bill you forever One atomic call budget for the run, across threads and fibers, bounded by default
Thread.new per agent and a join that swallows one failure Fan-out that settles every sibling before raising
rescue => e in six places, each shaped differently One CollaborationError, plus typed BudgetExceededError
A logger you grep after something goes wrong A trace with the exact prompt each agent received

Install

gem 'ruby_llm-team', require: 'ruby_llm/team'

Quickstart

require 'ruby_llm/team'

team = RubyLLM::Team.new
                    .add(:planner, PlannerAgent)
                    .add(:reviewer, ReviewerAgent)

execution = team.run(max_calls: 4, context: 'Fix one failing test.') do |run|
  run.step :plan,   with: :planner,  prompt: 'Write a short implementation plan.'
  run.step :review, with: :reviewer, from: [:plan], prompt: 'Is this plan safe?'
  run.output :review
end

execution.output            # => the reviewer's answer
execution.value(:plan)      # => the plan the reviewer actually saw
puts execution.to_markdown  # => the full trace

A coworker is anything that responds to #ask — a RubyLLM::Agent class, an instance, or a plain object. That is what makes offline tests trivial (see Testing).

Or hand the tools to a model and let it decide who to consult, with your budget as the limit:

session = team.session(max_calls: 6)
chat    = RubyLLM.chat.with_tools(*session.tools)

chat.ask('Solid Queue or Sidekiq for a three-person team?')
session.calls.map(&:coworker)  # => whom the model actually chose to ask

How it fits together

flowchart LR
    App["Your Ruby<br/><i>order, branching, policy</i>"] -->|steps| Session
    subgraph Team["RubyLLM::Team"]
        Session["Session<br/><i>budget · failures · trace</i>"]
        Artifacts[("Artifacts<br/><i>named versions</i>")]
        Session <--> Artifacts
    end
    Session -->|ask| A1["Agent A"]
    Session -->|ask| A2["Agent B"]
    Session -->|ask| A3["Agent C"]
    A1 & A2 & A3 -->|models, tools, retries| RubyLLM["RubyLLM"]

Team sits between your code and your agents. It never owns models, prompts, schemas, retries, or your business rules.

Handoffs are explicit

as: names an output. from: selects which named outputs the next coworker receives, verbatim. Reusing a name publishes a new version, so a revision never silently overwrites its source.

sequenceDiagram
    participant W as writer
    participant E as editor
    W->>W: ask(as: :draft) → draft@v1
    W->>E: from: [:draft]
    E-->>W: review@v1 ("revise")
    W->>W: ask(as: :draft, from: [:draft, :review]) → draft@v2
    Note over W,E: artifact(:draft) is v2 — deterministic,<br/>even when work ran in parallel
session.ask(:writer, 'Write the draft', as: :draft, from: [])
session.ask(:editor, 'Review it',       as: :review, from: [:draft])
session.ask(:writer, 'Revise it',       as: :draft,  from: %i[draft review])

session.artifact(:draft).version  # => 2
session.artifact(:draft).sources  # => ["draft@v1 (writer)", "review@v1 (editor)"]

Running work in parallel

reviews = session.parallel(
  { security: prompt, performance: prompt, style: prompt },
  concurrency: :threads   # or :fibers, using the optional async gem
)

Every task reserves budget up front and appears in the trace. There is deliberately no limit: — bound concurrency where you own the task list:

tasks.each_slice(3).flat_map { |batch| session.parallel(batch.to_h) }

Quality loops stay in your Ruby

There is no refine or repair API. Compose the loop through session.ask and every round is budget-accounted, traced, and failure-normalized because it is an ordinary call:

session.ask(:writer, task, as: :draft, from: [])

3.times do
  review = session.ask(:critic, 'Review the draft.', as: :review, from: [:draft])
  break if review.fetch('verdict') == 'pass'

  session.ask(:writer, 'Revise using every finding.', as: :draft, from: %i[draft review])
end

Your code owns the predicate and the bound. Swap the critic for a validator and the same shape becomes a repair loop.

Traces

to_markdown renders the run for reading. to_h / to_json export it for tooling, including per-call and run-total token usage:

execution.to_h[:usage]   # => { input_tokens: 8_412, output_tokens: 3_120 }
execution.to_h[:calls]   # => structure, statuses, artifact lineage
execution.to_json(include_content: true)  # prompts and results, opt-in

Prompts and results are excluded unless you ask for them, so an exported trace is safe to ship. The prompt recorded is the exact text the coworker received — context and handoffs included.

Testing

Coworkers are plain objects, so most tests need no stubbing library and no API key:

writer = Class.new { def ask(_prompt) = 'DRAFT' }
team   = RubyLLM::Team.new.add(:writer, writer)

execution = team.run { |run| run.step :draft, with: :writer }
expect(execution.value(:draft)).to eq('DRAFT')

For live workflows, record one VCR cassette and replay it in CI with no key — spec/ruby_llm/code_review_workflow_spec.rb shows both patterns side by side.

Examples

Copy from code_review/ first — 110 lines, and it shows the whole library: parallel fan-out, named handoffs, budget, trace, and a verdict computed in Ruby rather than trusted from a model.

Example Shape Run it
simple_team.rb Two coworkers, one handoff ruby examples/simple_team.rb (no API key)
code_review/ Fan-out / fan-in — start here ruby examples/code_review/workflow.rb [diff]
topic_analyst/ Parallel research, ranked output ruby examples/topic_analyst/workflow.rb "Rails jobs"
decision_panel/ No Ruby orchestration — a lead model picks whom to consult ruby examples/decision_panel/workflow.rb "your question"
editorial_pipeline.rb Two teams composed in plain Ruby ruby examples/editorial_pipeline.rb "your domain"
blog/ Advanced. Seven editorial passes, bounded gates, escalation ruby examples/blog/workflow.rb

The blog example is production-scale on purpose and is the largest thing here; read it for patterns, not as a starting point. Live examples need OPENROUTER_API_KEY; the research ones also use YDC_API_KEY.

What Team is not

No graph DSL, YAML workflows, or role/backstory metaphors. No memory, RAG, MCP, or search. No dashboards, persistence, or scheduling — ruby_llm-agents owns that Rails layer and Team composes inside it. No hidden retries or model selection: RubyLLM owns those.

Reasoning and the evidence behind each refusal: docs/DECISIONS.md.

Development

bundle install
bundle exec rake spec          # the suite
bundle exec rake spec:replay   # recorded workflows, no API key needed
bundle exec rubocop

License

MIT. See LICENSE.txt.