Skip to content

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.

Terminal window
pip install graphmind-ai
  • Distribution name graphmind-ai, import name graphmind.
  • 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.

support_agent.py
import json
import graphmind
from 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.tool
def search_orders(email: str):
return db.orders.find(email=email)
@gm.tool
def 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.

Swap one line:

import anthropic
import 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.

Attach a callback handler — the sync one for sync chains, the async one for ainvoke and LangGraph:

import graphmind
from 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.tool
def 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 handler
await 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 conceptnode kindnode id
chain / runnablechainchain:<name>
LLM / chat modelllmllm:<name>
tooltooltool:<name>
retrieverretrieverretriever:<name>

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.

What each attachment point can actually do. Every ✅ is covered by a test in the package’s tests/.

observebefore holderror holdafter holdinjectretryabort
@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.

CallWhat it does
graphmind.init(app=...) — alias configureCreate (or replace) the process-wide instance.
graphmind.instrument_openai(client) — alias wrap_openaiGate every OpenAI request; returns the client.
graphmind.instrument_anthropic(client) — alias wrap_anthropicGate every Anthropic request; returns the client.
gm.callback_handler() — alias gm.handlerBaseCallbackHandler for sync chains.
gm.async_callback_handler() — alias gm.async_handlerAsyncCallbackHandler for async chains / LangGraph.
@gm.toolGate 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.

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_region
load_us = gm.tool(partial(load_region, "us"), name="load_us") # -> tool:load_us

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

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.py holds 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 continue in well under 100 ms. An atexit hook 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 original seq when 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/ingest and no further.

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=0
node.finished tool:search durationMs=0.05 heldMs=0
node.finished agent:py-docs durationMs=0.78 heldMs=0

For tests, graphmind.clock.set_clock(fn) substitutes the duration clock (a function returning milliseconds), and Session(clock=...) drives only the held-time accounting.

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.

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 this
gm = 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.

VariableMeaning
GRAPHMIND_URLIngest endpoint (default ws://127.0.0.1:4747/ingest)
GRAPHMIND_DISABLED1 disables instrumentation — beats an explicit enabled=True in code
GRAPHMIND1 re-enables it in a production-looking environment
GRAPHMIND_HIDE_INPUTS / _OUTPUTS / _TOOL_ARGS / _TOOL_RESULTS1 or true: record less
GRAPHMIND_LOOP_THRESHOLD / GRAPHMIND_ON_LOOP / GRAPHMIND_LOOP_ALLOWThe 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.