This guide is the shortest path from zero to a running Rubot workflow, but it also covers the main runtime concepts and local Rails setup that were previously in the root README.
Rubot is a Ruby and Rails framework for building code-first operational workflows.
If you only need a one-off script, Rubot is usually too much. It becomes useful when a task needs to live as a durable business process with checkpoints, approvals, auditability, and predictable failure handling.
The strongest in-process adoption path today is a Rails app, but the bigger idea is not just "Rails workflow helpers." Rubot gives generated or hand-written workflow software a durable structure.
The intended Rails shape is:
- app routes, controllers, and views compose the product-facing UI
Operation,Workflow,Agent, andToolhold execution logic- the Rubot admin UI is a separate governance surface for runs, approvals, replay, and traces
That makes Rubot a good fit for teams that want more than happy-path automation:
- ops teams building operational software through code generation
- Rails teams that want a real framework shape under generated code
- internal platforms that need approvals, replay, and governance as first-class concerns
Rubot::Toolfor typed, explicit application actionsRubot::Agentfor structured reasoning participantsRubot::Workflowfor durable step orchestration with approval pausesRubot::Operationfor feature-level grouping on top of tools, agents, and workflowsRubot::Run,Rubot::Event, andRubot::Approvalruntime objects- a simple executor with trace capture and workflow resume support
- a provider abstraction plus a first RubyLLM adapter
- subject-bound run helpers and optional subject-scoped memory retrieval
- policy adapters for runtime and admin authorization checks
- execution claims, step checkpoints, and cancellation for safer concurrent execution
Rubot::Evalfor fixtures, scoring, and regression checks against real runs- a first-party file actions tool pack for ingestion, extraction, classification, and brief generation
- Rails integration entry points through a Railtie, store-backed operator console, and generators
The current product story is strongest in Rails, but the architectural value is broader:
- durable runs
- explicit workflow state
- human approval points
- replay and traceability
- code that engineering can inherit without starting from generated glue
Add Rubot to your Gemfile:
gem "rubot", path: "../rubot"Then require it:
require "rubot"If you are evaluating Rubot strategically, the right comparison is usually not "can this replace a one-off script?" It is "does this give my generated or hand-authored workflow a structure I can trust later?"
class FetchTicket < Rubot::Tool
description "Load ticket context for a support workflow."
idempotent!
input_schema do
string :ticket_id
end
output_schema do
string :ticket_id
string :priority
array :tags, of: :string
end
def call(ticket_id:)
{
ticket_id: ticket_id,
priority: "normal",
tags: ["billing"]
}
end
endAgents consume structured input and emit structured output. They can be plain Ruby objects, or they can use the configured Rubot provider when you omit perform.
class TriageTicketAgent < Rubot::Agent
instructions do
"Review ticket context and recommend routing."
end
model "gpt-4.1-mini"
input_schema do
string :ticket_id
string :priority
array :tags, of: :string
end
output_schema do
string :queue
string :summary
end
endIn the zero-config path, Rubot can use the agent's instructions and schemas directly to produce structured output. Add a custom perform only when you need bespoke control over provider calls or tool use.
Configure the provider once:
Rubot.configure do |config|
config.provider = Rubot::Providers::RubyLLM.new(provider_name: "openai")
config.default_model = "gpt-4.1-mini"
endclass TicketTriageWorkflow < Rubot::Workflow
tool_step :fetch_ticket,
tool: FetchTicket,
input: ->(input, _state, _context) { { ticket_id: input[:ticket_id] } }
agent_step :triage_ticket,
agent: TriageTicketAgent,
input: ->(_input, state, _context) { state.fetch(:fetch_ticket) }
approval_step :supervisor_review, role: "support_supervisor", reason: "Human review required."
step :finalize
def finalize
run.state[:finalize] = {
triage: run.state.fetch(:triage_ticket),
approved_by: run.approvals.last&.decision_payload&.fetch(:approved_by, nil)
}
end
endThe important pattern is: the agent advises, the workflow decides. Let the model return structured judgment, then let normal Ruby workflow code enforce thresholds, approvals, and final business rules.
For repetitive step wiring, Rubot also ships small input helpers that compile down to the same runtime path as raw lambdas:
class TicketTriageWorkflow < Rubot::Workflow
tool_step :fetch_ticket,
tool: FetchTicket,
input: from_input(:ticket_id)
agent_step :triage_ticket,
agent: TriageTicketAgent,
input: from_state(:fetch_ticket)
endUse helpers for the common "pull from input/state/context" cases. Keep raw lambdas for real branching or heavier shaping logic.
You can also compose helpers for small reshaping cases:
tool_step :prepare_payload,
tool: PreparePayload,
input: merge(
slice(from_state(:fetch_ticket), :ticket_id, :priority),
from_context(:channel)
)When the workflow already has the final data it needs in run.state, you can also skip a boilerplate finalize step:
class TicketTriageWorkflow < Rubot::Workflow
tool_step :fetch_ticket, tool: FetchTicket, input: from_input(:ticket_id)
agent_step :triage_ticket, agent: TriageTicketAgent, input: from_state(:fetch_ticket)
output :triage_ticket
endUse output for simple exposure or reshaping. Keep an explicit terminal step when the finish phase contains real business logic.
When a tool needs to call an external API, prefer Rubot::HTTP rather than open-coding request logic each time.
class FetchTicket < Rubot::Tool
input_schema do
string :ticket_id
end
output_schema do
string :ticket_id
string :subject
end
def call(ticket_id:)
response = Rubot::HTTP.get(
"https://api.example.com/tickets/#{ticket_id}",
headers: { "Authorization" => "Bearer #{ENV.fetch("API_TOKEN")}" }
)
{
ticket_id: response.body.fetch("id"),
subject: response.body.fetch("subject")
}
end
endRubot::HTTP is a thin Faraday-backed helper. It handles request execution, JSON parsing, configurable timeouts and retries, and raises Rubot::HTTPError for failed responses.
run = Rubot.run(TicketTriageWorkflow, input: { ticket_id: "t_123" })
puts run.status
# => :waiting_for_approval
run.approve!(approved_by: "lead@example.com")
Rubot::Executor.new.resume(TicketTriageWorkflow, run)
pp run.outputWorkflow output defaults to a public snapshot of the final run.state. Internal framework metadata like checkpoints stays on run.state and out of run.output.
run.inputis the original payloadrun.statestores step outputs by step namerun.eventsis the execution timelinerun.approvalsholds approval recordsrun.outputis the workflow's final public state snapshot
In practice, a useful split is:
- tools return facts
- agents return judgment
- workflows own control flow and policy
- operations provide the app-facing feature boundary
Rubot is optimized around three DX constraints:
- define typed boundaries early so mistakes fail fast
- keep workflow state inspectable through
run.stateand emitted events - make Rails adoption incremental so the core runtime still works in plain Ruby
ruby -Ilib examples/basic_workflow.rb
ruby -Ilib -Itest test/rubot_test.rbIf you want to inspect a fuller Rails-shaped example, the repository also includes sample_app with:
- app-facing controllers and views
- a Rubot-backed resume screening capability
- generated install wiring and the mounted admin engine
Inside a Rails app, use generators to reduce setup:
bin/rails generate rubot:install
bin/rails generate rubot:tool FetchTicket
bin/rails generate rubot:agent TriageTicket
bin/rails generate rubot:workflow TicketTriageThe install generator mounts the admin engine at /rubot/admin, writes config/initializers/rubot.rb, and configures Rubot::Stores::ActiveRecordStore so the generated Rails path is durable by default.
Generated files land in:
app/toolsapp/agentsapp/workflowsapp/operationsif you introduce operation-level feature boundaries in your appconfig/initializers/rubot.rb
The recommended app structure is:
- product-facing routes and controllers live in your Rails app
- those controllers invoke operations or workflows
- Rubot primitives live in app-level directories like
app/tools,app/agents,app/workflows, andapp/operations - the Rubot admin engine lives separately, typically under a route like
/rubot/admin
Once the engine is mounted, /rubot/admin/playground gives you a lightweight browser-based surface for running tools, agents, and workflows against fixture JSON while you iterate locally.
For the full mounting, auth, and customization story, see Admin Guide. For the repo-level runtime and Rails packaging view, see Architecture.
If an operation owns multiple workflows, you can also declare named entrypoints so controllers call the capability in business terms instead of repeating workflow and trigger names:
class ProcessImprovementOperation < Rubot::Operation
workflow :current_state, CurrentStateWorkflow, default: true
workflow :future_state, FutureStateWorkflow
trigger :future_state_design, workflow: :future_state
entrypoint :current_state, workflow: :current_state
entrypoint :future_state, trigger: :future_state_design
end
ProcessImprovementOperation.launch_future_state(payload: { brief_id: "p_123" })Rubot.run returns a Rubot::Run with:
statuscurrent_stepinputstateoutputeventsapprovalstool_calls
For queue-backed execution:
run = Rubot.enqueue(TicketTriageWorkflow, input: { ticket_id: "t_123" })
pending = Rubot.store.find_run(run.id)
pending.approve!(approved_by: "lead@example.com")
Rubot.resume_later(run.id)Rubot.enqueue returns the queued run immediately and hands execution to Rubot::RunJob / Rubot::StepJob. Rubot.resume_later schedules Rubot::ResumeRunJob, which reloads the run from the configured store before continuing it.
For replay and comparison during debugging:
original = Rubot.run(TicketTriageWorkflow, input: { ticket_id: "t_123" })
replay = Rubot.replay(original)Replayed runs keep the same trace_id, remember replay_of_run_id, and can be compared side-by-side from the engine run page.
Important event types currently emitted:
run.startedstep.enteredstep.exitedtool.invokedtool.completedtool.failedagent.startedagent.completedagent.tool_loop.iterationagent.tool_loop.completedmodel.response.receivedapproval.requestedapproval.grantedrun.completedrun.failed
Subject-bound execution is supported with Rubot.run_for(subject, ...), Rubot.enqueue_for(subject, ...), and Operation.launch_for(subject, ...).
Stores can also look up runs for a subject so existing Rails record pages can embed Rubot state cleanly.
For authorization, Rubot can wrap runtime and admin actions with a policy adapter:
Rubot.configure do |config|
config.policy_adapter = Rubot::Policy::PunditAdapter.new
config.policy_actor_resolver = ->(context, controller) { context[:current_user] || controller&.current_user }
endFor subject-scoped memory retrieval:
class TicketMemory < Rubot::Memory::SubjectAdapter
def fetch(subject:, **)
[{ role: :system, content: "Prior notes for ticket ##{subject.id}" }]
end
end
Rubot.configure do |config|
config.subject_memory_adapter = TicketMemory.new
endThe bare runtime defaults to Rubot::Stores::MemoryStore, which is useful for local development and demos.
The Rails install generator defaults new apps to Rubot::Stores::ActiveRecordStore and creates the matching tables so runs persist durably out of the box.
To use the Active Record store in Rails:
Rubot.configure do |config|
config.store = Rubot::Stores::ActiveRecordStore.new
endThen run:
bin/rails generate rubot:install
bin/rails db:migrateActive Record-backed execution also includes:
- subject-level concurrency protection for active runs
- durable step checkpoints
- duplicate execution-claim protection for resume jobs
- cancellation support via
run.request_cancellation!
This is one of Rubot's biggest practical advantages. A human approval that pauses a run for three days is still the same durable run, with the same state and trace history, rather than a custom state machine you have to rebuild for each feature.
Rails apps can declare small framework-wide defaults in config/rubot.yml.
Supported first-pass keys include:
providerdefault_modelqueues.runqueues.stepqueues.resumefeatures.admin_live_updates
Explicit Rubot.configure values still win over YAML. See YAML Configuration for the supported shape and merge rules.
Rubot includes a small eval DSL that runs real agents, workflows, or operations against fixtures and applies score thresholds.
class TicketTriageEval < Rubot::Eval
target TriageTicketAgent
fixture :billing_case,
input: {
ticket_id: "t_123",
priority: "normal",
tags: ["billing"]
},
expected: {
queue: "billing",
summary: "Route to billing"
}
score :output_match do |result|
result.output == result.expected
end
assert_threshold :output_match, equals: 1.0
endRun a single eval or all loaded evals with:
rake 'rubot:eval[TicketTriageEval]'
rake rubot:evalRubot documents its public, provisional, and internal surfaces in Public API.
Use that document as the current API contract:
Rubot::Tool,Rubot::Agent,Rubot::Workflow,Rubot::Run, stores, policy adapters, and middleware bases are stable public surfacesRubot::Operationand admin engine hooks are functional and supported but remain provisional during signature refinement- runtime helpers like
Rubot::Executor,Rubot::Async, jobs, presenters, records, andrubot_*internals are not supported application entrypoints
Additional references: