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.

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:

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" }
# }

Docs