Skip to content

mcheemaa/mistri

Repository files navigation

مستری

mistri, the agent harness for Ruby applications.

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

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.

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

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

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)

Images and provider options

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

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

Roadmap

0.2.0: an MCP client bridge, so any MCP server's tools plug into an agent.

Credits

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

License

MIT. See LICENSE.

About

The agent harness for Ruby applications. Durable sessions, streaming, human-in-the-loop approval, compaction, structured output, sub-agents, and MCP, with zero runtime dependencies.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages