agent_signal
← All templates
LangGraph · 1.2.15 · Python ✓ Tested

Tested on LangGraph (langgraph 1.2.15, pinned in requirements.txt)

Minimal web UI to approve paused runs

A stdlib-only web page (plain HTML forms, no JavaScript) that lists paused LangGraph runs and approves or rejects them with Command(resume=...), with 409/400/404 handling.

What it shows

  • GET /: HTML list of runs with Approve/Reject buttons and a note field for paused runs.
  • POST /runs, POST /runs/<id>/decision: Start a run that pauses at interrupt(); decide it via graph.invoke(Command(resume=...)). 409 if already decided, 400 for an invalid decision, 404 for an unknown run.
  • GET /api/runs: JSON status, pending interrupt payloads (from graph.get_state(cfg).tasks[*].interrupts) and results.
Mocked vs real. No LLM; the action is the text the user submits. LangGraph is real: interrupt, InMemorySaver, get_state, Command resume. Verified with HTTP requests via curl (verify.sh), not in a real browser.

Code

app.pysrc
"""Minimal approval UI for paused LangGraph runs. Python stdlib http.server only (no web framework).
GET /            HTML list of runs, with Approve/Reject forms for paused ones
POST /runs       start a run (form field: action) -> pauses at interrupt()
POST /runs/<id>/decision  (form: decision=approve|reject, note=...) -> Command(resume=...)
GET /api/runs    JSON state of all runs"""
import html, json, sys, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Lock
from typing import TypedDict
from urllib.parse import parse_qs
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command

class S(TypedDict, total=False):
    action: str
    decision: dict
    result: str

def approval(s):
    return {"decision": interrupt({"question": "Approve this action?", "action": s["action"]})}
def execute(s):
    d = s["decision"]
    return {"result": f"EXECUTED: {s['action']}" if d["approved"] else f"REJECTED: {d.get('note') or 'no reason'}"}
b = StateGraph(S); b.add_node("approval", approval); b.add_node("execute", execute)
b.add_edge(START, "approval"); b.add_edge("approval", "execute"); b.add_edge("execute", END)
graph = b.compile(checkpointer=InMemorySaver())
RUNS: list[str] = []; LOCK = Lock()

def cfg(tid): return {"configurable": {"thread_id": tid}}
def run_info(tid):
    st = graph.get_state(cfg(tid))
    pending = [i.value for t in st.tasks for i in t.interrupts]
    return {"id": tid, "status": "paused" if pending else "done", "pending": pending,
            "action": st.values.get("action"), "result": st.values.get("result")}

PAGE = """<!doctype html><title>Approvals</title><h1>LangGraph approvals</h1>
<form method=post action=/runs><input name=action value="refund order #42 ($19.99)" size=40><button>Start run</button></form><hr>{rows}"""
def render():
    rows = []
    for tid in reversed(RUNS):
        r = run_info(tid); e = html.escape
        if r["status"] == "paused":
            rows.append(f"<div><b>{e(tid)}</b> PAUSED: {e(r['pending'][0]['question'])} <code>{e(r['action'])}</code>"
                        f"<form method=post action=/runs/{tid}/decision><input name=note placeholder=note>"
                        f"<button name=decision value=approve>Approve</button><button name=decision value=reject>Reject</button></form></div>")
        else:
            rows.append(f"<div><b>{e(tid)}</b> DONE: {e(r['result'] or '')}</div>")
    return PAGE.format(rows="\n".join(rows) or "<p>No runs yet.</p>")

class H(BaseHTTPRequestHandler):
    def _send(self, code, body, ctype="text/html"):
        b = body.encode(); self.send_response(code); self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
    def _redirect(self): self.send_response(303); self.send_header("Location", "/"); self.end_headers()
    def _form(self):
        n = int(self.headers.get("Content-Length", 0)); q = parse_qs(self.rfile.read(n).decode())
        return {k: v[0] for k, v in q.items()}
    def do_GET(self):
        if self.path == "/": return self._send(200, render())
        if self.path == "/api/runs": return self._send(200, json.dumps([run_info(t) for t in RUNS]), "application/json")
        self._send(404, "not found", "text/plain")
    def do_POST(self):
        f = self._form()
        if self.path == "/runs":
            tid = "run-" + uuid.uuid4().hex[:6]
            with LOCK: graph.invoke({"action": f.get("action", "noop")}, cfg(tid)); RUNS.append(tid)
            return self._redirect()
        parts = self.path.strip("/").split("/")
        if len(parts) == 3 and parts[0] == "runs" and parts[2] == "decision" and parts[1] in RUNS:
            tid = parts[1]
            with LOCK:
                if run_info(tid)["status"] != "paused":
                    return self._send(409, "run is not paused (already decided)", "text/plain")
                if f.get("decision") not in ("approve", "reject"):
                    return self._send(400, "decision must be approve or reject", "text/plain")
                graph.invoke(Command(resume={"approved": f["decision"] == "approve", "note": f.get("note", "")}), cfg(tid))
            return self._redirect()
        self._send(404, "not found", "text/plain")
    def log_message(self, fmt, *a): print("  [http]", fmt % a, flush=True)

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
    print(f"serving on http://127.0.0.1:{port}", flush=True)
    ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
verify.shsrc
#!/usr/bin/env bash
# Starts the server, drives it with curl, prints results, stops it.
set -u; cd "$(dirname "$0")"; P=8765; U=http://127.0.0.1:$P
.venv/bin/python app.py $P > server.log 2>&1 & PID=$!; trap "kill $PID" EXIT
for i in $(seq 30); do curl -s $U/ >/dev/null && break; sleep 0.2; done
echo "## start two runs"
curl -s -o /dev/null -w "POST /runs -> %{http_code}\n" -d "action=refund order #42" $U/runs
curl -s -o /dev/null -w "POST /runs -> %{http_code}\n" -d "action=delete user 7" $U/runs
echo "## state"; curl -s $U/api/runs | python3 -m json.tool
IDS=($(curl -s $U/api/runs | python3 -c 'import json,sys;print(" ".join(r["id"] for r in json.load(sys.stdin)))'))
curl -s -w "  invalid decision value -> %{http_code}\n" -d "decision=maybe" $U/runs/${IDS[0]}/decision
echo "## HTML has Approve buttons: $(curl -s $U/ | grep -c 'value=approve')"
curl -s -o /dev/null -w "approve ${IDS[0]} -> %{http_code}\n" -d "decision=approve" $U/runs/${IDS[0]}/decision
curl -s -o /dev/null -w "reject ${IDS[1]} -> %{http_code}\n" -d "decision=reject&note=not+allowed" $U/runs/${IDS[1]}/decision
curl -s -w "  double-decide ${IDS[0]} -> %{http_code}\n" -d "decision=reject" $U/runs/${IDS[0]}/decision
curl -s -w "  unknown run id -> %{http_code}\n" -d "decision=maybe" $U/runs/nope/decision
echo "## final state"; curl -s $U/api/runs | python3 -m json.tool
echo "## HTML after:"; curl -s $U/ | grep -E "DONE|PAUSED"
echo "## server log"; cat server.log
requirements.txtsrc
langgraph==1.2.15
langgraph-checkpoint==4.3.0
run_output.txtsrc
## start two runs
POST /runs -> 303
POST /runs -> 303
## state
[
    {
        "id": "run-b085b6",
        "status": "paused",
        "pending": [
            {
                "question": "Approve this action?",
                "action": "refund order #42"
            }
        ],
        "action": "refund order #42",
        "result": null
    },
    {
        "id": "run-2d5b84",
        "status": "paused",
        "pending": [
            {
                "question": "Approve this action?",
                "action": "delete user 7"
            }
        ],
        "action": "delete user 7",
        "result": null
    }
]
decision must be approve or reject  invalid decision value -> 400
## HTML has Approve buttons: 2
approve run-b085b6 -> 303
reject run-2d5b84 -> 303
run is not paused (already decided)  double-decide run-b085b6 -> 409
not found  unknown run id -> 404
## final state
[
    {
        "id": "run-b085b6",
        "status": "done",
        "pending": [],
        "action": "refund order #42",
        "result": "EXECUTED: refund order #42"
    },
    {
        "id": "run-2d5b84",
        "status": "done",
        "pending": [],
        "action": "delete user 7",
        "result": "REJECTED: not allowed"
    }
]
## HTML after:
<form method=post action=/runs><input name=action value="refund order #42 ($19.99)" size=40><button>Start run</button></form><hr><div><b>run-2d5b84</b> DONE: REJECTED: not allowed</div>
<div><b>run-b085b6</b> DONE: EXECUTED: refund order #42</div>
## server log
serving on http://127.0.0.1:8765
  [http] "GET / HTTP/1.1" 200 -
  [http] "POST /runs HTTP/1.1" 303 -
  [http] "POST /runs HTTP/1.1" 303 -
  [http] "GET /api/runs HTTP/1.1" 200 -
  [http] "GET /api/runs HTTP/1.1" 200 -
  [http] "POST /runs/run-b085b6/decision HTTP/1.1" 400 -
  [http] "GET / HTTP/1.1" 200 -
  [http] "POST /runs/run-b085b6/decision HTTP/1.1" 303 -
  [http] "POST /runs/run-2d5b84/decision HTTP/1.1" 303 -
  [http] "POST /runs/run-b085b6/decision HTTP/1.1" 409 -
  [http] "POST /runs/nope/decision HTTP/1.1" 404 -
  [http] "GET /api/runs HTTP/1.1" 200 -
  [http] "GET / HTTP/1.1" 200 -
LICENSEsrc
MIT License

Copyright (c) 2026 Agent Signal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Upgrade gotchas

  • Not production-safe as is: no authentication, no CSRF protection, binds to 127.0.0.1 only. Whoever can POST can approve.
  • Runs live in memory (InMemorySaver plus a Python list); a restart loses them. Use SqliteSaver or PostgresSaver.
  • A lock serialises graph calls so two clicks cannot resume the same run twice; with multiple workers enforce this in the database.
  • Resumes run synchronously inside the request; for long steps run the work in the background.
  • Run values are HTML-escaped with html.escape because the action text is user input.

Free starter pack

The approval-flow starter pack

Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.

FAQ

Does it need a web framework?

No. It uses the Python standard library web server and plain HTML forms.

Is it safe to expose publicly?

No. It has no auth or CSRF protection; add both before exposing it.

How was it verified?

verify.sh drives it with curl: two runs paused, one approved, one rejected, plus 409, 400 and 404 cases. Not tested in a real browser. langgraph 1.2.15, Python 3.13, Oct 15, 2026. MIT-licensed.