Rubot
Agentic Internal Tools.
Why this
I am not fully sold on handing crucial operations to probabilistic agents and calling it automation.
Large models are useful, but they still hallucinate, drift, and fail in ways that are hard to predict ahead of time. That is tolerable for low-stakes drafting. It is much less tolerable when the system is touching customer records, moving money, making decisions, or triggering downstream work that people will have to clean up later.
Rubot exists because the interesting problem is not “how do I let an agent run wild?” It is “how do I build AI-assisted workflows with boundaries, state, approvals, traces, and sane failure modes?”
What Rubot is
Rubot is a Rails-native framework for building agentic internal tools with durable workflow state, approvals, and operator oversight.
The intended shape is simple: your app keeps the
product-facing UI, while Tool,
Agent, Workflow, and
Operation classes hold the execution logic.
-
Rubot::Toolfor explicit application actions -
Rubot::Agentfor structured reasoning participants -
Rubot::Workflowfor ordered, resumable orchestration -
Rubot::Operationfor feature-level composition
What it includes
Rubot includes the runtime pieces you need to make these systems inspectable instead of magical: runs, events, approvals, checkpoints, replay support, policy hooks, Rails integration, and a small admin surface for operating workflows.
The goal is not maximum autonomy. The goal is durable, governed execution with enough structure that you can trust what the system is doing and debug it when it goes wrong.
When this is worth it
If all you need is a one-off script that fetches a page and asks an LLM for an opinion, Rubot is probably too much. A small Ruby script is simpler.
Rubot becomes useful when that script turns into a real business process inside Rails and you suddenly need the plumbing around the model call:
- resumable runs when a human approval pauses the process for hours or days
- schema enforcement so model output failures become ordinary Ruby errors instead of silent bad state
- an event trail for auditability, debugging, and “why did this happen?” questions
- standard seams for testing tools, workflows, and agent outputs without rebuilding the harness every time
In short: if humans need to trust it, review it, resume it, or explain it later, Rubot starts earning its keep.
Example
Here is a toy example featuring all four primitives: deterministic policy lookup, website retrieval, an agent reviewing conference fit, and an operation that routes strong low-risk requests down an automatic path while sending the rest through human approval.
toy_example.rb
class LoadTravelPolicy < Rubot::Tool
description "Load company travel policy and budget rules."
input_schema do
string :employee_id
string :conference_name
string :conference_url
integer :estimated_cost_cents
end
output_schema do
string :employee_id
string :conference_name
string :conference_url
integer :estimated_cost_cents
integer :budget_limit_cents
array :allowed_topics, of: :string
end
def call(employee_id:, conference_name:, conference_url:, estimated_cost_cents:)
# Tools should stay deterministic. This one only loads policy facts.
policy = travel_policy_for(employee_id: employee_id, conference_name: conference_name)
{
employee_id: employee_id,
conference_name: conference_name,
conference_url: conference_url,
estimated_cost_cents: estimated_cost_cents,
budget_limit_cents: policy.fetch(:budget_limit_cents),
allowed_topics: policy.fetch(:allowed_topics)
}
end
private
def travel_policy_for(employee_id:, conference_name:)
TravelPolicyRepository.fetch(employee_id:, conference_name:)
end
end
class FetchConferenceWebsite < Rubot::Tool
description "Fetch public conference website content for review."
input_schema do
string :conference_url
end
output_schema do
string :conference_url
string :page_title
string :page_text
end
def call(conference_url:)
# Fetching the page is deterministic; interpreting it is not.
response = Faraday.get(conference_url)
{
conference_url: conference_url,
page_title: extract_title(response.body),
page_text: extract_visible_text(response.body)
}
end
private
def extract_title(html)
Nokogiri::HTML(html).at("title")&.text.to_s
end
def extract_visible_text(html)
Nokogiri::HTML(html).text.gsub(/\s+/, " ").strip
end
end
class ReviewConferenceFit < Rubot::Agent
instructions do
"Review the conference website content and determine whether the agenda
aligns with the allowed company topics. Explain your reasoning briefly."
end
input_schema do
string :conference_name
string :conference_url
string :page_title
string :page_text
array :allowed_topics, of: :string
end
output_schema do
boolean :content_matches_policy
float :confidence_score
array :matched_topics, of: :string
array :risks, of: :string
string :summary
end
end
class ConferenceTravelReviewWorkflow < Rubot::Workflow
tool_step :policy,
tool: LoadTravelPolicy,
input: ->(input, _state, _context) do
input.slice(:employee_id, :conference_name, :conference_url, :estimated_cost_cents)
end
tool_step :conference_site,
tool: FetchConferenceWebsite,
input: ->(_input, state, _context) do
{ conference_url: state.fetch(:policy).fetch(:conference_url) }
end
agent_step :conference_review,
agent: ReviewConferenceFit,
input: ->(_input, state, _context) do
# The agent gets a single structured payload assembled from
# deterministic steps.
state.fetch(:policy).merge(state.fetch(:conference_site))
end
step :finalize
def finalize
# Workflow policy is authoritative. The model advises; the workflow decides.
policy = run.state.fetch(:policy)
review = run.state.fetch(:conference_review)
within_budget = policy.fetch(:estimated_cost_cents) <= policy.fetch(:budget_limit_cents)
auto_approvable =
within_budget &&
review.fetch(:content_matches_policy) &&
review.fetch(:confidence_score) >= 0.90 &&
review.fetch(:risks).empty?
run.state[:finalize] = {
employee_id: policy.fetch(:employee_id),
conference_name: policy.fetch(:conference_name),
within_budget: within_budget,
content_matches_policy: review.fetch(:content_matches_policy),
confidence_score: review.fetch(:confidence_score),
auto_approvable: auto_approvable,
recommended: auto_approvable,
review_summary: review.fetch(:summary),
matched_topics: review.fetch(:matched_topics),
risks: review.fetch(:risks)
}
end
end
class ConferenceTravelManualApprovalWorkflow < Rubot::Workflow
step :hydrate_review
approval_step :manager_review,
role: "engineering_manager",
reason: "Conference travel requires human approval."
step :finalize
def hydrate_review
run.state[:finalize] = run.input.fetch(:review)
end
def finalize
run.state[:finalize] = run.state.fetch(:finalize).merge(
approval: run.approvals.last&.decision_payload,
auto_approvable: false
)
end
end
class ConferenceTravelApprovalOperation < Rubot::Operation
def self.launch(input:, subject: nil, context: {})
review_run = Rubot.run(
ConferenceTravelReviewWorkflow,
input: input,
subject: subject,
context: context
)
review = review_run.output
return review_run if review.fetch(:auto_approvable)
Rubot.run(
ConferenceTravelManualApprovalWorkflow,
input: { review: review },
subject: subject,
context: context
)
end
end
simulated_run.rb
input = {
employee_id: "emp_42",
conference_name: "Rails World",
conference_url: "https://rubyonrails.org/world",
estimated_cost_cents: 132_500
}
run = ConferenceTravelApprovalOperation.launch(input: input)
# internal output from LoadTravelPolicy
# {
# employee_id: "emp_42",
# conference_name: "Rails World",
# conference_url: "https://rubyonrails.org/world",
# estimated_cost_cents: 132_500,
# budget_limit_cents: 150_000,
# allowed_topics: ["ruby", "rails", "developer tools", "ai infrastructure"]
# }
# internal output from FetchConferenceWebsite
# {
# conference_url: "https://rubyonrails.org/world",
# page_title: "Rails World 2026",
# page_text: "Talks on Rails performance, Ruby tooling, AI infrastructure, and platform engineering."
# }
# internal output from ReviewConferenceFit
# {
# content_matches_policy: true,
# confidence_score: 0.94,
# matched_topics: ["ruby", "rails", "ai infrastructure"],
# risks: [],
# summary: "The agenda is directly relevant to our platform team's work."
# }
# review workflow output
# {
# employee_id: "emp_42",
# conference_name: "Rails World",
# within_budget: true,
# content_matches_policy: true,
# confidence_score: 0.94,
# auto_approvable: true,
# recommended: true,
# review_summary: "The agenda is directly relevant to our platform team's work.",
# matched_topics: ["ruby", "rails", "ai infrastructure"],
# risks: []
# }
#
# because auto_approvable is true, the operation returns that run directly.
# run.output
# {
# employee_id: "emp_42",
# conference_name: "Rails World",
# within_budget: true,
# content_matches_policy: true,
# confidence_score: 0.94,
# auto_approvable: true,
# recommended: true,
# review_summary: "The agenda is directly relevant to our platform team's work.",
# matched_topics: ["ruby", "rails", "ai infrastructure"],
# risks: [],
# }
#
# if confidence_score were 0.61 or risks were present, the operation would
# start ConferenceTravelManualApprovalWorkflow instead.
#
# in a real Rails app, approval would usually happen through the Rubot
# dashboard or a custom application UI.
#
# run.waiting_for_approval? # => true
# run.approve!(approved_by: "manager@example.com")
# Rubot::Executor.new.resume(ConferenceTravelManualApprovalWorkflow, run)
#
# run.output
# {
# employee_id: "emp_42",
# conference_name: "Rails World",
# within_budget: false,
# content_matches_policy: true,
# confidence_score: 0.61,
# auto_approvable: false,
# recommended: false,
# review_summary: "Relevant, but the budget is high and the agenda is mixed.",
# matched_topics: ["ruby"],
# risks: ["budget exceeds policy threshold"],
# approval: { approved_by: "manager@example.com" }
# }