mistri, the agent harness for Ruby applications.
mistri.sh · documentation · upgrading
A mistri (Urdu: مستری) is the fixer: the skilled tradesperson who actually gets it done. Mistri is an application-embedded agent runtime for Ruby. It owns the model loop, safe tool boundary, event stream, and append-only session record. Your application owns authorization, policy, presentation, and the semantics of its side effects.
Mistri works in plain Ruby applications, including Rails, Sinatra, Hanami, jobs, and services. Rails integrations are optional. The gem declares zero runtime dependencies.
Start in sixty seconds
Add Mistri to your bundle:
gem "mistri"
Then define a tool and run an agent:
require "mistri"
weather = Mistri::Tool.define(
"get_weather", "Current weather for a city.",
schema: -> { string :city, "City name", required: true },
) do |args|
{ city: args.fetch("city"), forecast: "34 C and clear" }
end
agent = Mistri.agent("claude-opus-4-8", tools: [weather])
result = agent.run("What should I wear in Lahore today? One sentence.") do |event|
print event.delta if event.type == :text_delta
end
puts
Mistri.agent infers the provider from the model ID and reads
ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY. Pass api_key:
explicitly when the host resolves credentials another way. A normally
completed invocation returns a Mistri::Result with status
predicates, final text, usage, and structured task output when requested.
Host-contract failures raise as documented in Reliability.
Runnable examples live in examples/.
Why Mistri
- One session model. The same append-only log runs over memory, JSONL, Active Record, or a host store. With a persistent store, runs can suspend, reload, resume, steer, and compact without inventing a second state model. See Sessions and control.
- Human approval does not hold a thread. A gated tool parks the request and
returns. With a shared store, another process can record the decision later;
resumerevalidates the exact call before execution. - Provider semantics survive the abstraction. Anthropic, OpenAI, and Gemini stream through direct implementations that retain each provider's reasoning, tool-pairing, and replay requirements. Unknown model IDs still pass through when their provider can be inferred.
- Tool input crosses a real boundary. Completed model arguments are owned, bounded, validated, and deeply frozen before hooks, approval predicates, MCP, or handlers see them. See Tool contracts.
- The runtime stays small. The gemspec declares no runtime gem dependencies. Optional Rails adapters load only when required.
- Real APIs are part of the test strategy. The live integration suite exercises critical control, persistence, delegation, MCP, and structured-output paths across all three providers when their keys are present. The default CI suite remains hermetic.
Choose the right layer
These projects solve different problems:
| Need | Start with |
|---|---|
| A durable tool loop inside any Ruby application, with append-only sessions, approval, steering, compaction, linked workers, and provider-neutral MCP | Mistri |
| Broad model access plus media, embeddings, image generation, and Rails-backed chat | RubyLLM |
| Agents expressed through Rails controllers, actions, callbacks, views, and Active Job | Active Agent |
Mistri is deliberately not a terminal UI, hosted agent service, broad media client, policy engine, or exactly-once job system. The host application keeps its users, authorization rules, queues, database, UI, idempotency keys, and reconciliation policy. Mistri supplies the execution mechanism that connects them.
How the loop works
- The input is appended to the session before the provider is called.
- Provider events stream to the subscriber as the response arrives.
- Completed tool calls cross Mistri's ownership and validation boundary.
- Current host policy and approval rules run before a handler can start.
- Tool results append to the same session, then the model continues until it answers, hands off the turn, suspends for approval, aborts, or reaches a budget.
Every completed message is appended to the chosen Session store before the next model-visible turn. Opaque reasoning and pairing metadata replay only to the provider that created them; the shared transcript stays provider-neutral.
Tools and approval
A tool has a name, description, JSON Schema object, and handler. Independent
calls in one model turn execute concurrently up to max_concurrency.
book_hotel = Mistri::Tool.define(
"book_hotel", "Books the selected hotel.",
schema: lambda {
string :hotel_id, "Hotel ID", required: true
number :total_usd, "Quoted total", required: true
},
needs_approval: ->(args) { args.fetch("total_usd") > 500 },
) do |args, context|
Bookings.create!(
hotel_id: args.fetch("hotel_id"),
traveler: context.app.fetch(:traveler),
)
end
agent = Mistri.agent(
"claude-opus-4-8",
tools: [book_hotel],
context: { traveler: current_traveler },
)
Validation runs before the approval predicate, so policy never receives a string where the schema promised a number. Invalid calls return a bounded, model-readable error and valid siblings still run. Mistri never coerces model input implicitly.
When a gated call arrives:
result = agent.run("Book the corner suite for the Lisbon trip")
result.awaiting_approval? # => true; the handler did not run
call = result.pending.fetch(0)
agent.session.approve(call.id, note: "Approved by finance")
result = agent.resume
That is the smallest in-process flow. With a durable store, a controller, route, mutation, job, or console can append the decision using only the Session ID and call ID; a later process rebuilds current tools and policy before resuming. See Sessions and control.
Tool results can also carry host-only UI data or an explicit failure fact. See
Tool contracts for schemas, validators, normalizers,
Mistri::ToolResult, hooks, and ends_turn.
Sessions and control
The default memory store is useful for one process. JSONL persists the complete append-only record on local disk without a database:
store = Mistri::Stores::JSONL.new("tmp/mistri-sessions")
session = Mistri::Session.new(store: store)
agent = Mistri.agent("claude-opus-4-8", session: session)
agent.run("Start a haiku about the sea.")
# A later process can reload the same session by ID.
reloaded = Mistri::Session.new(store: store, id: session.id)
Mistri.agent("claude-opus-4-8", session: reloaded).run("Now finish it.")
For a database-backed store, implement append(id, entry) and load(id), or
use the optional Active Record adapter:
$ bin/rails generate mistri:install AgentEntry
require "mistri/stores/active_record"
store = Mistri::Stores::ActiveRecord.new(AgentEntry)
Steering is another append, so a request handler or worker can redirect a live exchange without sharing its Agent object:
Mistri::Session.new(store: store, id: session_id).steer(
"Make the headline blue instead."
)
Only one Agent may actively call run or resume for a session at a time.
Approvals, steering, and worker reports may append concurrently. Read the
reliability contract before connecting tools with
external side effects.
Streaming into any Ruby application
Every run accepts a block. Handle events directly, or compose a sink:
sse = Mistri::Sinks::SSE.new(stream) # any IO-like object
sink = Mistri::Sinks::Coalesced.new(sse) # merge token bursts to UI speed
agent.run(input, &sink)
Mistri::Sinks::ActionCable is available for Rails, but no Railtie is required.
The same event stream works in Sinatra, Rack, a WebSocket server, a background
job, or a test. Event types form an extensible union; consumers should handle
the types they use and ignore the rest.
Long conversations and structured tasks
Compaction is on by default for catalogued models. When a session enters the model's safe headroom, Mistri asks the provider for a visible structured summary, appends it, and continues from the summary plus a recent tail. The exact history remains in the store for transcripts.
agent.context_usage
# => { tokens: 141_000, window: 1_000_000, fraction: 0.141 }
agent.compact # explicit checkpoint; returns nil when nothing should compact
Task mode requires a final JSON value matching a schema. Providers use native constraints only when they can represent the contract; Mistri validates the answer locally in every case.
schema = {
type: "object",
properties: {
"tiers" => { type: "array", items: { type: "string" } },
},
required: ["tiers"],
}
result = agent.task("Extract the pricing tiers.", schema: schema)
result.output # parsed and validated
Task validation applies when that invocation completes normally. An approval
suspension or ends_turn handoff returns with output nil; after settlement or
the human answer, call task again if structured output is still required.
See Sessions and control for compaction boundaries, cross-provider replay, stores, approvals, and transcripts.
Sub-agents
Delegate work into a child session so exploration does not consume the parent's context. Only the report returns to the parent, while the full child transcript remains linked and inspectable.
researcher = Mistri::SubAgent.new(
name: "researcher",
description: "Reads pages and answers factual questions.",
provider: Mistri.provider("claude-haiku-4-5"),
system: "Research the question. Return findings and sources.",
tools: [fetch_page],
)
agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
Mistri also provides a host-bounded spawn tool, named worker types, background dispatch with worker-side runtime reconstruction, exact capability grants, reports, steering, stopping, and a management console. See Sub-agents, including the runtime, concurrency, and lease limits a production dispatcher must understand.
Model Context Protocol
Bridge tools over Streamable HTTP or stdio, then apply the same local approval and validation boundary:
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 },
)
Remote URLs default to public HTTPS with validated, pinned DNS answers. Inbound
records are size-bounded. An unconfirmed tools/call is never replayed
automatically because the server may already have committed its side effect.
OAuth services are storage-agnostic; the Rails connection generator is one
optional host adapter.
Read MCP for stdio, OAuth, private-network policy, schema authority, response limits, and large-result resource links.
Skills, definitions, and workspaces
- Skills expose one-line descriptions until the model requests the full
SKILL.md, keeping the system prompt small. - Definitions load an agent's model, tool names, and prompt from Markdown with YAML frontmatter and checked placeholders.
- Workspaces give the built-in read, write, edit, find, and list tools a directory, memory value, Active Record table, or single host-owned value.
The edit engine refuses ambiguous matches and, when a workspace supports
conditional writes, rebases anchored edits without losing unrelated concurrent
changes. A Directory workspace rejects lexical escape and traversal through
existing symlinks in a stable, host-controlled tree; it is not an
operating-system sandbox. See
Context and workspaces and the runnable
page_editor.rb example.
Reliability contract
Mistri makes failures explicit, but it does not pretend distributed side effects are exactly once:
- Provider requests retry transient failures with backoff. The MCP client never replays an ambiguous tool call; the Agent bridge returns the uncertainty as an error-marked tool result.
- A process can crash after a handler commits an external write but before the result reaches the session. Tools with side effects must be idempotent or reconcilable.
- Budgets are checked between model-visible turns, so they are soft ceilings.
- Provider and MCP records default to an 8 MiB per-record limit; streaming has no total-size limit while each record remains safe.
Mistri::AbortSignalis an in-process cooperative signal. Cross-process worker control uses the configured lock adapter and is also cooperative.
The complete operational contract, event lifecycle, retry behavior, pricing rules, and sink guidance live in Reliability.
Documentation
All primary documentation is versioned with the code and renders directly on GitHub:
| Guide | What it covers |
|---|---|
| Documentation index | The complete task-oriented map |
| Tool contracts | Schemas, validation, approval, hooks, results, and handoff |
| Sessions and control | Stores, replay, approvals, steering, compaction, and transcripts |
| Context and workspaces | Skills, definitions, context transforms, host-owned memory, and editable documents |
| Sub-agents | Specialists, spawning, dispatch, locks, reports, and control |
| MCP | HTTP and stdio, OAuth, egress policy, limits, and schema handling |
| Reliability | Retries, events, budgets, stopping, concurrency, and side effects |
| Upgrade guide | Required host changes between releases |
| Changelog | Complete release history and exact behavior changes |
Testing and contributing
The default suite is hermetic and needs no API keys:
$ bundle exec rake test
$ bundle exec rubocop
Live tests read provider keys from the gitignored
.env.development.local file:
$ MISTRI_LIVE=1 bundle exec rake test
$ bundle exec rake integration
$ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
The integration matrix exercises critical end-to-end scenarios against one Anthropic, OpenAI, and Gemini model by default when the corresponding keys are present. See CONTRIBUTING.md for the project contract.
Compatibility and support
Mistri supports Ruby 3.2 and newer. It is pre-1.0: minor releases may include intentional contract changes with an explicit migration path. Read UPGRADING.md and CHANGELOG.md before upgrading.
Use GitHub issues for reproducible bugs and focused feature proposals. Report vulnerabilities privately as described in SECURITY.md.
Credits
Mistri's architecture is informed by pi by Mario Zechner. See NOTICE.
License
MIT. See LICENSE.