0.0
The project is in a healthy, maintained state
Smith is a workflow-first multi-agent orchestration library built on RubyLLM. It provides state machine modeling, typed contracts, budget enforcement, guardrails, and observability for agent workflows.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

Smith

Smith runs AI agents inside workflows you can trust in production. A workflow is a state machine: each step calls an agent, runs your Ruby, routes, fans out or waits. Every run can be saved after each step and resumed later, in another process, so a run can wait days for a person or a webhook without holding a worker. Smith sits on top of RubyLLM, which talks to the model providers.

What you get:

  • Workflows with named states and transitions, which you can inspect before they run.
  • Durable runs: saved after every step; they wait for events and timers, start child runs, and retry later. Your app owns the storage, queues and timers; Smith never sleeps, polls or enqueues.
  • Checked outputs: output schemas and contracts, and handling for replies that are cut off, refused or malformed.
  • Cost control: token and cost budgets per run, your own ledgers across runs, cache-aware pricing, and half-price batch calls.
  • Safety: isolated steps, outside text marked untrusted, guardrails and tool budgets.
  • Operations: streaming, traces, a doctor, and a testing kit.

Warning

Smith is pre-1.0: contracts can tighten between minor versions. Pin an exact version.

Install

# Gemfile
gem "smith-agents", "0.14.0", require: "smith"

In a Rails app:

bin/rails generate smith:install
bin/rails db:migrate

This writes config/initializers/smith.rb and a WorkflowState table where runs are saved. (The gem is called smith-agents because smith was taken; the Ruby namespace is Smith.)

A first workflow

require "smith"

RubyLLM.configure { |config| config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY") }

class Summarizer < Smith::Agent
  register_as :summarizer
  model "claude-haiku-4-5", provider: :anthropic
  instructions { "Summarize the user's text in one sentence." }
end

class Summarize < Smith::Workflow
  initial_state :idle
  state :done
  state :failed
  seed_messages { |context| [{ role: :user, content: context[:text] }] }

  transition :summarize, from: :idle, to: :done do
    execute :summarizer, output_to: :summary
    on_failure :fail
  end
end

result = Summarize.new(context: { text: "Our Q3 revenue grew 12%..." }).run!
result.state             # => :done
result.context[:summary] # => "Revenue grew 12% in Q3..."

An agent is a model plus instructions (and optionally an output schema, tools and fallback models). A workflow moves from state to state through transitions; execute calls an agent, and output_to: keeps its reply in the run's context for later steps.

Steps

Step Declared with Does
Agent call execute :agent Calls one agent. mode: :batch sends it through the batch API; stream: streams its text
Ruby compute { |step| ... } Runs your code; reads and writes the context
Route route :classifier, routes: {...} Picks the next transition from an agent's answer
Fan-out execute :agent, parallel: true, fan_out branches: {...} Calls agents concurrently
Refine optimize generator:, evaluator: Drafts and critiques in bounded rounds
Delegate orchestrate orchestrator:, worker: Plans tasks and hands them to workers
Nest workflow OtherWorkflow Runs another workflow as one step
Wait await :event, into:, timeout:, delay seconds Waits for an event your app delivers, or for a time
Child runs call, map, spawn Starts other workflows as runs of their own

Steps can declare on_failure (where a failure goes) and retry_on (bounded retries). Examples of each: docs/PATTERNS.md.

Durable runs

Save a run after every step, and it can stop and pick up again anywhere. Here a draft waits up to two days for someone to approve it:

class Publish < Smith::Workflow
  initial_state :idle
  state :drafted
  state :approved
  state :expired
  state :done
  state :failed

  transition :draft, from: :idle, to: :drafted do
    execute :writer, output_to: :draft
    on_failure :fail
  end

  transition :approve, from: :drafted, to: :approved do
    await :approval, into: :approval, timeout: 2 * 86_400
    on_timeout :expire
  end
  transition :expire, from: :drafted, to: :expired

  transition :publish, from: :approved, to: :done do
    compute { |step| Posts.publish(step.read_context(:draft)) }
  end
end

Publish.new(context: { topic: "Release notes" }).run_persisted!("post-42") # stops at the wait

wait = Publish.restore("post-42").awaiting            # the event, a token, the deadline
Publish.resume!("post-42", event: :approval, token: wait.token, payload: { ok: true })
Publish.restore("post-42").run_persisted!("post-42")  # runs to the end

Your app delivers events (resume!), schedules the timeout (expire_wait! once deadline_at passes), and runs the next step from a job. Smith stores runs through an adapter (:active_record, :redis, :rails_cache, :memory or your own) and refuses a state past persistence_max_bytes. A later version can rename a state or a step (renamed_state :drafted, to: :written, renamed_transition :approve, to: :review), and runs persisted under the old names carry on under the new ones, waits included, under the keys they had. Waits, child runs, durable retries, replay safety, renames and storage: docs/PERSISTENCE.md.

Agent outputs

An agent can declare the shape of its answer and what to do when a reply goes wrong:

class Reviewer < Smith::Agent
  register_as :reviewer
  model "claude-opus-4-7", provider: :anthropic
  output_schema ReviewSchema                         # the reply must be this JSON object
  output_contract ReviewContract                     # and pass your own check
  on_truncation retry_with: { max_tokens: 64_000 }   # a reply cut off at its limit
  on_refusal fallback: "anthropic/claude-sonnet-4-6" # a reply the model refused
end

A reply that fails its schema or contract goes back to the model once with the problems listed; one that still fails raises Smith::OutputInvalid. Every reply is billed and counted, whatever happens to it. docs/AGENTS.md covers outputs, model fallbacks, how requests are shaped per provider, streaming and batch mode.

Retries

transition :charge, from: :approved, to: :charged do
  compute { |step| Payments.charge(step.read_context(:order_id), idempotency_key: step.idempotency_key) }
  retry_on Payments::Timeout, attempts: 5, backoff: 60, durable: true
  on_failure :fail
end

retry_on retries a step a bounded number of times. With durable: true the run waits between attempts instead of sleeping in a worker. Each step gets idempotency keys that stay the same across retries and restores, so a receiver can drop a request it already handled. A response memo keeps what a step's provider calls returned, so a step replayed after a crash doesn't pay again. Errors you name as interrupting (your database out of reach) stop a step as a crash would, instead of failing it. docs/ERRORS_AND_RETRIES.md

Budgets

class Research < Smith::Workflow
  budget total_tokens: 200_000, total_cost: 2.00, wall_clock: 300
  budget_ledger(:account_id) { |scope| AccountLedger.new(scope[:account_id]) }
  # states and transitions as usual
end

budget bounds one run. budget_ledger names your own ledger (an account's monthly spend, say): Smith asks it to hold each request's estimated cost before sending it, and settles the real cost after. A refusal stops the step before anything is sent, and a step can wait for budget instead. docs/BUDGETS.md

Streaming and batch

execute :writer, stream: :answer sends the reply's text to a sink you register as the model writes it. Treat it as a draft: Smith checks the reply only once it is complete. execute :triager, mode: :batch sends the request through Anthropic's batch API at half price; the run waits, and your app delivers the result when the batch ends. fan_out and optimize take the same mode:; an optimize step batches each call of its loop in turn. docs/AGENTS.md

Untrusted content

class ReviewContext < Smith::Context
  persist :source, :fields
  untrusted :source, :fields
end

class DocumentReview < Smith::Workflow
  context_manager ReviewContext
  session :isolated
  initial_state :fetched
  state :extracted

  transition :extract, from: :fetched, to: :extracted do
    execute :field_extractor,
            input: ->(ctx) { [{ role: :user, content: Smith::Untrusted.wrap(ctx[:source], source: "document") }] },
            output_to: :fields
  end
end

With session :isolated, each agent step sees only the messages its input: builds, not a shared transcript. Keys marked untrusted never reach instructions, and Smith::Untrusted.wrap fences outside text off from everything else. Messages can carry documents and images. docs/PERSISTENCE.md, Step Isolation.

Tools and guardrails

Agents can use Smith::Tool subclasses, with a budget on how many calls a step may make. Once a tool has run, a step is not retried and takes no fallback, as its tools would run again; a tool that is safe to run again (a lookup) declares capabilities { replay :safe }, and a step whose tools were all safe keeps its retries, fallbacks and output repair. Guardrails check inputs, outputs and tool calls. Smith ships Tools::Think; Tools::WebSearch and Tools::UrlFetcher are stubs that declare the interface, which you subclass with your own search or fetch. docs/TOOLS_AND_GUARDRAILS.md

Testing

require "smith/testing/minitest" # or "smith/testing/rspec"

class PublishTest < ActiveSupport::TestCase
  include Smith::Testing::Assertions

  test "drafts, then waits for approval" do
    Smith::Testing.stub_agent(Writer, outputs: "Release notes for 2.0.")
    run = Publish.new(context: { topic: "Release notes" })

    Smith::Testing.run_until_waiting(run)
    assert_waiting_for run, :approval
    Smith::Testing.resume_with(run, payload: { ok: true })
    assert_transitioned_through run, :draft, :approve, :publish
  end
end

A stubbed agent is answered where its request would go to the provider, so everything else runs for real: tools, budgets, retries, output checks. docs/TESTING.md

Doctor

bin/rails smith:doctor              # configuration, models, the state table
bin/rails smith:doctor:durability   # saves and restores a run
bin/rails smith:doctor:live         # one real provider call

Outside Rails: smith doctor, smith doctor --durability, smith doctor --live.

Documentation

Topic Read
Step patterns, with examples docs/PATTERNS.md
Agents: outputs, models, streaming, batch docs/AGENTS.md
Runs: storage, waits, child runs, retries, isolation, artifacts docs/PERSISTENCE.md
Errors, retries, the response memo docs/ERRORS_AND_RETRIES.md
Budgets and ledgers docs/BUDGETS.md
Tools and guardrails docs/TOOLS_AND_GUARDRAILS.md
Every setting docs/CONFIGURATION.md
Testing and the doctor docs/TESTING.md
Inspecting a workflow before it runs docs/INSPECTION.md
Working on Smith itself docs/DEVELOPMENT.md
What changed CHANGELOG.md

Versions

Pin an exact version. Apps that cache workflow definitions should include Smith::EXECUTION_SEMANTICS_VERSION in their cache key: it changes when the way Smith executes workflows changes, independently of the gem version.

License

MIT.