xeno
xeno is a framework for durable AI agents. Built on Rails, it runs standalone (xeno new my-agent) or inside the Rails app you already have.
A xeno agent is a session that survives restarts and deploys mid-turn, waits days for a human approval without holding a process, and resumes exactly where it stopped. Under the hood, xeno composes RubyLLM for model calls with ActiveJob and ActiveRecord for durability.
[!WARNING] xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref
2aaddf96. The releasedruby_llmgem (1.16.0) is not compatible. Standalone apps get the pin from the scaffold's Gemfile. If your Rails app already uses a releasedruby_llm, xeno cannot run alongside it today. This resolves at RubyLLM v2 GA, when xeno switches to the released gem.
The filesystem is the authoring interface
A xeno agent is a directory:
agent/
├── agent.rb # optional: model and runtime config
├── instructions.md # the always-on system prompt
├── instructions.rb # optional: dynamic instructions, resolved per turn
├── tools/
│ └── get_weather.rb # tool "get_weather" (class Xeno::Tools::GetWeather)
├── hooks/
│ └── metrics.rb # observe-only event handlers
└── schedules/
└── weekly_recap.md # cron-triggered agent session
The path supplies the name; there are no name: fields. xeno info prints the resolved agent and flags anything misplaced.
Quick start
Standalone, from nothing:
xeno new my-agent && cd my-agent
bundle install
xeno server
One process: web and jobs in the same Puma, SQLite, no external services. The dev chat UI is at http://localhost:3000/agent/dev.
In an existing Rails app:
bundle add xeno
bin/rails g xeno:install
bin/rails db:migrate
The generator creates agent/ at your app root and mounts the engine at /agent. Tools are your domain code: they call your models, policies, and credentials directly, with no connector layer in between.
A minimal example
Add a gated tool at agent/tools/charge_card.rb:
class Xeno::Tools::ChargeCard < Xeno::Tool
description "Charge the customer's card. Irreversible."
parameter :amount_cents, type: :integer, description: "Amount in cents"
approval :always
def execute(amount_cents:)
PaymentService.charge!(amount_cents)
end
end
Send "charge $42 to my card" and the agent decides to call the tool. approval :always parks the turn: the job ends, and the pending approval is a database row. Kill the process, deploy, come back tomorrow. When you approve (over HTTP, or one click in the dev UI), the turn resumes from its last checkpoint and the tool runs exactly once.
That arc is the flagship integration test (test/demo_acceptance_test.rb, three real processes) and a watchable script (script/demo.sh).
How durability works
Work nests session → turn → step. A turn is one ActiveJob; a step is one model call plus its tool calls, checkpointed in your database:
- A tool execution and its transcript write commit in one transaction, so recorded calls never re-execute. A killed process resumes from the last checkpoint.
- Turn claims use an atomic compare-and-swap with heartbeats, stale-claim takeover, fencing tokens, and a reaper for orphaned turns. Any ActiveJob backend works; with Solid Queue the whole story is database rows.
- Parking ends the job. A parked session costs nothing for days; resolving its pending input enqueues the resume.
- Messages that arrive mid-turn queue up and fold into the next turn.
docs/runtime.md, shipped inside the gem, documents the runtime invariants in full.
Sessions over HTTP
The engine mounts at /agent:
POST /agent/v1/sessions create a session → { session_id, continuation_token }
POST /agent/v1/sessions/:id/messages follow-up message (steer: true replaces the active turn)
POST /agent/v1/sessions/:id/inputs approve / deny / answer (by action_id)
POST /agent/v1/sessions/:id/cancel stop the active turn
POST /agent/v1/sessions/:id/compact summarize old history
POST /agent/v1/sessions/:id/reset retire the session
GET /agent/v1/sessions/:id/stream event stream
GET /agent/v1/health
Events are append-only rows with a per-session index. The stream endpoint serves Server-Sent Events (SSE) or NDJSON (?format=ndjson) and replays from any cursor (?start_index=N); the same table is your audit log.
Slack
Two credentials and the agent answers mentions in threads:
# agent/channels/slack.rb
Xeno.channel :slack do
signing_secret ENV["SLACK_SIGNING_SECRET"]
bot_token ENV["SLACK_BOT_TOKEN"]
end
Standalone apps load these from .env; in a Rails app, Rails.application.credentials works here too.
Point your Slack app's Events API at POST /agent/v1/channels/slack/events. xeno verifies signatures in constant time, answers the URL handshake, dedupes retries, and acks inside Slack's 3-second window. Mentions and direct messages start sessions; each thread is one session; replies continue it.
When the agent parks, the prompt lands in the thread, and replies resolve it:
- approve or deny settles an approval
- a number ("2") or a label ("tuesday") picks a choice
- any text answers a free-form question
- unrelated replies are held as the next message, never treated as an answer
Replies post at turn completion by default; stream_replies true in the channel block opts into post-then-edit streaming.
Schedules
A schedule is a markdown file with a cron line:
---
cron: "0 9 * * 1"
---
Summarize the week's activity and post highlights to the team.
bin/rails xeno:schedules:sync compiles agent/schedules/*.md into Solid Queue recurring entries; standalone apps sync at boot. Development never fires on cadence: trigger by name via the dev endpoint. Schedule runs execute under the app principal and cannot wait on a human, so a gated tool deterministically fails the run.
Configuration
Standalone apps configure in agent/agent.rb; mounted apps use config/initializers/xeno.rb:
Xeno.configure do |config|
# Fail closed: every endpoint except health returns 401 until you set this.
# The truthy return value becomes the session principal.
config.authenticate = ->(request) do
token = request.headers["Authorization"]&.delete_prefix("Bearer ")
{ "user" => "api" } if ActiveSupport::SecurityUtils.secure_compare(
token.to_s, ENV["XENO_API_KEY"].to_s
)
end
config.max_steps = 20 # per-turn model-call budget
config.turn_stale_after = 5.minutes # dead-worker takeover window
end
Development with no lambda configured stays usable on localhost; everywhere else the absence of a checker is a 401.
The principal is also the ownership boundary: a session belongs to the principal stamped at creation, and every session-scoped endpoint returns 404 for any other principal. See docs/runtime.md for the authorization rules.
Model registry
Model ids resolve against RubyLLM's registry, and models released after the gem's catalog snapshot need a registry refresh before they resolve. Standalone apps refresh automatically on first boot, right after the boot-time migrations (skip with XENO_SKIP_MODEL_REFRESH=1). To refresh later, run it manually for now (a friendlier command is planned):
# mounted
bin/rails runner "RubyLLM.models.refresh!"
# standalone
bundle exec ruby -r dotenv/load -r xeno/standalone -e 'Xeno.rails_app; Xeno.definition; RubyLLM.models.refresh!'
Or bypass the registry per model with model "the-id", provider: :openai, assume_model_exists: true. Automatic compaction then needs config.compaction_context_window, since the registry cannot supply the window.
Security model
xeno runs tools inside your app on purpose:
- There is no sandbox and no
bashtool. The model's only execution surface is the tools you write. - That power cuts both ways: prompt injection reaches whatever your tools expose. Keep parameters narrow and typed, scope queries to the session's principal, authorize inside tools with your existing layer, and treat tool output fed back to the model as untrusted.
- Approvals are the guardrail. Gate anything irreversible or externally visible with
:onceor:always. - Webhooks verify signatures in constant time, HTTP auth fails closed, and dev routes never mount outside development.
Known limitations
- SSE holds a thread per client, which is fine for dev UIs and small deployments. Catch-up reads are batched so reconnects on long sessions stay bounded.
- SQLite and PostgreSQL are the tested databases; the suite runs on both. MySQL is untested and has known caveats.
- Execution is at-least-once: a step interrupted mid-flight re-runs on retry, so tool side effects need idempotency or approval gates. Consumers dedupe events by
(session, index). - Cancellation is cooperative and lands at the next step boundary; an in-flight model call finishes first. A parked turn cancels instantly.
- Sessions retire explicitly:
resetis the only terminal transition. - One agent per app.
Development
bin/rails test # the whole suite: offline, deterministic fake LLM
script/demo.sh # the kill -9 demo, narrated
script/verify_generators.sh # generators against a fresh rails new app
script/verify_standalone.sh # the standalone one-process arc
Tests run against a scripted fake server: no network, no keys, no flake. The kill-and-resume arcs use real processes and real signals.
Status: preview (0.0.x)
xeno is an early preview with no production mileage yet; anything may change between 0.0.x releases. It targets RubyLLM v2 (unreleased, pinned to a known-good commit; see the warning above); the pin drops at v2 GA. 0.1.0 will mark the first release we consider stable enough to build on. See CHANGELOG.md.
MIT License.