How to configure and manage approval gates
Prerequisites
- IRREVERSIBLE tools annotated in your Python code
- UndoLog proxy running on
http://localhost:8080 - API key configured via
UNDOLOG_PROXY_API_KEYS - Dashboard accessible at
http://localhost:3000
What you'll build
You will send an IRREVERSIBLE tool call, inspect the pending approval, approve it via REST, and observe the session resume. You will also review the approval audit trail.
Steps
1. Trigger a pending approval
Send an IRREVERSIBLE tool call through the proxy:
curl -X POST http://localhost:8080/mcp/tool_call \
-H "Content-Type: application/json" \
-H "X-Api-Key: my-api-key" \
-H "X-Org-Id: org-demo" \
-d '{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"tool_name": "delete_database",
"step_index": 1,
"args": {"db_name": "prod-db"}
}'
The proxy returns HTTP 202 with:
{
"status": "pending_approval",
"approval_id": "a1b2c3d4e5f6...",
"retry_after": 5
}
2. List pending approvals
curl -s "http://localhost:8080/approvals?state=pending" \
-H "X-Org-Id: org-demo" | jq .
Response:
[
{
"id": "a1b2c3d4e5f6...",
"org_id": "org-demo",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"effect_id": "eff-001",
"tool_name": "delete_database",
"args": "{\"db_name\":\"prod-db\"}",
"status": "pending",
"created_at": "2026-05-23T12:00:00Z"
}
]
3. Approve or reject via REST
Approve:
curl -X POST "http://localhost:8080/approvals/a1b2c3d4e5f6.../approve" \
-H "X-Org-Id: org-demo"
Reject:
curl -X POST "http://localhost:8080/approvals/a1b2c3d4e5f6.../reject" \
-H "X-Org-Id: org-demo"
On approve, the proxy executes the tool inline and commits the result. A successful decision returns:
{
"status": "approved",
"approval_id": "a1b2c3d4e5f6...",
"effect_id": "eff-001",
"execution": "committed",
"result": {"status": "deleted"}
}
If the tool execution fails, the approval is still recorded (status: "approved") but execution is "failed" with an error field:
{
"status": "approved",
"approval_id": "a1b2c3d4e5f6...",
"effect_id": "eff-001",
"execution": "failed",
"error": "..."
}
If the approval is already resolved, you get HTTP 409:
{"error": "approval already resolved"}
4. Approve from the dashboard
Navigate to http://localhost:3000. The dashboard shows pending approvals in
real time via SSE. Each row shows the tool name, arguments, and timestamps.
Click Approve or Reject to resolve.
5. Retry the tool call after approval
After approval, the agent retries the same step. The engine replays the cached result:
curl -X POST http://localhost:8080/mcp/tool_call \
-H "Content-Type: application/json" \
-H "X-Api-Key: my-api-key" \
-H "X-Org-Id: org-demo" \
-d '{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"tool_name": "delete_database",
"step_index": 1,
"args": {"db_name": "prod-db"}
}'
Response:
{"status": "replayed", "effect_id": "eff-001", "result": {"status": "deleted"}}
6. Review the audit trail
Approval events are recorded in the undolog_approval_events table. Query it
directly:
SELECT ae.id, ae.approval_id, ae.event_type, ae.actor, ae.created_at
FROM undolog_approval_events ae
WHERE ae.org_id = 'org-demo'
ORDER BY ae.created_at DESC;
Verify it works
Run the full lifecycle in one script:
SESSION_ID="test-session-$(uuidgen)"
APPROVAL_RESPONSE=$(curl -s -X POST http://localhost:8080/mcp/tool_call \
-H "Content-Type: application/json" \
-H "X-Api-Key: my-api-key" \
-H "X-Org-Id: org-demo" \
-d "{
\"session_id\": \"$SESSION_ID\",
\"tool_name\": \"delete_database\",
\"step_index\": 1,
\"args\": {\"db_name\": \"test-db\"}
}")
APPROVAL_ID=$(echo "$APPROVAL_RESPONSE" | jq -r '.approval_id')
echo "Approval ID: $APPROVAL_ID"
# Approve it (auto-executes the tool and commits the result)
curl -s -X POST "http://localhost:8080/approvals/$APPROVAL_ID/approve" \
-H "Content-Type: application/json" \
-H "X-Org-Id: org-demo" \
-d '{"actor": "admin@example.com"}' | jq .
# Retry (tool was already committed during approval, returns cached result)
curl -s -X POST http://localhost:8080/mcp/tool_call \
-H "Content-Type: application/json" \
-H "X-Api-Key: my-api-key" \
-H "X-Org-Id: org-demo" \
-d "{
\"session_id\": \"$SESSION_ID\",
\"tool_name\": \"delete_database\",
\"step_index\": 1,
\"args\": {\"db_name\": \"test-db\"}
}" | jq .
Expected final response:
{"status": "replayed", "effect_id": "eff-...", "result": {"status": "deleted"}}
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized on approval endpoints |
Missing or invalid X-Org-Id |
Send X-Org-Id matching the org that owns the approval |
404 Not Found on approve/reject |
Wrong approval ID | List pending approvals and copy the exact ID |
409 Conflict on decision |
Approval already resolved | Check state=approved or state=rejected to see the current status |
Retry returns pending_approval again |
Not yet approved | Wait for the approval webhook; check GET /approvals?state=pending |
| SSE dashboard not updating | Browser tab throttled | Open DevTools Network tab, verify SSE events arrive every 5s |
| Approval decision not persisted after proxy restart | In-memory store | Production deployments use the database-backed store via the engine |