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")endRun 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.dispose | Session 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.
Gated callables
Section titled “Gated callables”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| Gate | Behaviour |
|---|---|
before | Fires before the block runs. inject returns the debugger’s value without calling it at all — nothing happens, no side effect. |
after | Post-body, pre-return. Step mode, or an explicit after breakpoint. |
error | Fires 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) } docsendThe 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).
Client instrumentation
Section titled “Client instrumentation”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.
ruby-openai
Section titled “ruby-openai”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.
RubyLLM
Section titled “RubyLLM”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:
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.
Kill switches
Section titled “Kill switches”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:
| Condition | Result |
|---|---|
GRAPHMIND_DISABLED=1 | Disabled. Beats everything, including enabled: true. |
enabled: passed | As given. |
| Looks like production | Disabled unless GRAPHMIND=1. |
| Otherwise | Enabled. |
“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.
Durations and held time
Section titled “Durations and held time”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=0node.finished tool:search durationMs=0.04 heldMs=0For tests,
Graphmind::Clock.override = -> { ms } replaces the duration clock, and
Graphmind::Session.new(clock:) drives only the held-time accounting.
Recording less
Section titled “Recording less”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.
Loop hold
Section titled “Loop hold”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 thisGraphmind.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.
Options
Section titled “Options”Graphmind.configure accepts app:, sdk: and every session option:
| Option | Default | Meaning |
|---|---|---|
url: | ws://127.0.0.1:4747/ingest | Ingest endpoint (or GRAPHMIND_URL) |
enabled: | kill-switch logic | Force on/off (GRAPHMIND_DISABLED=1 still wins) |
meta: | — | Extra metadata on every run.started |
pause_timeout: | hold forever | Auto-continue a gate nobody resumes |
buffer_size: | 2000 | Replay ring buffer, in events (also capped at max_buffer_bytes:, 8 MiB) |
hide_inputs: … hide_tool_results: | false | Recording less |
loop_guard: | { threshold: 3, mode: "pause" } | Loop hold; false turns it off |
logger: | warn | Where the rate-limited warnings go |
Oversized payloads
Section titled “Oversized payloads”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).
Fail-open invariants
Section titled “Fail-open invariants”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
continueimmediately; 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.
See also
Section titled “See also”- 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.