Connectors · LangGraph
LangGraph Connector
Pause LangGraph runs with interrupt(), route encrypted approvals to every device registered for the user (web, iOS, Android, Telegram), and resume the graph by polling the relay for the settled outcome. Built on top of the Python Agent SDK.
What it does
langgraph-noxy is a thin layer over the Noxy Python SDK that turns any LangGraph node into a human-in-the-loop checkpoint. It owns three responsibilities:
- Send — builds an encrypted actionable from your graph state and calls
send_decisionon the Noxy relay. - Suspend — calls LangGraph's
interrupt()so the graph saves its state to the configured checkpointer and returns control to your server. - Resume — polls the relay's
GetDecisionOutcomevia the SDK until the user responds (or the decision expires), then callsgraph.invoke(Command(resume=…))with the decision in state.
Installing pulls in the SDK. pip install langgraph-noxy brings in noxy-sdk automatically — you do not need a separate checkout of the Noxy SDK. The connector wires interrupt() to Noxy's encrypted multi-device delivery and gRPC outcome polling so you do not invent your own correlation table.
Flow
┌───────────┐ send_decision ┌──────────┐ route ┌──────────────────────┐
│ LangGraph │ ────────────────▶ │ Noxy │ ────────▶ │ Devices (web / iOS / │
│ node │ │ Relay │ │ Android / Telegram) │
└─────┬─────┘ └────┬─────┘ └─────────┬────────────┘
│ interrupt() │ get_decision_outcome │ Approve / Reject
▼ │ (poll w/ backoff) ▼
┌───────────┐ │ ┌──────────────┐
│Checkpoint │ ◀──────────────────────┴──────────────────── │ Your server │
│ (state) │ wait_and_resume() → Command(resume=…) │ FastAPI… │
└───────────┘ ─────────────────────────────────────────────▶ └──────────────┘
- Your graph reaches the HITL node — the node builds an actionable from current state.
- The connector calls
send_decision; the relay encrypts and fans out to every device for the identity. - The node calls
interrupt(); LangGraph persists the state and returns fromgraph.invokewith an__interrupt__marker carrying thedecision_id. - The user approves or rejects on any device, or the decision TTL expires.
- You call
bridge.wait_and_resume(graph, decision_id)— the SDK pollsGetDecisionOutcomewith exponential backoff until the decision settles. - The handler re-runs the HITL node, which now returns the decision to downstream state via
Command(resume=…).
Relay delivers outcomes via gRPC polling (GetDecisionOutcome); there is no webhook to host. The SDK applies exponential backoff between polls.
Requirements
- Python 3.10 or newer.
- A LangGraph graph compiled with a checkpointer — required for
interrupt()/Command(resume=…). - A Noxy app — see Create App — for the
NOXY_APP_TOKEN. - An identity for your user: phone (E.164), email, your own user id, or wallet address (
0x…). See Identity types.
Installation
# Production
pip install langgraph-noxy
# With the FastAPI example server included
pip install "langgraph-noxy[examples]"The package ships with noxy-sdk >= 2.1.0, langgraph >= 0.2.0, and langgraph-checkpoint >= 2.0.0 as dependencies.
Configuration
Set credentials in your environment or pass them to NoxyConfig:
| Variable | Required | Default | Description |
|---|---|---|---|
NOXY_APP_TOKEN | Yes | — | App token from the Noxy dashboard (Bearer auth to relay). |
NOXY_IDENTITY_ID | Yes* | — | Target identity: phone, email, user id, or wallet address. |
NOXY_ENDPOINT | No | https://relay.noxy.network | Relay gRPC endpoint. |
*Identity is passed to NoxyLangGraphBridge(client, identity_id) in code; use the env var only if your app reads it from the environment.
Quick start
End-to-end: a one-node graph that asks the user to approve a task before continuing.
import uuid
from typing import Optional, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from noxy import NoxyConfig, init_noxy_agent_client
from langgraph_noxy import NoxyLangGraphBridge, build_tool_call_actionable
class State(TypedDict, total=False):
task: str
noxy_decision: Optional[dict]
_noxy_sent_decision_id: Optional[str]
def build_actionable(state: State) -> dict:
return build_tool_call_actionable(
tool="run_task",
args={"task": state["task"]},
title="Approve task?",
summary=state["task"],
)
client = init_noxy_agent_client(
NoxyConfig(
endpoint="https://relay.noxy.network",
auth_token="your-app-token",
decision_ttl_seconds=3600,
)
)
identity = "user@example.com" # email, phone, user_id, or 0x…
bridge = NoxyLangGraphBridge(client, identity)
builder = StateGraph(State)
builder.add_node("noxy_hitl", bridge.create_hitl_node(build_actionable))
builder.add_edge(START, "noxy_hitl")
builder.add_edge("noxy_hitl", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
paused = graph.invoke({"task": "Send 1 wei"}, config)
decision_id = paused["__interrupt__"][0].value["decision_id"]
# Poll relay until approved / rejected / expired (SDK exponential backoff)
final = bridge.wait_and_resume(graph, decision_id)
print(final["noxy_decision"]) # → {"outcome": "approved", "approved": True, ...}Manual polling
If you already poll relay elsewhere (a worker, a cron, a separate service), resume from a single get_decision_outcome response instead of running the built-in loop:
from noxy.decision_outcome import WaitForDecisionOutcomeOptions
resume_handler = bridge.create_resume_handler(graph)
response = client.wait_for_decision_outcome(
WaitForDecisionOutcomeOptions(decision_id=decision_id, identity_id=identity)
)
final = resume_handler.resume_from_poll_response(
response, decision_id=decision_id, identity_id=identity
)Graph state schema
Two state keys are part of the contract between your graph and the connector:
| Key | Written by | Purpose |
|---|---|---|
noxy_decision (default; configurable) | HITL node, on resume | Carries the human's decision into your graph: {outcome, approved, decision_id, identity_id, received_at}. |
_noxy_sent_decision_id | Resume handler | Marks that the decision was already routed so re-running the HITL node after resume does not double-send. The constant NOXY_SENT_DECISION_ID_KEY exposes the literal. |
Always declare both as optional fields on your TypedDict state:
from langgraph_noxy import NOXY_SENT_DECISION_ID_KEY # "_noxy_sent_decision_id"
class State(TypedDict, total=False):
task: str
noxy_decision: Optional[dict]
_noxy_sent_decision_id: Optional[str]Why this exists. LangGraph re-executes a node from the top after resume. Without the _noxy_sent_decision_id guard the node would call send_decision a second time and use up another quota slot. The resume handler sets it via Command(update=…) so the second pass skips routing and goes straight to returning the human decision.
Building actionables
The HITL node accepts a build_actionable(state) -> dict callable. Use the helper to build a standard propose_tool_call payload:
from langgraph_noxy import build_tool_call_actionable
def build_actionable(state: State) -> dict:
return build_tool_call_actionable(
tool="transfer_funds",
args={"to": "0x000…dEaD", "amountWei": "1"},
title="Approve transfer of 1 wei?",
summary="Agent wants to send 1 wei to the burn address.",
# extra fields are merged verbatim into the actionable
extra={"chain": "ethereum", "ttlHint": "5m"},
)The shape matches the Noxy decision payload — see Decisions & Lifecycle for the full schema.
Poll tuning
Pass WaitForDecisionOutcomeOptions to bridge.wait_and_resume (the same fields the Python SDK accepts) to control the polling loop:
| Field | Default | Description |
|---|---|---|
initial_poll_interval_ms | 400 | First delay between polls. |
max_poll_interval_ms | 30000 | Cap between polls. |
max_wait_ms | 900000 | Stop polling and resume with a timeout outcome. |
backoff_multiplier | 1.6 | Exponential backoff factor. |
from noxy.decision_outcome import WaitForDecisionOutcomeOptions
final = bridge.wait_and_resume(
graph,
decision_id,
wait_options=WaitForDecisionOutcomeOptions(
decision_id=decision_id,
identity_id=identity,
max_wait_ms=300_000,
),
)Timeout / expired outcomes
When the poll budget (max_wait_ms) is exceeded, or the relay reports an expired / timeout outcome, the decision is propagated as a regular decision and your downstream nodes can branch on noxy_decision.outcome. To short-circuit the graph or apply a default action, pass on_timeout to the node:
def on_timeout(state, resume) -> dict:
# Apply a safe default — e.g. mark the action as skipped
return {
"noxy_decision": resume.to_state(),
"status": "timed_out_default",
}
bridge.create_hitl_node(build_actionable, on_timeout=on_timeout)API reference
NoxyLangGraphBridge(client, identity_id, *, registry=None)
One bridge per identity wires the relay client, the in-memory pending-interrupt registry, the HITL node factory, and the resume handler.
| Method | Returns | Purpose |
|---|---|---|
create_hitl_node(build_actionable, *, state_key="noxy_decision", on_timeout=None) | Callable LangGraph node | Build the node you add to your graph. |
create_resume_handler(graph) | NoxyGraphResumeHandler | Lower-level handler for manual polling / resume. |
wait_and_resume(graph, decision_id, *, wait_options=None) | Final graph state | Poll the relay via the SDK, then resume the paused graph in one call. |
NoxyGraphResumeHandler
| Method | When to use |
|---|---|
wait_and_resume(client, options) | Run the SDK poll loop, then resume. options is a WaitForDecisionOutcomeOptions. |
wait_and_resume_async(client, options) | FastAPI / async handler. On Python 3.10 it falls back to a worker thread because LangGraph's interrupt() uses sync-only contextvars; on 3.11+ it runs natively. |
resume_from_poll_response(response, *, decision_id, identity_id) | Resume from a single terminal get_decision_outcome response when you poll elsewhere. |
resume_from_webhook(payload) / resume_from_event(event) | Legacy — resume from a webhook-shaped JSON body if you bridge relay events yourself. |
Helpers and types
| Symbol | Description |
|---|---|
create_noxy_hitl_node(client, identity_id, registry, build_actionable, …) | Lower-level node factory if you prefer not to use the bridge. |
build_tool_call_actionable(tool, args, title, summary, *, kind="propose_tool_call", extra=None) | Build a standard actionable payload. |
parse_webhook_payload(payload) | Optional: validate and parse a webhook-shaped JSON body into a NoxyWebhookEvent if you bridge events yourself. |
PendingInterrupt / PendingInterruptRegistry | Thread-safe in-memory map of decision_id → thread_id. Replace with your own implementation backed by Redis / Postgres for multi-process deployments. |
NoxyDecisionOutcome / NoxyDecisionResume / NoxyWebhookEvent | Typed wrappers around the decision outcome. |
NOXY_SENT_DECISION_ID_KEY | String constant for the private state key ("_noxy_sent_decision_id"). |
SendDecisionFailedError / UnknownDecisionError | Raised when no delivery returned a decision_id or when a resume references an unknown decision. |
FastAPI poll-resume server
The package ships with an end-to-end FastAPI example (examples/poll_resume_server.py): start a run with POST /runs, then resume it with POST /runs/wait. Here is the minimum:
import os
import uuid
from typing import Optional, TypedDict
from fastapi import FastAPI, HTTPException
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.constants import END, START
from langgraph.graph import StateGraph
from noxy import NoxyConfig, init_noxy_agent_client
from noxy.decision_outcome import WaitForDecisionOutcomeOptions
from langgraph_noxy import NoxyLangGraphBridge, build_tool_call_actionable
IDENTITY = os.environ["NOXY_IDENTITY_ID"]
class State(TypedDict, total=False):
task: str
noxy_decision: Optional[dict]
status: Optional[str]
_noxy_sent_decision_id: Optional[str]
def build_actionable(state: State) -> dict:
return build_tool_call_actionable(
tool="execute_task",
args={"task": state["task"]},
title="Approve agent task?",
summary=f"The agent wants to run: {state['task']}",
)
def on_timeout(_state, resume) -> dict:
return {"noxy_decision": resume.to_state(), "status": "timed_out_default"}
def after_decision(state: State) -> dict:
decision = state.get("noxy_decision") or {}
if decision.get("approved"):
return {"status": "executed"}
if state.get("status") == "timed_out_default":
return {"status": "skipped_after_timeout"}
return {"status": "rejected"}
client = init_noxy_agent_client(
NoxyConfig(
endpoint=os.environ.get("NOXY_ENDPOINT", "https://relay.noxy.network"),
auth_token=os.environ["NOXY_APP_TOKEN"],
decision_ttl_seconds=3600,
)
)
bridge = NoxyLangGraphBridge(client, IDENTITY)
builder = StateGraph(State)
builder.add_node("noxy_hitl", bridge.create_hitl_node(build_actionable, on_timeout=on_timeout))
builder.add_node("after_decision", after_decision)
builder.add_edge(START, "noxy_hitl")
builder.add_edge("noxy_hitl", "after_decision")
builder.add_edge("after_decision", END)
graph = builder.compile(checkpointer=InMemorySaver())
resume_handler = bridge.create_resume_handler(graph)
app = FastAPI()
@app.post("/runs")
def start_run(body: dict) -> dict:
thread_id = body.get("thread_id") or str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
state = graph.invoke({"task": body.get("task", "demo task")}, config)
decision_id = None
if isinstance(state, dict) and state.get("__interrupt__"):
decision_id = state["__interrupt__"][0].value.get("decision_id")
return {"thread_id": thread_id, "decision_id": decision_id, "state": state}
@app.post("/runs/wait")
async def wait_for_outcome(body: dict) -> dict:
decision_id = body.get("decision_id")
if not decision_id:
raise HTTPException(status_code=400, detail="decision_id is required")
options = WaitForDecisionOutcomeOptions(decision_id=str(decision_id), identity_id=IDENTITY)
try:
final = await resume_handler.wait_and_resume_async(client, options)
except Exception as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"ok": True, "state": final}Run it (after pip install "langgraph-noxy[examples]"):
export NOXY_APP_TOKEN="…"
export NOXY_IDENTITY_ID="user@example.com"
uvicorn examples.poll_resume_server:app --reloadProduction checklist
- Persistent checkpointer. Replace
InMemorySaverwith the LangGraph Postgres or SQLite checkpointer so paused threads survive restarts. - Persistent registry. The default
PendingInterruptRegistryis in-memory. For multi-process or multi-replica deployments, swap in your own implementation backed by Redis or your database — the contract is the same three methods (register/lookup/pop). - Run the poll loop off the request path.
wait_and_resumecan block for the fullmax_wait_ms. Run it in a background worker or task queue for long approval windows rather than holding an HTTP request open. - TTL & poll budget. Set
decision_ttl_secondsonNoxyConfigandmax_wait_msinWaitForDecisionOutcomeOptionsto sensible bounds — short for synchronous user prompts, long for asynchronous approval workflows. Always handleexpired/timeoutwithon_timeoutor a downstream branch. - Quota. Each
send_decisionconsumes one decision from your monthly pool — see Pricing. The_noxy_sent_decision_idguard prevents re-routing on resume, so polling does not consume extra quota. - Observability. Log
decision_id+thread_idat routing time and on resume so you can correlate a single approval across the agent, relay, and device.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
UnknownDecisionError on resume | The decision id is not in the registry — either it was already resumed, the registry was lost (process restart with in-memory storage), or the resume is for a different deployment. Use a persistent registry for production. |
SendDecisionFailedError when entering the HITL node | The relay returned no successful delivery with a decision_id — usually because the identity has no registered devices. Confirm the user installed a Client SDK and that the identity exactly matches. |
| Graph doesn't pause | The graph was compiled without a checkpointer. interrupt() requires one — pass checkpointer=InMemorySaver() (or Postgres / SQLite) to builder.compile. |
| HITL node re-routes the decision after resume | Your state schema does not declare _noxy_sent_decision_id; LangGraph drops unknown keys from Command(update=…). Add the field to your TypedDict. |
wait_and_resume returns a timeout outcome too early | The poll budget max_wait_ms elapsed before the user responded. Increase it for asynchronous approvals, and make sure decision_ttl_seconds on the relay is at least as long. |
| Async resume hangs on Python 3.10 | LangGraph relies on sync-only contextvars for interrupt() on 3.10. The connector falls back to asyncio.to_thread automatically — make sure your event loop allows new threads. |
Where to next
LangChain Connector
Same flow, exposed as a HumanInTheLoopMiddleware for create_agent.
Python SDK
The underlying Agent SDK if you want to call send_decision directly.
Decisions & Lifecycle
Payload shape, TTL, and outcome semantics that the actionable maps to.
Best Practices
TTLs, idempotency, retries, and quota guidance for production HITL.