مستری

mistri, the agent harness for Ruby applications.

mistri.sh · docs

Gem Version CI Coverage Ruby >= 3.2 Runtime dependencies: zero License: MIT

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

Why Mistri

  • 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).

Install

gem "mistri"

Sixty-second start

agent = Mistri.agent("claude-opus-4-8")
result = agent.run("Name three Ruby web frameworks.")
puts result.text

Mistri.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.

Tools

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")
end

A 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 })
end

Handlers and hooks can take the run's context as a second argument, and context.app carries whatever object you pass as Mistri.agent(context:) — the acting user, a tenant, a request — so tools stay authorization-aware without closure gymnastics:

agent = Mistri.agent("claude-opus-4-8", tools: tools,
                     context: { traveler: current_traveler })

Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
  Bookings.create!(args, traveler: context.app[:traveler])
end

Human approval

Mark 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.

book_hotel = Mistri::Tool.define("book_hotel", "Books the chosen hotel.",
                                 needs_approval: ->(args) { args["total_usd"].to_i > 500 }) do |args|
  Bookings.create!(args)
end

result = agent.run("Book the corner suite for the Lisbon trip")
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).resume

The harness renders nothing: it emits an :approval_needed event and your app draws the UI.

Steering

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.")

Sessions

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 AgentEntry
require "mistri/stores/active_record"
store = Mistri::Stores::ActiveRecord.new(AgentEntry)

Compaction

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.

Task mode

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 validated

Skills

Expert 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::Skill

A skill is a SKILL.md (or flat .md) with name:/description: frontmatter, or built from database rows with Mistri::Skill.new(name:, description:, body:).

Definitions

An agent as a markdown file: YAML frontmatter for config, the body as the prompt, {placeholders} filled at build time (unfilled ones raise). Tool names and any extra keys stay your vocabulary; the gem only reads the file.

definition = Mistri::Definition.load("app/agents/trip_planner.md")
agent = Mistri.agent(definition.model,
                     system: definition.render(first_name: traveler.first_name),
                     tools: registry.build(definition.tools, traveler))

Sub-agents

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: a name for the event stream, 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])

Editing documents

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.

MCP

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)

The bridge lists the server's tools once, at build time; client.tools(refresh: true) re-lists when a host wants a changed toolset. prefix: namespaces local names (linear__create_issue) because duplicate tool names raise at Agent.new: collisions fail loud instead of one server's tool silently shadowing another's.

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 McpConnection

Each 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"))

Streaming into Rails

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.

Stopping, budgets, reliability

# 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)

Retries are invisible to the model but not to your UI: each backoff emits a :retry event carrying attempt, max_attempts, and delay, so a sink can show a live "reconnecting" state instead of a silent spinner. Terminal events are loop-owned: :done and :error reach the subscriber only for the accepted attempt, so a recovered retry never flashes an error it then walks back.

agent.run("Plan the itinerary.") do |event|
  case event.type
  when :text_delta then stream(event.delta)
  when :retry then banner("Retrying (#{event.attempt}/#{event.max_attempts}) in #{event.delay}s")
  when :done, :error then clear_banner
  end
end

Images and provider options

photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
photo = Mistri::Content::Image.from_data_uri(params[:image])   # canvases and uploads
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 })

Testing

rake test is hermetic and fast. rake integration runs every feature end to end against real provider APIs, once per model in the matrix: an Anthropic, an OpenAI, and a Gemini model by default. 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.

$ bundle exec rake integration
$ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration

Roadmap

Next up: strict tool schemas, provider-native MCP passthrough, and the hardening that falls out of the first production applications.

Credits

Mistri's architecture is informed by pi by Mario Zechner. See NOTICE.

License

MIT. See LICENSE.