Your Rails app is already an agent runtime.
Silas turns the Rails app you already run into a durable AI-agent runtime.
Active Job Continuations, Solid Queue, and one Postgres ledger table make every
turn survive kill -9 and resume from the last completed step — with a live
operator inbox at /silas/inbox and park-at-zero human-in-the-loop approvals
holding the big levers. No new service, no managed platform, no per-run meter:
the durable stack is already booted inside your app. The only new surface is the
app/agent/ directory below.
Honestly early and honestly narrow: v0.1, one maintainer, zero external
users, durability proven by an in-repo kill -9 chaos harness (100/100, zero
duplicate effects, byte-identical replay), and scoped to trusted code you write
yourself (the sandbox is an interim Docker seam, not a microVM). The full pitch
and the honest caveats: Why Silas ·
Silas vs eve.
app/agent/
instructions.md # the persona (ERB, snapshotted once per turn)
agent.yml # data-only config: model, limits
tools/ # one file per tool; identity = filename
issue_refund.rb # keyword signature = the schema the model sees
skills/ # markdown playbooks, loaded on demand
triage.md # description: frontmatter is the routing hint
Quickstart
bundle add silas
bin/rails generate silas:install
bin/rails db:migrate
class Agent::Tools::IssueRefund < Silas::Tool
description "Refund an order."
param :amount, :integer, desc: "Pence"
approval :always # parks the run; a human approves from your app
transactional! # DB-only side effects -> exactly-once, guaranteed
def call(order_id:, amount:)
Refund.create!(order_id:, amount:)
{ refunded: order_id }
end
end
session = Silas.agent.start(input: "Refund order 42, £12.50")
session.pending_approvals.first.approve!(by: "daniel")
session.continue(input: "Now email the customer.")
Or talk to it from the terminal — the REPL runs inside your app, so tools hit
your real dev database, and parked approvals prompt inline (the same
approve!/decline! as the inbox and Slack):
$ bin/rails silas:chat
you> Refund order 42, £12.50
✓ lookup_order(order_id: 42)
⏸ issue_refund(order_id: 42, amount: 1250) — awaiting approval
approval needed — issue_refund(order_id: 42, amount: 1250)
approve? [y]es / [d]ecline / [s]kip> y
agent> Done — £12.50 refunded on order 42.
SESSION=id resumes an existing session.
The durability contract (what's actually guaranteed)
Verified by chaos_host/bin/chaos — the harness that kill -9s a live agent
hundreds of times per release (results in chaos_host/results/):
- A turn survives hard process death (worker kill -9, whole-tree kill -9, SIGTERM deploys) and resumes from the last completed step: 100% completion, byte-identical transcripts, on SQLite and Postgres.
transactional!tools execute exactly once. The tool's DB writes and the ledger row commit or roll back together. Zero duplicates across every chaos run.- Other tools are at-least-once within one step — and when a crash makes an
execution ambiguous, the default
at_most_once!policy parks the run for a human verdict instead of guessing (idempotent!opts into automatic re-runs). - Approvals park at zero compute — the job exits; approving enqueues a fresh one that replays completed work from rows, never re-calling the model or re-running tools. Parks expire (default 7 days) rather than ghosting forever.
- The rescuer is part of the contract. Solid Queue marks a dead worker's
jobs failed and nothing retries them; the installer wires
Silas::DeadJobRescuerJobas a recurring task (every 30s). Recovery time ≈SolidQueue.process_alive_threshold+ that cadence. Do not remove it. - Deploys can't corrupt a run: instructions are snapshotted per turn, and a
deploy that changes tools/skills mid-turn fails the turn loudly
(
NondeterminismError) instead of resuming into a different agent.
Engines
Inference is one pluggable seam (config.engine):
:ruby_llm— API-key auth via RubyLLM; any provider it supports. Canonical, production mode. Compose resilience viaconfig.around_model_call.:agent_sdk— aclaude -psubprocess runs the whole turn, calling back into your tools over an in-worker HTTP MCP endpoint whosetools/callgoes through the same Ledger. Always--bare(API-key auth only; the boot guard raises if OAuth is configured withANTHROPIC_API_KEYpresent, and if the key is missing in api_key mode). v1 is honestly weaker than:ruby_llm: exactly-once within a run,approval :nevertools only, and fail-closed on a mid-subprocess kill.
Triggers
An agent is reached by more than a method call:
schedules/—app/agent/schedules/*.md(cron frontmatter, body = the turn input) or*.rbhandlers.bin/rails silas:schedulescompiles them into Solid Queue recurring tasks. A scheduled run is a normal durable turn.channels/—app/agent/channels/*.rbbind email (Action Mailbox) and Slack to the loop. A new thread starts a session, a reply continues it, and approvals render as Slack buttons / signed email links that call the sameapprove!/decline!. Outbound delivery is idempotent and off the durable loop.
The inbox
Mount the engine (the generator does this) and a live inbox appears at
/silas/inbox: a session list, a live step-trace that streams over Turbo
Streams as the agent runs, approval cards whose Approve/Decline buttons call the
exact same approve!/decline! as Slack and email, and per-session token/cost
accounting. It's deny-by-default — invisible until you wire auth:
Silas.configure do |c|
# Devise-compatible: the lambda DENIES by rendering; passes by not rendering.
c.inbox_auth = ->(controller) { controller.head :not_found unless controller.current_user&.admin? }
# c.inbox_public_read = true # public read-only demo; approve/decline stay gated
# c.model_prices["your-model"] = { in: 300, out: 1500 } # microcents / 1k tokens
end
Turbo streaming activates automatically when the host has turbo-rails (every
default Rails app does); without it the trace falls back to a polling refresh.
The gem itself takes no turbo dependency.
Requirements
Rails >= 8.1 (Active Job Continuations) and Solid Queue >= 1.2 for the
durability contract. macOS dev note: Solid Queue forks + pg need
PGGSSENCMODE=disable OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES.
License
MIT.