mistri, the agent harness for Ruby applications.
A mistri (Urdu: مستری) is the fixer: the skilled tradesperson who actually gets it done. This one lives inside your app, not in a terminal. It runs the model loop, executes tools, streams every event, persists sessions to your own database, and pauses for a human when a tool needs approval, all with zero runtime gem dependencies.
require "mistri"
weather = Mistri::Tool.define(
"get_weather", "Current weather for a city.",
schema: -> { string :city, "City name", required: true },
) do |args|
Weather.for(args["city"])
end
agent = Mistri.agent("claude-opus-4-8", tools: [weather]) # reads ANTHROPIC_API_KEY
agent.run("What should I wear in Lahore today?") do |event|
print event.delta if event.type == :text_delta
end- Built for applications. Sessions are durable, append-only records in your own store. Runs stop, resume, steer, and compact from any process.
- Fire-and-forget human approval. A gated tool suspends the run and returns immediately. The approval can arrive two days later from a bare web request; nothing sleeps waiting.
- Three providers, frontier-deep. Anthropic, OpenAI, and Gemini, each streamed natively with thinking, prompt caching, parallel tool calls, and constrained JSON output. One message model across all three.
- Zero runtime dependencies. Plain Ruby all the way down.
- Verified against real APIs. A live integration harness runs every
feature end to end on every provider (
rake integration).
gem "mistri"agent = Mistri.agent("claude-opus-4-8")
result = agent.run("Name three Ruby web frameworks.")
puts result.textMistri.agent infers the provider from the model id (claude-*, gpt-*,
gemini-*) and reads the matching key (ANTHROPIC_API_KEY,
OPENAI_API_KEY, GEMINI_API_KEY); pass api_key: to set it explicitly.
Every run returns a Result: completed?, awaiting_approval?,
aborted?, errored?, with text and (for tasks) output.
A tool is a name, a description, an argument schema, and a block. The block returns a String, a Hash (sent as JSON), or content such as an image. The agent calls tools, feeds results back, and loops until the model answers; independent calls in a turn run in parallel.
weather = Mistri::Tool.define("get_weather", "Current weather for a city.", schema: lambda {
string :city, "City name", required: true
string :units, "Temperature units", enum: %w[celsius fahrenheit]
}) do |args|
Weather.for(args["city"], units: args["units"] || "celsius")
endA tool can speak on two channels: content for the model, ui for your
interface. The ui payload rides the :tool_result event and persists with
the session, but never reaches a provider:
Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
page = apply(args)
Mistri::ToolResult.new(content: "Saved.", ui: { "html" => page })
endMark a tool needs_approval: true (or a predicate on its arguments) and the
run suspends instead of executing it, instantly, with no thread waiting.
The decision is a one-line session write from any process, any time later;
resume settles it and carries on.
send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
Gifts.send!(args)
end
result = agent.run("Send Ana a $200 gift")
result.awaiting_approval? # => true; nothing executed
# Days later, in a controller:
Mistri::Session.new(store:, id: session_id).approve(call_id) # or .deny(call_id, note: "...")
# Then, in a worker:
Mistri.agent("claude-opus-4-8", tools: tools, session: reloaded).resumeThe harness renders nothing: it emits an :approval_needed event and your
app draws the UI.
Queue a message into a running exchange from any process. It folds into the conversation at the next turn boundary; one that arrives as the model finishes cleanly extends the run so it gets answered.
Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")A session is the durable record of a run: an append-only entry log over a pluggable store (memory, JSONL files, or your database).
store = Mistri::Stores::JSONL.new("tmp/sessions")
session = Mistri::Session.new(store:)
agent = Mistri.agent("claude-opus-4-8", session:)
agent.run("Start a haiku about the sea.")
# Later, even in another process: reload by id and continue.
resumed = Mistri.agent("claude-opus-4-8", session: Mistri::Session.new(store:, id: session.id))
resumed.run("Now finish it.")In Rails, generate a model (name it whatever you like) and use the ActiveRecord store:
$ bin/rails generate mistri:install AgentEntryrequire "mistri/stores/active_record"
store = Mistri::Stores::ActiveRecord.new(AgentEntry)Long sessions survive their context window: when the conversation grows into
the reserve headroom, the provider writes a visible structured summary and
replay continues from it. The full history stays in your store for
transcript views. On by default whenever the model's window is known;
compaction: false disables it.
agent.context_usage # => { tokens: 141_000, window: 200_000, fraction: 0.705 }
agent.compact # the manual button:compacting and :compaction events carry the summary, so users see
exactly what the model still remembers.
A run that must end in JSON matching a schema. Tools run as usual; providers constrain the final answer natively where they can, and the answer is validated client-side everywhere. A violation goes back to the model once, then raises. You get a guaranteed shape or a loud error, never silence.
schema = {
type: "object",
properties: { "tiers" => { type: "array", items: { type: "string" } } },
required: ["tiers"],
}
result = agent.task("Extract the pricing tiers from this page.", schema: schema)
result.output # => { "tiers" => [...] }, parsed and validatedExpert playbooks with progressive disclosure: each skill costs one line in
the system prompt until the model decides it is relevant and pulls the full
body through an auto-provided read_skill tool.
agent = Mistri.agent("claude-opus-4-8", skills: "app/skills") # or an array of Mistri::SkillA skill is a SKILL.md (or flat .md) with name:/description:
frontmatter, or built from database rows with
Mistri::Skill.new(name:, description:, body:).
Delegate to a child agent with a clean context: exploration fills the
child's window, and only the final answer returns. Children run on their own
sessions in your store, linked in the parent transcript; their events stream
into the parent tagged with an origin.
researcher = Mistri::SubAgent.new(
name: "researcher", description: "Reads pages and answers factual questions.",
provider: Mistri.provider("claude-haiku-4-5-20251001"), # cheaper model for grunt work
system: "Research. Report findings only.", tools: [fetch_page],
)
agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])Or hand the model an open spawn tool and let it compose its own workers: instructions, a tool subset, and a host-allowlisted model per child. Several spawns in one turn fan out in parallel:
spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])The document tools (read_file, edit_file, write_file, find_in_file,
list_files) work over a workspace: a directory, memory, ActiveRecord, or
a single value anywhere, like one database column holding a page:
workspace = Mistri::Workspace::Single.new(
read: -> { page.html },
write: ->(html) { page.update!(html: html) },
path: "hero.html",
)
agent = Mistri.agent("claude-opus-4-8", tools: Mistri::Tools.files(workspace))The edit engine matches exactly, then whitespace-tolerantly; an ambiguous match refuses (never silently edits the wrong place), and a near-miss error names the closest region so the model's retry is one-shot.
Bridge any Model Context Protocol server's tools into an agent. The client speaks Streamable HTTP with zero new dependencies; auth is a token string or a lambda that re-resolves once on 401, so refresh logic lives in one place. Approval gates compose: a third-party write tool can require a human.
client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
token: -> { connection.bearer_token })
tools = Mistri::MCP.tools(client, prefix: "linear",
gates: { "create_issue" => true })
agent = Mistri.agent("claude-opus-4-8", tools: tools)Local stdio servers spawn as child processes, credentials in their environment. That is also the whole "give the agent a browser" story:
browser = Mistri::MCP::Client.new(
command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
)
agent = Mistri.agent("claude-opus-4-8",
tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))For the full connect-your-tools story in Rails, generate a connection model (name it whatever you like):
$ bin/rails generate mistri:mcp McpConnectionEach row is one server connection carrying its own OAuth flow state and
encrypted tokens. The OAuth services underneath (Mistri::MCP::OAuth.start,
.complete, .refresh) are storage-agnostic, so the same flow works from a
controller, a GraphQL mutation, or a job. Registration happens as your
application: client_name: is yours to set.
connection, authorize_url = McpConnection.connect(
name: "Linear", url: params[:url],
client_name: "YourApp", redirect_uri: mcp_callback_url,
)
# redirect the user to authorize_url; then, in the callback:
connection = McpConnection.complete(state: params[:state], code: params[:code])
agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))Sinks bridge the event stream to a transport, and compose as blocks:
cable = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
sink = Mistri::Sinks::Coalesced.new(cable) # merges token bursts to UI speed
agent.run(input, &sink)Mistri::Sinks::SSE.new(response.stream) does the same for
ActionController::Live. There is no Railtie and nothing to configure;
the generator and stores duck-type into any app.
# Trip the signal from anywhere; the partial turn persists, resume is clean.
signal = Mistri::AbortSignal.new
agent.run("Draft a long essay.", signal: signal)
# Ceilings are opt-in and off by default.
budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
# Transient failures (429, 5xx, timeouts) retry with backoff, invisibly to
# the model. On by default; retries: false disables.
policy = Mistri::RetryPolicy.new(attempts: 3)photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
agent.run("What trend does this chart show?", images: [photo])
Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
Mistri.agent("claude-opus-4-8", provider_options: { cache: false })rake test is hermetic and fast. rake integration runs every feature end
to end against real provider APIs, once per model in the matrix. Scenarios
assert that coined codenames (a ghost of a word like Wraithowyn exists in
no training data) flowed through tool results, summaries, and child agents:
proof of information flow, not model knowledge.
$ MISTRI_INTEGRATION_MODELS=claude-opus-4-8,gpt-5.5 bundle exec rake integration0.2.0: an MCP client bridge, so any MCP server's tools plug into an agent.
Mistri's architecture is informed by pi by Mario Zechner. See NOTICE.
MIT. See LICENSE.