Skip to content

Ruby

The Ruby SDK speaks the same wire protocol as every other adapter, so the same graphmind server and the same viewer debug a Ruby agent with no special mode. What is different is the shape Ruby code actually takes: there is no dominant agent framework, so the gem leads with gated callables — wrap a block, a Proc, a Method, or a hash of them — and adds client instrumentation for the two libraries people do use.

require "graphmind"
gm = Graphmind.configure(app: "support-agent")
search = gm.tool("search_orders") { |email:| Order.where(email: email).limit(5).as_json }
gm.run("handle-ticket") do
search.call(email: "alex@example.com")
end

Run npx graphmind-ai in another terminal and open http://127.0.0.1:4747. With nothing attached, every line above is a no-op.

The gem is called graphmind, needs Ruby ≥ 3.1 (CI runs 3.1 and 3.3), and has no runtime dependencies — the WebSocket client is hand-rolled over stdlib sockets, so adding a debugger to someone else’s Gemfile cannot force a resolution on faraday, websocket-driver or eventmachine.

Graphmind.configure(app:, **opts)Create (and become) the process-wide instance. Returns a Graphmind::Client.
gm.run(name) { |ctx| … }One run boundary — one AbortController, one agent node.
gm.tool(name) { |**args| … }Wrap a block as a gated callable. Call it with .call(...).
gm.wrap(callable, name:)The same for a Proc, a Method, or anything with #call.
gm.wrap_tools(hash_or_array)Wrap a {name => callable} hash, an array, or one callable.
gm.wrap_method(obj, :name)Gate one method on one object, in place.
gm.span(name, kind:, input:) { |s| … }An arbitrary node on the canvas — a retrieval step, a planner loop, a Sidekiq job body.
gm.instrument_openai(client)Instrument a ruby-openai OpenAI::Client in place.
gm.instrument_ruby_llm(chat)Instrument a RubyLLM::Chat and its tools in place.
gm.ready(timeout = 2.0)Attach guarantee. false means “carry on detached”, never an error.
gm.stats / gm.disposeSession stats; release held gates and close the socket.

Every method is also available on the module itself — Graphmind.run, Graphmind.tool, Graphmind.span — delegating to a default instance created on first use. Use configure when you want to name the app or pass options; use the module-level shorthand in a script.

gm.tool is the sharp end of the debugger, because only a wrapper that owns the call site can substitute a result:

refund = gm.tool("issue_refund") { |order_id:, cents:| Payments.refund(order_id, cents) }
gm.run("handle-ticket") do
refund.call(order_id: "4471", cents: 2400)
end
GateBehaviour
beforeFires before the block runs. inject returns the debugger’s value without calling it at all — nothing happens, no side effect.
afterPost-body, pre-return. Step mode, or an explicit after breakpoint.
errorFires when the block raises, before the caller sees it. inject swallows the error and returns a value; retry re-runs the body; continue re-raises the original; abort aborts the run.

For the parts of the graph nothing can infer — a retrieval step, a hand-rolled planner loop, a background job:

gm.span("retrieve", kind: "retriever", input: { query: query }) do |span|
docs = Index.search(query, k: 8)
span.output = { count: docs.size, ids: docs.map(&:id) }
docs
end

The block’s value is the span’s value; span.output = sets what the node shows when the two should differ (a big result you do not want in the debugger, or a summary that reads better).

Both integrations prepend a module to that object’s singleton class. Nothing global is monkey-patched: another client or chat in the same process is untouched, and neither gem is loaded unless you ask for it.

client = gm.instrument_openai(OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY']))
client.chat(parameters: { model: 'gpt-4o-mini', messages: [...] })

An llm:step node per chat / responses.create, with the model, the trimmed messages, the reply and token usage. The before gate means the debugger can pause and inject a response without the request ever leaving the process; the error gate catches a 429 or a timeout, and retry re-sends. Streamed deltas reach the canvas when you pass stream:.

Tool execution is your code, not the gem’s — gate it with gm.tool / gm.wrap_tools.

chat = gm.instrument_ruby_llm(RubyLLM.chat.with_tools(Weather))
chat.ask("what's the weather in Cairo?")

An llm:step node per provider round-trip — one per API call, not one per ask — and a tool:<name> node per RubyLLM::Tool#call with real inject and retry, so the debugger can replace a tool result and let the model carry on with it. Gates at before / error / after on both.

The LLM hook is provider_completion, which is one HTTP round-trip. It is private, so if a future version renames it the adapter falls back to complete_once and then to the public complete (coarser: one node for the whole turn). Whichever hook was used is reported on the node as hook, so you are never guessing which one you got.

Both RubyLLM majors are supported, and CI runs the suite against each: the newest release (2.x) and the last 1.x. The hooks are private API whose signatures changed in 2.0, so every patch forwards whatever arguments it receives. A signature change in a future release cannot turn into an ArgumentError inside your chat, attached or not.

require "graphmind" loads a Railtie when Rails is present, so an initializer is all you need:

config/initializers/graphmind.rb
Graphmind.configure(app: "checkout")

The app name defaults to your Rails application’s name, and the production kill switch keys off RAILS_ENV (below), so a debugger left in the Gemfile does nothing in production.

Precedence, first match wins — the same rule as the JS and Python clients, with a Ruby-flavoured production check because there is no NODE_ENV:

ConditionResult
GRAPHMIND_DISABLED=1Disabled. Beats everything, including enabled: true.
enabled: passedAs given.
Looks like productionDisabled unless GRAPHMIND=1.
OtherwiseEnabled.

“Looks like production” is the first variable that is set out of GRAPHMIND_ENV, ENVIRONMENT, APP_ENV, RAILS_ENV, RACK_ENV, ENV, NODE_ENV, counting production / prod (case-insensitive) as production. A boring documented list rather than a heuristic, on purpose.

GRAPHMIND_URL moves the ingest endpoint, exactly as elsewhere. The GRAPHMIND_HIDE_* and loop variables below are read too.

Nodes are timed with Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond). durationMs is fractional to 0.01 ms and is still wall-clock time, holds included. node.finished also carries heldMs, the time the debugger held that execution. The viewer shows ran time; see durations. Checked against a real server:

node.finished tool:search durationMs=6.52 heldMs=0
node.finished tool:search durationMs=0.04 heldMs=0

For tests, Graphmind::Clock.override = -> { ms } replaces the duration clock, and Graphmind::Session.new(clock:) drives only the held-time accounting.

The same four switches as the TypeScript and Python SDKs, with the same placeholder and wire output: GRAPHMIND_HIDE_INPUTS, GRAPHMIND_HIDE_OUTPUTS, GRAPHMIND_HIDE_TOOL_ARGS, GRAPHMIND_HIDE_TOOL_RESULTS (1 or true), or as options:

Graphmind.configure(app: "checkout", hide_tool_args: true, hide_tool_results: true)

Either source turning a switch on turns it on, and options also accept 1 and "true". Hashes with Symbol keys are redacted like String keys, because JSON writes both the same way. session.redaction_switches shows what is in effect. Checked with GRAPHMIND_HIDE_TOOL_RESULTS=true against a real server:

node.finished tool:search output="__REDACTED__" redaction={"count":1,"keys":["output"]}

Exception messages (node.error) are never redacted, and the tool-only switches do not hide the same data inside LLM messages. The full rules are on environment → recording less. test/test_redaction.rb reproduces the shared conformance fixture byte for byte and matches the differential file generated from the TypeScript build.

While a debugger is attached, the third call in a row of one tool with identical arguments, with no other tool call between, is held before it runs, on the calling thread only. This covers anything that goes through the gem’s wrappers: gm.tool, gm.wrap, wrap_tools, wrap_method. With no debugger attached, nothing is held and one line is logged per streak:

[graphmind] possible loop: search (tool:search) was called 3× in a row with identical arguments; no debugger is attached to hold it (start `npx graphmind-ai` to pause it there). Polling on purpose? add it to loop_guard: { allow_nodes: [...] } or GRAPHMIND_LOOP_ALLOW; GRAPHMIND_ON_LOOP=off silences this
Graphmind.configure(app: "checkout", loop_guard: { threshold: 5, allow_nodes: [:poll_job] })

loop_guard: takes threshold, mode ("pause", "warn", "off"), allow_nodes, ignore_keys and kinds, as Symbol, String or camelCase keys. session.loop_guard_config shows what is in effect. For each field the option wins over GRAPHMIND_LOOP_THRESHOLD / GRAPHMIND_ON_LOOP / GRAPHMIND_LOOP_ALLOW, which win over the default.

Ruby-specific comparison rules: Symbol and String keys and values count as the same argument; Structs and other #to_h objects compare by to_h; other objects compare by #inspect. Comparison uses the full arguments, even though the recorded input is truncated at 200 items. Under hide_inputs:, or hide_tool_args: on a tool, the fingerprint is sent as "__REDACTED__". Fingerprinting runs on every watched call and grows with argument size; the test bounds it at 0.5 ms for a 1 KB argument. The general rules are on environment → loop hold.

Graphmind.configure accepts app:, sdk: and every session option:

OptionDefaultMeaning
url:ws://127.0.0.1:4747/ingestIngest endpoint (or GRAPHMIND_URL)
enabled:kill-switch logicForce on/off (GRAPHMIND_DISABLED=1 still wins)
meta:—Extra metadata on every run.started
pause_timeout:hold foreverAuto-continue a gate nobody resumes
buffer_size:2000Replay ring buffer, in events (also capped at max_buffer_bytes:, 8 MiB)
hide_inputs: … hide_tool_results:falseRecording less
loop_guard:{ threshold: 3, mode: "pause" }Loop hold; false turns it off
logger:warnWhere the rate-limited warnings go

A payload over 512 KB is shrunk to a preview before it leaves the gem, exactly as in the other SDKs — see limits. One Ruby-specific caveat: when a cut lands inside an astral character (an emoji), the shrunk string carries a lone-surrogate escape, because the wire format is what JavaScript writes. The hub reads it fine, and the gem never re-parses its own frames. But Ruby’s JSON.parse refuses a lone surrogate, so your own tooling that re-parses a stored or exported frame needs a permissive parser (for example JSON.parse(text, allow_invalid_unicode: true) where your JSON library offers it, or read those strings as bytes).

The same four promises the rest of GraphMind makes:

  • Disabled: wrapping is identity, nothing is emitted, no socket is opened.
  • Enabled but detached: gates resolve continue immediately; events go to the ring buffer only and replay when a debugger attaches.
  • The gem never raises into your app. Internal failures degrade to a rate-limited warning. Errors raised by your code inside a run propagate untouched — they are your errors.
  • A debugger that disconnects mid-hold releases every held gate with continue.
  • Concepts — runs, nodes, gates and the four actions.
  • Writing an adapter — the wire contract, if you want to instrument a Ruby library the gem does not cover yet.