# LangGraph 1.2.15: 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.

Status: tested 2026-10-15 · Python 3.13 (tested) · License: MIT · Tier: Free
Download: https://tryagentsignal.com/downloads/langgraph-approval-ui-starter.zip

## 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.

## 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.

## 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.

## Code

### app.py

```python
"""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.sh

```
#!/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.txt

```
langgraph==1.2.15
langgraph-checkpoint==4.3.0

```

### run_output.txt

```
## 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 -

```
