I had a support triage agent already running in production. It was called Resolvd, wired up in n8n with Claude doing the classification, and it worked. So why rebuild the same thing in code? Because there was one behavior the visual workflow made awkward, and I wanted to see it done properly: pausing a running agent in the middle of a decision, holding its entire state on disk, and resuming it only after a human says yes or no.
That behavior is the whole reason RelayG exists.
The problem
A triage agent for refunds is mostly boring, and that is good. Someone was double charged $25, you refund it automatically. Someone wants $450 back, that is above your ceiling and a senior agent should look at it. The interesting cases sit in the middle. A $120 refund is too much to hand out on autopilot but too routine to escalate every time. You want a person to glance at it and approve or deny, and you want the agent to wait for that answer without losing its place.
In a workflow tool you approximate this with webhooks, wait nodes, and state you stuff into a database by hand. It works, but the pause is bolted on. I wanted the pause to be a first-class part of how the agent runs.
The core idea: the agent is an explicit state machine
The thing I care about most here is that RelayG is not a vague "LLM in a loop." It is a state machine with three named nodes and edges between them, declared up front:
classify -> policy_check -> act -> END
classify reads the ticket and produces structured labels. policy_check applies pure Python refund rules. act takes the action. That is the entire graph, wired in LangGraph with add_node and add_edge. Because the shape is explicit, I can read the control flow without running anything, and I can unit test each node in isolation.
The state that flows through those nodes is typed. It is a TypedDict carrying the ticket fields, the classification, the policy decision and its reason, and an actions list that accumulates across nodes via an operator.add reducer. The classification itself is a Pydantic model with Literal types for intent and urgency, so "refund_request" is a value the type system knows about rather than a string I hope is spelled right.
One deliberate choice: the LLM only touches classify. It extracts intent, urgency, and a refund amount. Every actual decision about money happens in policy_check, which has no LLM and no I/O:
if amount < AUTO_APPROVE_LIMIT: # under $50
return "auto_approve", ...
if amount <= HUMAN_APPROVAL_LIMIT: # $50 to $200
return "needs_approval", ...
return "escalate", ... # over $200
The model reads text. Deterministic rules decide what happens. That split is the point.
How the pause works
The interesting node is act. When the policy decision is needs_approval, instead of returning a value, the node calls interrupt():
verdict = interrupt(
{
"ticket_id": ticket_id,
"question": f"Approve refund of ${amount:.2f}?",
"reason": state["policy_reason"],
}
)
approved = bool(verdict.get("approved"))
interrupt() suspends execution right there, in the middle of the node. LangGraph writes the current state to a checkpointer and hands the interrupt payload back to whoever invoked the graph. The run is genuinely stopped, not blocked on a thread. If the process died at this moment, the paused ticket would still be sitting in storage.
The checkpointer in RelayG is a SqliteSaver over a plain SQLite file, keyed by a thread_id (I use the ticket id). When a human is ready to answer, you resume by invoking the graph again with a Command:
result = graph.invoke(Command(resume=verdict), config)
LangGraph loads the checkpoint for that thread, injects verdict as the return value of the interrupt() call that was frozen earlier, and the act node continues from exactly that line. If approved, it issues the refund and records who approved it; if denied, it sends a decline reply carrying the reviewer's note. Every action, refund, reply, or escalation, is appended to an audit.jsonl log, so after the fact you can see what the agent did and who signed off.
The demo runs five sample tickets through this. Two of them ($120 and $75) hit the approval gate, pause, and get resumed with simulated reviewer verdicts, one approved and one denied. It runs offline by default: with no GROQ_API_KEY set, classify falls back to a deterministic keyword mock, so the interrupt and resume path is fully testable without any API key, and it is covered by pytest.
One honest limitation
The tools are mocks. issue_refund, send_reply, and escalate do not call Stripe or an email provider; they write structured entries to the audit log and return. RelayG is a faithful demonstration of the control flow, the typed state, the checkpointing, and the human-in-the-loop mechanism, but wiring the actions to real systems is left as an exercise. The refund thresholds ($50 and $200) are also hardcoded constants rather than per-customer or per-plan policy, which a real deployment would want.
I am comfortable with that scope, because the part that is genuinely hard to get right in a workflow tool, the durable pause and resume, is the part that is real here.
The tradeoff
Rebuilding Resolvd in LangGraph meant giving up the visual editor and the drag-and-drop integrations that made the n8n version fast to assemble. In exchange I got typed state checked at development time, a graph I can read and unit test as plain Python, and interrupts as the core execution model instead of webhook-and-wait plumbing. For an agent that touches money and needs a human in the loop, that felt like the right trade.
Code, demo, and tests are here: https://github.com/AgentPostmortem/relayg
Top comments (0)