Connectors · LangChain
LangChain Connector
Add Noxy human-in-the-loop to any LangChain create_agent by registering a single middleware. Tool calls you mark for review are routed as encrypted approvals to the user's devices, the agent suspends, and a webhook resumes it with the human's decision.
What it does
noxy-langchain wraps LangChain's HumanInTheLoopMiddleware and bridges it to the Noxy relay. Drop the middleware on your agent, declare which tools require approval, and the connector handles the rest:
- Intercept guarded tool calls before the agent executes them.
- Send an encrypted actionable to all devices registered for the identity.
- Suspend via LangGraph's
interrupt()— the agent state is checkpointed. - Resume from the Noxy webhook with
{"type": "approve"}or{"type": "reject", "message": …}for each tool call.
Built on the LangGraph runtime. LangChain agents created with create_agent compile to a LangGraph state graph under the hood, so suspension and resume use the same primitives as the LangGraph connector. You only need a checkpointer.
Flow
┌────────────────┐ tool calls ┌───────────────────────┐
│ create_agent() │ ──────────────▶ │ NoxyHumanInTheLoop │
│ + middleware │ │ Middleware │
└────────┬───────┘ └────────────┬──────────┘
│ interrupt() │ send_decision
▼ ▼
┌───────────┐ ┌──────────┐
│Checkpoint │ │ Noxy │ ────▶ Devices
│ state │ │ Relay │
└───────────┘ └──────────┘
▲ │ webhook
│ Command(resume=HITLResponse) ▼
└────────────── resume_from_webhook() ──┘
- The agent proposes one or more tool calls. The middleware inspects each name against your
interrupt_onmap. - For every guarded call it builds an
action_request; one combined Noxy actionable carries all of them. send_decisionroutes the encrypted actionable; the relay fans it out to all registered devices.interrupt()suspends the agent — control returns to your server with aGraphInterrupt.- User approves or rejects on a device, or the decision TTL expires.
- Noxy posts a webhook to your server. The resume handler maps the outcome to a LangChain
HITLResponseand resumes the agent. - The middleware applies
{"type": "approve"}or{"type": "reject"}to each suspended tool call and the agent continues.
Requirements
- Python 3.10 or newer.
- A LangChain agent built with
langchain.agents.create_agentand a checkpointer (required for HITL). - A Noxy app — see Create App — for the
NOXY_APP_TOKEN. - An identity for the user: phone (E.164), email, your own user id, or wallet address (
0x…). See Identity types.
Installation
# Production
pip install noxy-langchain
# With the FastAPI webhook example included
pip install "noxy-langchain[examples]"Pulls in noxy-sdk >= 2.1.0, langchain >= 0.3, and the matching langgraph runtime as dependencies.
Quick start
import uuid
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from noxy import NoxyConfig, init_noxy_agent_client
from noxy_langchain import NoxyLangChainBridge
@tool
def transfer_funds(to: str, amount: str) -> str:
"""Transfer funds — guarded."""
return f"Sent {amount} to {to}"
client = init_noxy_agent_client(
NoxyConfig(
endpoint="https://relay.noxy.network",
auth_token="your-app-token",
decision_ttl_seconds=3600,
)
)
bridge = NoxyLangChainBridge(client, "user@example.com") # email, phone, user_id, or 0x…
agent = create_agent(
init_chat_model("openai:gpt-4o-mini"),
tools=[transfer_funds],
middleware=[bridge.create_hitl_middleware({"transfer_funds": True})],
checkpointer=InMemorySaver(),
)
resume_handler = bridge.create_resume_handler(agent)
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
# agent.invoke(...) suspends with a GraphInterrupt when transfer_funds is proposed.
# Later, in your webhook handler:
final = resume_handler.resume_from_webhook({
"decisionId": "<decisionId>",
"identityId": "user@example.com",
"outcome": "approved", # or "rejected" / "expired" / "timeout"
})Configuring the middleware
create_hitl_middleware is a thin wrapper over LangChain's HumanInTheLoopMiddleware. The interrupt_on map decides which tools require approval:
middleware = bridge.create_hitl_middleware(
{
# Always require approval
"transfer_funds": True,
# Allow Approve, Reject, or Edit args via LangChain's review config
"deploy_contract": {
"allowed_decisions": ["approve", "reject", "edit"],
"description": "Approve contract deployment",
},
# Auto-approve in dev, gate in prod
"rebalance": True if PROD else False,
},
description_prefix="Tool execution requires approval",
)You can mix multiple guarded tools in a single agent step: the middleware bundles them into one decision, and the resume handler returns one {"type": …} entry per call.
Agent state
The middleware extends the default AgentState with one private field — _noxy_sent_decision_id — so re-running the model step after resume does not re-route the decision. You do not need to declare anything yourself; NoxyAgentState is exposed for typing and tests:
from noxy_langchain import NoxyAgentState, NOXY_SENT_DECISION_ID_KEY
# NOXY_SENT_DECISION_ID_KEY == "_noxy_sent_decision_id"Webhook payload
Noxy posts JSON to your registered webhook:
| Field | Type | Description |
|---|---|---|
decisionId | str | Decision to resume — snake_case decision_id is also accepted. |
identityId | str | Identity that took the decision (phone, email, user id, or wallet). |
outcome | str | One of approved, rejected, expired, timeout. |
receivedAt | str | Optional ISO timestamp of the device submission. |
The connector maps Noxy outcomes to LangChain HITL decisions:
approved→{"type": "approve"}for every guarded tool callrejected→{"type": "reject", "message": "Human rejected the requested agent action."}expired/timeout→{"type": "reject", "message": "Decision expired before a human response was received."}
Pass reject_message to hitl_response_from_outcome to override the default copy when you build responses manually.
API reference
NoxyLangChainBridge(client, identity_id, *, registry=None)
| Method | Returns | Purpose |
|---|---|---|
create_hitl_middleware(interrupt_on, *, description_prefix="Tool execution requires approval") | NoxyHumanInTheLoopMiddleware | Build the middleware to register on create_agent(..., middleware=[…]). |
create_resume_handler(agent) | NoxyAgentResumeHandler | Resume the agent from webhook callbacks. |
NoxyAgentResumeHandler
| Method | When to use |
|---|---|
resume_from_webhook(payload) | Sync handler. |
resume_from_webhook_async(payload) | FastAPI / async handler. On Python 3.10 it falls back to a worker thread (LangGraph's contextvars are sync-only there); on 3.11+ it runs natively. |
resume_from_event(event) / resume_from_event_async(event) | Lower-level — when you have already parsed a NoxyWebhookEvent. |
Helpers and types
| Symbol | Description |
|---|---|
NoxyHumanInTheLoopMiddleware | The middleware itself; the bridge instantiates it for you. |
NoxyAgentState | Default agent state extended with _noxy_sent_decision_id. |
build_hitl_actionable(action_requests, *, title=None) | Builds the actionable from LangChain HITL action_requests; usually you don't call this directly. |
build_tool_call_actionable(tool, args, title, summary, *, kind="propose_tool_call", extra=None) | Standalone helper if you build actionables outside the middleware. |
hitl_response_from_outcome(outcome, *, decision_count=1, reject_message=None) | Map a Noxy outcome to a HITLResponse with the right number of decisions. |
parse_webhook_payload(payload) | Validate a raw webhook JSON body and return a typed NoxyWebhookEvent. |
PendingDecision / PendingDecisionRegistry | Thread-safe in-memory map of decision_id → (thread_id, decision_count). Replace for multi-process deployments. |
NoxyDecisionOutcome / NoxyDecisionResume / NoxyWebhookEvent | Typed wrappers around the webhook payload. |
NOXY_SENT_DECISION_ID_KEY | String constant for the private state key. |
SendDecisionFailedError / UnknownDecisionError | Raised when no delivery returned a decision_id or when a webhook references an unknown decision. |
FastAPI webhook server
import os
import uuid
from fastapi import FastAPI, HTTPException
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.errors import GraphInterrupt
from noxy import NoxyConfig, init_noxy_agent_client
from noxy_langchain import NoxyLangChainBridge
@tool
def execute_task(task: str) -> str:
"""Execute an approved agent task."""
return f"Executed: {task}"
client = init_noxy_agent_client(
NoxyConfig(
endpoint="https://relay.noxy.network",
auth_token=os.environ["NOXY_APP_TOKEN"],
decision_ttl_seconds=3600,
)
)
bridge = NoxyLangChainBridge(client, os.environ["NOXY_IDENTITY_ID"])
agent = create_agent(
init_chat_model(os.environ.get("MODEL", "openai:gpt-4o-mini")),
tools=[execute_task],
middleware=[bridge.create_hitl_middleware({"execute_task": True})],
checkpointer=InMemorySaver(),
)
resume_handler = bridge.create_resume_handler(agent)
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}}
try:
result = agent.invoke(
{"messages": [{"role": "user", "content": body.get("task", "demo task")}]},
config,
version="v2",
)
return {"thread_id": thread_id, "result": result, "status": "completed"}
except GraphInterrupt as exc:
interrupt_value = exc.args[0][0].value
return {
"thread_id": thread_id,
"status": "awaiting_decision",
"decision_id": interrupt_value.get("decision_id"),
}
@app.post("/webhooks/noxy")
async def noxy_webhook(payload: dict) -> dict:
try:
final = await resume_handler.resume_from_webhook_async(payload)
except Exception as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"ok": True, "result": final}Run it (after pip install "noxy-langchain[examples]"):
export NOXY_APP_TOKEN="…"
export NOXY_IDENTITY_ID="user@example.com"
uvicorn examples.webhook_server:app --reloadMultiple tool calls in one step
A single model turn can produce more than one tool call. The middleware bundles every guarded call into one Noxy decision and stores decision_count in the registry. The resume handler returns one {"type": …} per call so all suspended calls resolve together. The user only sees one approval prompt.
Production checklist
- Persistent checkpointer. Replace
InMemorySaverwith the LangGraph Postgres or SQLite checkpointer so paused agents survive restarts. - Persistent registry. The default
PendingDecisionRegistryis in-memory. Implement the same three methods (register/lookup/pop) over Redis or your database for multi-process deployments. - Idempotent webhook handler. Noxy may retry deliveries.
resume_from_webhookpops the registry entry, so a retry returnsUnknownDecisionError— map it to HTTP 200/410 in your handler. - TTL. Tune
decision_ttl_secondsonNoxyConfig: short for synchronous prompts, long for async approvals. Always handleexpired/timeoutoutcomes (the connector turns them into a reject by default). - Quota. Each
send_decisionconsumes one decision from your monthly pool — see Pricing. The middleware skips routing when_noxy_sent_decision_idis set, so retried interrupts after resume do not consume more quota. - Observability. Log
decision_idand the LangGraphthread_idat routing time and on resume so you can correlate one approval across the agent, relay, and device.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
ValueError: NoxyHumanInTheLoopMiddleware requires a checkpointer and thread_id in config | Compile the agent with checkpointer=… and pass {"configurable": {"thread_id": …}} when invoking. |
UnknownDecisionError on webhook | Decision id is not in the registry — already resumed, registry lost on restart, or the webhook is for a different deployment. Use a persistent registry and respond 200 to retried unknowns. |
SendDecisionFailedError when the agent first hits a guarded tool | The relay returned no successful delivery with a decision_id — usually no devices are registered for the identity. Confirm the user installed a Client SDK and that the identity matches. |
| Number of human decisions does not match number of hanging tool calls | The registry's stored decision_count drifted from the tool calls being interrupted. Don't write to _noxy_sent_decision_id manually; let the middleware and resume handler manage it. |
| Async resume hangs on Python 3.10 | LangGraph's interrupt() uses sync-only contextvars on 3.10 — the connector falls back to a worker thread automatically. Make sure your event loop allows new threads. |
Where to next
LangGraph Connector
Same flow exposed as a graph node when you build with LangGraph directly.
Python SDK
The underlying Agent SDK if you want to call send_decision outside LangChain.
Decisions & Lifecycle
Payload shape, TTL, and outcome semantics that the actionable maps to.
Best Practices
TTLs, idempotency, retries, and quota guidance for production HITL.