Python
The Python package speaks the same wire protocol as the TypeScript adapters and connects to the
same graphmind CLI, so a Python agent and a TypeScript one look identical in the viewer.
pip install graphmind-ai- Distribution name
graphmind-ai, import namegraphmind. - Python 3.10+, one runtime dependency (
websockets), MIT licensed. - Provider SDKs are optional extras:
pip install 'graphmind-ai[openai]',[anthropic],[langchain], or[all].
The API is idiomatic Python rather than a transliteration: a context manager for runs, a
decorator for tools, and one instrument_* call per client. Everything below has a module-level
form (import graphmind as gm, then gm.tool) and an instance form
(gm = graphmind.init(...), then gm.tool) — init sets the process-wide default, so the two
refer to the same session and can be mixed freely.
End to end (OpenAI)
Section titled “End to end (OpenAI)”import jsonimport graphmindfrom openai import OpenAI
gm = graphmind.init(app="support-agent")
# 1. Instrument the client — every request becomes a gated llm:step node.client = graphmind.instrument_openai(OpenAI())
# 2. Decorate your tools — each call becomes a gated tool node.@gm.tooldef search_orders(email: str): return db.orders.find(email=email)
@gm.tooldef issue_refund(order_id: str, cents: int): return payments.refund(order_id, cents)
TOOLS = {"search_orders": search_orders, "issue_refund": issue_refund}
TOOL_SPECS = [ { "type": "function", "name": "search_orders", "description": "Find a customer's recent orders", "parameters": { "type": "object", "properties": {"email": {"type": "string"}}, "required": ["email"], }, }, { "type": "function", "name": "issue_refund", "description": "Refund an order", "parameters": { "type": "object", "properties": {"order_id": {"type": "string"}, "cents": {"type": "integer"}}, "required": ["order_id", "cents"], }, },]
def main(): # Wait for the debugger handshake so gates are armed from the first event. gm.ready()
# 3. Open a run — everything inside belongs to it. with gm.run("handle-ticket"): messages = [ {"role": "user", "content": "Order #4471 arrived broken. Sort it out."} ]
for _ in range(8): response = client.responses.create( model="gpt-5.4", tools=TOOL_SPECS, input=messages )
calls = [item for item in response.output if item.type == "function_call"] if not calls: print(response.output_text) break
messages += response.output for call in calls: result = TOOLS[call.name](**json.loads(call.arguments)) messages.append({ "type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result), })
gm.dispose()
main()Run graphmind in one terminal, python support_agent.py in another.
instrument_openai patches chat.completions.create (and .parse) and responses.create on
the instance — no library monkey-patching, no import hooks. It is idempotent, and clients
created after the call are not instrumented, so call it on each client you build. Streaming
responses are teed: your code receives exactly the provider’s stream while GraphMind observes the
deltas. A tools=[...] argument is pre-announced as a graph.hint, so the viewer draws the tool
roster grey before anything runs.
Anthropic
Section titled “Anthropic”Swap one line:
import anthropicimport graphmind
gm = graphmind.init(app="support-agent")client = graphmind.instrument_anthropic(anthropic.Anthropic())
with gm.run("handle-ticket"): message = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, tools=TOOL_SPECS, messages=messages, )Everything else — @gm.tool, gm.run, gm.ready — is identical.
messages.stream is instrumented too. There the HTTP request happens in __enter__, so that is
where the gate holds, and the proxy observes both consumption styles (raw event iteration and
.text_stream). It is the one provider attachment point where inject and retry are
unavailable: GraphMind cannot fabricate a provider stream object, so it holds, warns, and
continues rather than lying about having substituted one.
LangChain / LangGraph
Section titled “LangChain / LangGraph”Attach a callback handler — the sync one for sync chains, the async one for ainvoke and
LangGraph:
import graphmindfrom langgraph.prebuilt import create_react_agent
gm = graphmind.init(app="support-agent")gm.ready()
# Decorate the tools to get inject and retry — the handler alone cannot do those.@gm.tooldef search_orders(email: str) -> list[dict]: """Find a customer's recent orders.""" return db.orders.find(email=email)
# LangChain builds its schema from the decorated function's name, docstring and# signature, all of which `@gm.tool` preserves — so hand it the wrapper directly.agent = create_react_agent(model, tools=[search_orders, issue_refund])
with gm.run("handle-ticket"): result = agent.invoke( {"messages": [{"role": "user", "content": "Order #4471 arrived broken."}]}, config={"callbacks": [gm.handler()]}, )
gm.dispose()# async graphs / LangGraph — use the async handlerawait gm.ready_async()result = await agent.ainvoke(payload, config={"callbacks": [gm.async_handler()]})Chains, LLM calls, chat models, tools and retrievers each get their own node kind, and
LangChain’s parent_run_id becomes the parent/child structure on the canvas.
| LangChain concept | node kind | node id |
|---|---|---|
| chain / runnable | chain | chain:<name> |
| LLM / chat model | llm | llm:<name> |
| tool | tool | tool:<name> |
| retriever | retriever | retriever:<name> |
Anything else: spans
Section titled “Anything else: spans”For the parts of a graph GraphMind cannot see by itself — a LangGraph node body, a hand-rolled planner loop, a retrieval step in your own framework:
with gm.span("plan", kind="chain") as span: # `async with` too plan = build_plan(state) span.set_output(plan)A span has a before gate and honours abort. It owns no return value, so an injected value is
surfaced as the span’s output rather than silently dropped.
Capability matrix
Section titled “Capability matrix”What each attachment point can actually do. Every ✅ is covered by a test in the package’s
tests/.
| observe | before hold | error hold | after hold | inject | retry | abort | |
|---|---|---|---|---|---|---|---|
@gm.tool / gm.wrap_tools (sync + async) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
gm.span (sync + async) | ✅ | ✅ | — | — | as span output | — | ✅ |
OpenAI chat.completions / responses | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Anthropic messages.create | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Anthropic messages.stream | ✅ | ✅ (in __enter__) | ✅ | — | ❌ | ❌ | ✅ |
| LangChain sync handler | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| LangChain async handler | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Same shape as the LangGraph JS matrix, and for the same reason: a callback observes, a wrapper owns the call site.
The surface
Section titled “The surface”| Call | What it does |
|---|---|
graphmind.init(app=...) — alias configure | Create (or replace) the process-wide instance. |
graphmind.instrument_openai(client) — alias wrap_openai | Gate every OpenAI request; returns the client. |
graphmind.instrument_anthropic(client) — alias wrap_anthropic | Gate every Anthropic request; returns the client. |
gm.callback_handler() — alias gm.handler | BaseCallbackHandler for sync chains. |
gm.async_callback_handler() — alias gm.async_handler | AsyncCallbackHandler for async chains / LangGraph. |
@gm.tool | Gate a function: a tool:<name> node with inject / retry / abort. |
gm.wrap_tools({...}) | The same, for a mapping, a list, or one callable. |
with gm.run("name"): | Open a run. async with works on the same object. |
with gm.span("name", kind=...): | A gated node for anything else. async with too. |
gm.ready(timeout=2.0) / await gm.ready_async(timeout=2.0) | Wait for the handshake. False means detached, not an error. |
gm.stats() | Diagnostics: enabled, attached, buffered, dropped, held gates, seq. |
gm.dispose() | Release held gates, flush events, close the socket. Idempotent, no arguments. |
Every call above also exists as a method on an explicit instance (graphmind.GraphMind(app=...)),
which is what you want when one process debugs more than one agent.
Naming nodes
Section titled “Naming nodes”One node per code location; executions light it up. The node name is the callable’s
__name__ — and for the two common callables that have none, GraphMind falls back to something
stable rather than a repr with a memory address in it:
from functools import partial
load_eu = gm.tool(partial(load_region, "eu")) # -> tool:load_regionload_us = gm.tool(partial(load_region, "us"), name="load_us") # -> tool:load_usA functools.partial is named after the function it wraps; an instance of a class with
__call__ is named after its class. Two partials of the same function are therefore one node,
which is usually what you want — pass name= (or a key in gm.wrap_tools({...})) when you want
them apart.
Same guarantees
Section titled “Same guarantees”The Python package holds the identical contract:
- Never raises into your app. Internal failures degrade to a rate-limited warning on stderr and uninstrumented behaviour. Your own exceptions propagate untouched.
- Cheap when detached.
tests/test_overhead.pyholds the budgets: under 20 µs per wrapped call when disabled, under 1 ms per wrapped call when enabled but detached (two envelopes into the replay ring buffer), and under 20 µs per detached gate check. - Fails open. A debugger that disconnects mid-hold releases every gate with
continuein well under 100 ms. Anatexithook does the same at interpreter exit, and the transport thread is a daemon, so GraphMind can never keep a process alive. - Bounded memory and payloads. Events emitted while detached go into a ring buffer (default
2,000 events,
buffer_size) and are replayed with their originalseqwhen a viewer attaches. Prompts, arguments and results are depth-, width- and length-capped before serialization. fork()-safe. The loop thread is re-created in the child, so pre-forking servers (gunicorn, uvicorn workers, Celery) keep working.- Off in production. See below.
- Local-first. Events go to
ws://127.0.0.1:4747/ingestand no further.
Durations and held time
Section titled “Durations and held time”Nodes are timed with time.perf_counter(), a monotonic high-resolution clock. durationMs is
fractional to 0.01 ms, so a 60 µs tool shows <0.1ms rather than 0ms. durationMs is still
wall-clock time and includes any time a gate held the node. node.finished also carries
heldMs: the time the debugger held that execution, including holds on anything inside it. The
viewer shows ran time (durationMs - heldMs); see durations.
Checked against a real server:
node.finished tool:search durationMs=0.6 heldMs=0node.finished tool:search durationMs=0.05 heldMs=0node.finished agent:py-docs durationMs=0.78 heldMs=0For tests, graphmind.clock.set_clock(fn) substitutes the duration clock (a function returning
milliseconds), and Session(clock=...) drives only the held-time accounting.
Recording less
Section titled “Recording less”The same four switches as the TypeScript client, with the same placeholder and the same wire
output: GRAPHMIND_HIDE_INPUTS, GRAPHMIND_HIDE_OUTPUTS, GRAPHMIND_HIDE_TOOL_ARGS,
GRAPHMIND_HIDE_TOOL_RESULTS (1 or true), or as options:
gm = graphmind.init(app="support-agent", hide_tool_args=True, hide_tool_results=True)Either the environment or the option turning a switch on turns it on; hide_inputs=False cannot
undo GRAPHMIND_HIDE_INPUTS=1. Options also accept 1, "1" and "true". The hidden value
becomes "__REDACTED__" before the event is buffered, so a debugger that attaches later never
sees it. This covers @gm.tool, spans, the OpenAI and Anthropic instrumentation (streamed tokens
included) and the LangChain handlers. Checked with GRAPHMIND_HIDE_TOOL_ARGS=1 against a real
server:
node.started tool:search input="__REDACTED__" redaction={"count":1,"keys":["input"]}Exception messages and tracebacks (node.error) are never redacted. The tool-only switches do
not hide the same data inside LLM messages; use hide_inputs and hide_outputs for that. The
full rules are on environment → recording less.
Conformance with the TypeScript client is tested, not assumed: tests/test_redaction.py
reproduces the shared fixture byte for byte and matches a 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, with reason loop. Continue runs it, and the next identical call is
held again. Inject substitutes a result; through a LangChain callback it degrades to
continue, as at any callback gate. Abort raises GraphMindAbortError. A retry does not
hold twice. 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 thisgm = graphmind.init( app="support-agent", loop_guard={"threshold": 5, "allow_nodes": ["poll_job"]}, # or loop_guard=False)loop_guard takes threshold, mode ("pause", "warn", "off"), allow_nodes,
ignore_keys and kinds; camelCase keys are accepted too. For each field the option wins
over GRAPHMIND_LOOP_THRESHOLD / GRAPHMIND_ON_LOOP / GRAPHMIND_LOOP_ALLOW, which win over
the default. Python-specific comparison rules: Pydantic models compare by model_dump(), sets
ignore order, bytes are compared exactly, other objects compare by repr(), and an argument
that raises while being read is never counted as a repeat. Under hide_inputs, or
hide_tool_args on a tool, the loop’s fingerprint is sent as "__REDACTED__".
Fingerprinting runs on every watched tool call, attached or not; the test bounds it at 0.5 ms
for a 1 KB argument (test_fingerprinting_a_1kb_input_is_cheap), and it grows with argument
size. In a server that does not wrap each request in gm.run, every request shares one implicit
run, so the same call three times back-to-back across three separate requests counts as a loop.
Wrap requests in runs, or use allow_nodes. The general rules are on
environment → loop hold.
Environment
Section titled “Environment”| Variable | Meaning |
|---|---|
GRAPHMIND_URL | Ingest endpoint (default ws://127.0.0.1:4747/ingest) |
GRAPHMIND_DISABLED | 1 disables instrumentation — beats an explicit enabled=True in code |
GRAPHMIND | 1 re-enables it in a production-looking environment |
GRAPHMIND_HIDE_INPUTS / _OUTPUTS / _TOOL_ARGS / _TOOL_RESULTS | 1 or true: record less |
GRAPHMIND_LOOP_THRESHOLD / GRAPHMIND_ON_LOOP / GRAPHMIND_LOOP_ALLOW | The loop hold |
“Production-looking” is a deliberately boring, documented rule rather than a heuristic: the
first variable that is set out of GRAPHMIND_ENV, ENVIRONMENT, APP_ENV, PYTHON_ENV,
ENV, DJANGO_ENV, FLASK_ENV, NODE_ENV decides, and it counts as production when its value
is production or prod (case-insensitive). No hostname sniffing, no cloud metadata probes.
A disabled session never opens a socket, never allocates a buffer and never touches your objects:
instrument_openai returns the client untouched.