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 you resume it by polling the relay for the settled outcome.
What it does
langchain-noxy 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 by polling the relay's
GetDecisionOutcomevia the SDK, then applying{"type": "approve"}or{"type": "reject", "message": …}to each tool call.
Installing pulls in the SDK. pip install langchain-noxy brings in noxy-sdk automatically — no separate checkout of the Noxy SDK is needed. 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 │
└───────────┘ └────┬─────┘
▲ │ get_decision_outcome
│ Command(resume=HITLResponse) │ (poll w/ backoff)
└────────────── wait_and_resume() ─────┘
- 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 aGraphInterruptcarrying thedecision_id.- User approves or rejects on a device, or the decision TTL expires.
- You call
bridge.wait_and_resume(agent, decision_id); the SDK pollsGetDecisionOutcomewith exponential backoff until the decision settles. - The middleware applies
{"type": "approve"}or{"type": "reject"}to each suspended tool call and the agent continues.
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 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 langchain-noxy
# With the FastAPI example server included
pip install "langchain-noxy[examples]"Pulls in noxy-sdk >= 2.1.0, langchain >= 0.3, and the matching langgraph runtime as dependencies.
Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
NOXY_APP_TOKEN | Yes | — | App token from the Noxy dashboard. |
NOXY_IDENTITY_ID | Yes* | — | Target identity: phone, email, user id, or wallet. |
NOXY_ENDPOINT | No | https://relay.noxy.network | Relay gRPC endpoint. |
MODEL | No | openai:gpt-4o-mini | Chat model for the example server. |
*Identity is passed to NoxyLangChainBridge(client, identity_id) in code; use the env var when your app reads it from the environment.
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 langchain_noxy 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(),
)
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
# agent.invoke(...) suspends with a GraphInterrupt when transfer_funds is proposed;
# read decision_id from the interrupt value.
# Poll relay until approved / rejected / expired (SDK exponential backoff):
final = bridge.wait_and_resume(agent, "<decision_id>")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(agent)
response = client.wait_for_decision_outcome(
WaitForDecisionOutcomeOptions(decision_id="<decision_id>", identity_id="user@example.com")
)
final = resume_handler.resume_from_poll_response(
response, decision_id="<decision_id>", identity_id="user@example.com"
)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 langchain_noxy import NoxyAgentState, NOXY_SENT_DECISION_ID_KEY
# NOXY_SENT_DECISION_ID_KEY == "_noxy_sent_decision_id"Outcome mapping
The connector maps Noxy relay outcomes to LangChain HITL decisions (via hitl_response_from_outcome):
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.
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(
agent,
"<decision_id>",
wait_options=WaitForDecisionOutcomeOptions(
decision_id="<decision_id>",
identity_id="user@example.com",
max_wait_ms=300_000,
),
)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 | Lower-level handler for manual polling / resume. |
wait_and_resume(agent, decision_id, *, wait_options=None) | Final agent result | Poll the relay via the SDK, then resume the paused agent in one call. |
NoxyAgentResumeHandler
| 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 (LangGraph's contextvars are sync-only there); 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 |
|---|---|
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) | Optional: validate a webhook-shaped JSON body and return a typed NoxyWebhookEvent if you bridge events yourself. |
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 decision outcome. |
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 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 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.decision_outcome import WaitForDecisionOutcomeOptions
from langchain_noxy import NoxyLangChainBridge
IDENTITY = os.environ["NOXY_IDENTITY_ID"]
@tool
def execute_task(task: str) -> str:
"""Execute an approved agent task."""
return f"Executed: {task}"
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 = NoxyLangChainBridge(client, IDENTITY)
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("/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, "result": final}Run it (after pip install "langchain-noxy[examples]"):
export NOXY_APP_TOKEN="…"
export NOXY_IDENTITY_ID="user@example.com"
uvicorn examples.poll_resume_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. - 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. Tune
decision_ttl_secondsonNoxyConfigandmax_wait_msinWaitForDecisionOutcomeOptions: 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 resumed interrupts 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 resume | Decision id is not in the registry — already resumed, registry lost on restart, or the resume is for a different deployment. Use a persistent registry. |
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. |
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'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.