Deliberate is a human-approval layer for LangGraph agents. When your agent needs to pause and ask for approval before taking high-stakes action, Deliberate routes the request to the right person, sends notifications, and captures an audit trail of who decided what and why.
This guide will get you up and running in 15 minutes.
- Docker & Docker Compose — for running Postgres, the server, worker, and UI
- Python 3.11+ and
uv— for running the example agent - Node.js 18+ and
pnpm— for building the UI (optional; Docker Compose handles this) - Slack Bot Token (optional) — for Slack notifications
Install Docker, Python (via pyenv or your system package manager), and Node.js before continuing.
git clone https://github.com/beomwookang/deliberate.git
cd deliberateCopy .env.example to .env:
cp .env.example .envEdit .env and fill in SECRET_KEY:
# Generate a random secret key
python -c "import secrets; print(secrets.token_urlsafe(32))"Paste the output into the SECRET_KEY field in .env. Leave other fields as defaults for now.
SECRET_KEY=your-generated-key-here
DATABASE_URL=postgresql+asyncpg://deliberate:deliberate@localhost:5432/deliberate
DEFAULT_APPROVER_EMAIL=approver@example.com
UI_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:4000
INTERNAL_API_URL=http://server:4000
DELIBERATE_SERVER_URL=http://localhost:4000
DELIBERATE_API_KEY=your-api-key-here
DELIBERATE_UI_URL=http://localhost:3000docker compose up -dThis starts:
- Postgres (database) on port 5432
- Server (FastAPI) on port 4000
- Worker (background tasks) — no exposed port
- UI (Next.js) on port 3000
Verify services are running:
docker compose psWait 10-15 seconds for the database migrations to complete, then verify the server health:
curl http://localhost:4000/healthYou should see:
{"status": "healthy"}You have two options for configuring approvers and policies:
Option A: Edit YAML files (default)
The config files are in ./config/:
- Approvers:
./config/approvers.yaml— who can approve what - Policies:
./examples/policies/refund.yaml— rules for routing approvals
These are already pre-populated. You can view them:
cat config/approvers.yaml
cat examples/policies/refund.yamlFor now, leave them as-is. See Configuration Reference below if you want to customize.
Option B: Configure via API
If you prefer to manage configuration programmatically (useful for CI/CD, MCP-assisted setup, or multi-environment deployments), use the Admin REST API instead of editing YAML files.
First, set your bootstrap admin key in .env:
ADMIN_BOOTSTRAP_KEY=dlb_ak_your-bootstrap-key-hereThen create approvers and policies via cURL:
# Create an approver
curl -X POST http://localhost:4000/approvers \
-H "X-Deliberate-API-Key: ${ADMIN_BOOTSTRAP_KEY}" \
-H "Content-Type: application/json" \
-d '{
"id": "finance_lead",
"email": "priya@example.com",
"display_name": "Priya Sharma"
}'
# Create a group
curl -X POST http://localhost:4000/groups \
-H "X-Deliberate-API-Key: ${ADMIN_BOOTSTRAP_KEY}" \
-H "Content-Type: application/json" \
-d '{
"id": "finance_team",
"display_name": "Finance Team",
"members": ["finance_lead"]
}'
# Create a policy
curl -X POST http://localhost:4000/policies \
-H "X-Deliberate-API-Key: ${ADMIN_BOOTSTRAP_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "refund_approval",
"matches": { "layout": "financial_decision" },
"rules": [
{
"name": "auto_approve_small",
"when": "amount.value < 100",
"action": "auto_approve"
},
{
"name": "standard",
"when": "amount.value >= 100",
"approvers": { "any_of": ["finance_team"] },
"timeout": "4h",
"on_timeout": "escalate",
"escalate_to": "finance_lead",
"notify": ["email"]
}
]
}'Test the policy against a sample payload before deploying:
curl -X POST http://localhost:4000/policies/refund_approval/test \
-H "X-Deliberate-API-Key: ${ADMIN_BOOTSTRAP_KEY}" \
-H "Content-Type: application/json" \
-d '{
"subject": "Refund for order #123",
"layout": "financial_decision",
"amount": { "value": 250.00, "currency": "USD" }
}'See the Admin API Reference for the full endpoint documentation.
The example is a refund-processing agent that uses @approval_gate to pause and require human approval before issuing a refund. This is a classic HITL scenario — the agent has the context and recommendation, but a human must sign off before money moves.
Open a new terminal and run:
cd examples/refund_agent
pip install -e . -e ../../sdk
python agent.pyYou'll see output like:
INFO:refund_agent:Classifying refund request for customer C-1042
INFO:refund_agent:Refund $284.50 USD — waiting for human approval
INFO:deliberate.client:[APPROVAL_URL] http://localhost:3000/a/eyJhbGciOiJI...
Copy the approval URL from the logs and open it in your browser. You'll see the financial_decision layout with:
- Amount card — $284.50 USD, prominently displayed
- Customer info — name, tenure, ID
- Agent reasoning — structured summary with confidence level and supporting points
- Evidence table — support tickets, usage logs, and prior refund history
- Decision buttons — Approve, Approve with change, Request more info, Reject
- Rationale chips — Product Issue, Retention, Policy Exception, Other
This is the information architecture a finance lead needs to make a decision in under 30 seconds.
Click Approve (or any other decision). The agent in your terminal will immediately resume:
INFO:refund_agent:Approval received: approve
INFO:refund_agent:Processing refund of $284.50 USD for customer C-1042
INFO:refund_agent:Refund complete. Ledger entry recorded.
The decision is now in the append-only audit ledger — who approved, when, why, with what evidence, and how long they took to decide.
Congratulations! You've completed a full approval flow.
SECRET_KEY— Server signing secret. Used to create HMAC signatures for approval decisions. Generate withpython -c "import secrets; print(secrets.token_urlsafe(32))". No default.
DATABASE_URL— PostgreSQL async connection string.- Default:
postgresql+asyncpg://deliberate:deliberate@localhost:5432/deliberate
- Default:
SERVER_HOST— Server bind address. Default:0.0.0.0SERVER_PORT— Server port. Default:4000
UI_URL— Public URL of the approval UI (used in notification links).- Default:
http://localhost:3000
- Default:
NEXT_PUBLIC_API_URL— Public API URL visible to the browser.- Default:
http://localhost:4000
- Default:
INTERNAL_API_URL— Internal API URL (used by the UI server). Default:http://server:4000
DELIBERATE_SERVER_URL— Server URL for the agent SDK.- Default:
http://localhost:4000
- Default:
DELIBERATE_API_KEY— API key for the agent. No default; agents will fail if not set.DELIBERATE_UI_URL— URL where approval pages are accessible.- Default:
http://localhost:3000
- Default:
POLICIES_DIR— Directory containing policy YAML files.- Default:
/etc/deliberate/policies - Docker Compose:
./examples/policies
- Default:
APPROVERS_FILE— Path to approvers YAML.- Default:
/etc/deliberate/approvers.yaml - Docker Compose:
./config/approvers.yaml
- Default:
WEBHOOKS_FILE— Path to webhooks YAML (for webhook notifications).- Default:
/etc/deliberate/webhooks.yaml - Docker Compose:
./config/webhooks.yaml
- Default:
SLACK_BOT_TOKEN— Slack Bot token for notifications. Optional. If not set, Slack notifications are skipped.SLACK_SIGNING_SECRET— Slack signing secret. Optional.
SMTP_HOST— SMTP server address. Optional. If not set, email notifications are skipped.SMTP_PORT— SMTP port. Default:587SMTP_USERNAME— SMTP username. Optional.SMTP_PASSWORD— SMTP password. Optional.SMTP_FROM_EMAIL— From address for emails. Default:noreply@example.comSMTP_FROM_NAME— From name for emails. Default:DeliberateSMTP_USE_TLS— Use TLS for SMTP. Default:true
DEFAULT_APPROVER_EMAIL— Single approver email for M1 (no policy engine). Deprecated; use policies instead. No default; if no policy matches and this is not set, the server returns 400.
Policies define which approvers handle which requests. Stored in POLICIES_DIR (or ./examples/policies in Docker Compose).
Example: refund.yaml
name: refund_approval
matches:
layout: financial_decision
subject_contains: "Refund"
rules:
# Auto-approve small refunds
- name: auto_approve_small
when: "amount.value < 100"
action: auto_approve
rationale: "Below $100 threshold"
# Standard amount: any finance team member
- name: standard
when: "amount.value >= 100 and amount.value < 5000"
approvers:
any_of: [finance_team]
timeout: 4h
on_timeout: escalate
escalate_to: finance_lead
notify: [email, slack]
# High value: two-person approval
- name: high_value
when: "amount.value >= 5000"
approvers:
all_of: [finance_lead, cfo]
timeout: 8h
on_timeout: fail
require_rationale: true
notify: [email, slack, webhook]name— Policy identifiermatches— Conditions to apply this policy:layout— Layout type (e.g.,financial_decision)subject_contains— String to match in thesubjectfield
rules— Array of approval rules (evaluated top-to-bottom, first match wins):name— Rule identifierwhen— Expression to trigger this rule (see Expression Language below)action—auto_approveor (omitted: request human approval)approvers— Who to send the approval to:any_of— List of approver IDs; first to approve winsall_of— List of approver IDs; all must approve
timeout— Time before escalation (e.g.,4h,2d). Optional.on_timeout—escalateorfail. Optional.escalate_to— Approver ID to escalate to if timeout. Optional.notify— Notification channels:email,slack,webhook. Optional.require_rationale— If true, approver must provide notes. Default: false.
Expressions in the when field support:
- Operators:
<,>,<=,>=,==,!=,and,or,not - Operators (text):
contains - Field access:
payload.field_name(e.g.,amount.value,customer.email) - Missing fields: Evaluate to
false(not an error)
Examples:
# Numeric comparison
when: "amount.value >= 5000"
# Boolean AND
when: "amount.value >= 100 and amount.value < 5000"
# Negation
when: "not (customer.is_vip)"
# Text search
when: "subject contains 'refund'"
# Complex expression
when: "amount.value > 1000 and (customer.is_vip or agent_reasoning contains 'fraud')"File: ./config/approvers.yaml
Maps approver IDs (used in policies) to real people and groups.
approvers:
- id: finance_lead
email: priya@example.com
display_name: "Priya Sharma"
out_of_office:
active: false
from: null
until: null
delegate_to: null
- id: cfo
email: cfo@example.com
display_name: "Alex Chen"
groups:
- id: finance_team
members: [finance_lead, cfo]id— Unique identifier (used in policies)email— Email address (used for notifications and UI)display_name— Human-friendly nameout_of_office— Out-of-office delegation (schema present; enforcement planned for v1.1)
id— Group identifiermembers— List of approver IDs in the group
File: ./config/webhooks.yaml
For webhook notifications, list the endpoints to call when approvals are created:
webhooks:
- id: internal_audit
url: https://audit.example.com/webhook/approval
secret: webhook-secret-key-here
active: true
- id: slack_audit
url: https://hooks.slack.com/services/YOUR/WEBHOOK/URL
secret: ""
active: falseid— Unique identifierurl— Endpoint URLsecret— HMAC secret (optional; used to sign payloads)active— Enable/disable this webhook
Payloads are signed with X-Deliberate-Signature header (HMAC-SHA256).
All API calls to the server require the X-Deliberate-API-Key header (except UI endpoints).
GET /health
Returns:
{"status": "healthy"}POST /interrupts
Header: X-Deliberate-API-Key: <api-key>
Request body:
{
"thread_id": "550e8400-e29b-41d4-a716-446655440000",
"trace_id": "optional-trace-id",
"payload": {
"subject": "Refund for customer #4821",
"layout": "financial_decision",
"amount": { "value": 750.00, "currency": "USD" },
"customer": { "id": "4821", "display_name": "Alice Johnson" },
"agent_reasoning": "Customer reported shipping damage; invoice shows $750 refund justified.",
"evidence": [
{
"type": "invoice",
"id": "INV-2024-001",
"summary": "Original order: $750",
"url": "https://example.com/invoices/INV-2024-001"
}
],
"rationale_categories": ["damage", "customer_service", "other"]
}
}Response:
{
"approval_group_id": "550e8400-e29b-41d4-a716-446655440000",
"approval_ids": ["550e8400-e29b-41d4-a716-446655440001"],
"approval_mode": "any_of",
"status": "pending",
"decision_type": null,
"approval_id": "550e8400-e29b-41d4-a716-446655440001"
}For all_of approvals, approval_ids will have multiple entries.
GET /approvals/{approval_id}/status
Returns:
{
"status": "pending",
"approval_id": "550e8400-e29b-41d4-a716-446655440001",
"decision_type": null,
"decision_payload": null,
"rationale_category": null,
"rationale_notes": null
}After decision:
{
"status": "decided",
"approval_id": "550e8400-e29b-41d4-a716-446655440001",
"decision_type": "approve",
"decision_payload": null,
"rationale_category": "damage",
"rationale_notes": "Confirmed shipping damage. Refund approved."
}GET /approvals/{approval_id}/payload
Returns the original interrupt payload (for rendering the approval page):
{
"approval_id": "550e8400-e29b-41d4-a716-446655440001",
"status": "pending",
"layout": "financial_decision",
"payload": {
"subject": "Refund for customer #4821",
"amount": { "value": 750.00, "currency": "USD" },
...
}
}POST /approvals/{approval_id}/decide
Request body:
{
"decision_type": "approve",
"decision_payload": null,
"rationale_category": "damage",
"rationale_notes": "Confirmed shipping damage. Refund approved.",
"approver_email": "priya@example.com",
"review_duration_ms": 125,
"decided_via": "web_ui"
}Response:
{
"status": "decided"
}Decision types: approve, reject, modify, escalate.
POST /approvals/{approval_id}/resume-ack
Called by the agent after receiving the decision and resuming, to record latency:
{
"resume_status": "resumed",
"resume_latency_ms": 42
}Response:
{
"ok": true
}GET /approval-groups/{group_id}/status
For all_of approvals, returns aggregated status:
{
"group_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"approval_ids": [
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002"
],
"approvals": [
{
"approval_id": "550e8400-e29b-41d4-a716-446655440001",
"approver_email": "priya@example.com",
"status": "pending"
},
{
"approval_id": "550e8400-e29b-41d4-a716-446655440002",
"approver_email": "cfo@example.com",
"status": "decided",
"decision_type": "approve"
}
]
}GET /ledger?thread_id=<thread_id>&approver_id=<email>&date_from=<iso>&date_to=<iso>&q=<search>&cursor=<cursor>
Returns ledger entries for a thread (audit trail) with cursor-based pagination:
{
"entries": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"thread_id": "550e8400-e29b-41d4-a716-446655440001",
"event_type": "interrupt_created",
"payload": { ... },
"created_at": "2024-04-22T10:15:30Z"
},
{
"id": "550e8400-e29b-41d4-a716-446655440010",
"thread_id": "550e8400-e29b-41d4-a716-446655440001",
"event_type": "decision_recorded",
"payload": {
"approval_id": "550e8400-e29b-41d4-a716-446655440001",
"decision_type": "approve",
"approver_email": "priya@example.com",
"rationale_category": "damage"
},
"created_at": "2024-04-22T10:17:15Z"
}
],
"next_cursor": "eyJ0cyI6IjIwMjQtMDQtMjJUMTA6MTc6MTVaIiwiaWQiOiI1NTBlODQwMC4uLiJ9"
}Pass next_cursor as the cursor query parameter to fetch the next page. When next_cursor is null, all entries have been returned.
You can also export ledger entries as JSON or CSV:
GET /ledger/export/json?thread_id=<thread_id>
GET /ledger/export/csv?thread_id=<thread_id>
These endpoints support the magic-link approver identity flow. They do not require the X-Deliberate-API-Key header.
POST /auth/magic-link— Request a magic link for an approver email (returns token in response body in development mode; sends email in production).POST /auth/verify-magic-link— Exchange a magic link token for a session token.POST /auth/verify-session— Validate an existing session token and return approver identity.POST /auth/verify-approval-token— Validate a signed approval JWT (used by the approval page to confirm the token is valid and not expired).
Install the SDK in your agent environment:
pip install deliberateOr for development (from source):
cd sdk
uv pip install -e ".[dev]"The decorator wraps a LangGraph node function and handles interrupt submission, polling, and decision return.
from deliberate import approval_gate
@approval_gate(layout="financial_decision")
def process_refund(state):
"""Called when the graph reaches this node.
Returns a dict with interrupt payload fields.
The decorator submits to the server, waits for approval, and returns the decision.
"""
return {
"subject": f"Refund for customer #{state['customer_id']}",
"amount": {
"value": state['refund_amount'],
"currency": "USD"
},
"customer": {
"id": state['customer_id'],
"display_name": state['customer_name']
},
"agent_reasoning": "Customer reported damage; invoice shows order was $750.",
"evidence": [
{
"type": "invoice",
"id": state['order_id'],
"summary": "Original order",
"url": f"https://example.com/orders/{state['order_id']}"
}
],
"rationale_categories": ["damage", "quality_issue", "other"]
}In your LangGraph definition:
from langgraph.graph import StateGraph
graph = StateGraph(YourState)
graph.add_node("process_refund", process_refund)
graph.add_edge("some_node", "process_refund")
graph.add_edge("process_refund", "next_node")When the graph runs and reaches process_refund:
- The decorator submits the payload to Deliberate
- Deliberate evaluates policies and creates approval(s)
- The decorator polls for a decision (2-second intervals, 1-hour timeout by default)
- When decided, the decorator returns
{"decision": {...}}to the state - Your next node receives the decision in the state
@approval_gate(
layout="financial_decision", # Required: layout type
notify=["email", "slack"], # Optional: channels
policy="path/to/policy.yaml", # Optional: policy file
timeout_seconds=3600, # Optional: polling timeout
server_url="http://localhost:4000", # Optional: server URL
api_key="...", # Optional: API key
ui_url="http://localhost:3000" # Optional: UI URL
)
def your_node(state):
...layout(required) — Layout type for the UInotify— Notification channels. Passed through to the server; the server evaluates which channels to use based on the matched policy rule.policy— Policy file path. The server evaluates policies server-side; this parameter is reserved for future client-side filtering.timeout_seconds— Polling timeout (default: 3600 seconds / 1 hour)server_url,api_key,ui_url— Override env vars
For custom flows, you can use the client directly:
from deliberate import DeliberateClient
from deliberate.types import InterruptPayload
client = DeliberateClient(
base_url="http://localhost:4000",
api_key="your-api-key",
ui_url="http://localhost:3000"
)
# Submit an interrupt
result = await client.submit_interrupt(
thread_id="550e8400-e29b-41d4-a716-446655440000",
payload=InterruptPayload(
subject="Refund request",
layout="financial_decision",
amount={"value": 750.00, "currency": "USD"},
...
)
)
# Poll for decision
decision = await client.wait_for_decision(
result.approval_id,
timeout=3600
)
# Or for all_of groups:
decision = await client.wait_for_decision(
result.approval_group_id,
use_group=True,
timeout=3600
)
if decision['decision_type'] == 'approve':
# Resume the agent with the decision
passSee deliberate.types for all payload schemas.
Deliberate comes with built-in layouts for common approval scenarios. Choose the layout that matches your use case:
For refunds, expense approvals, budget requests. Leads with amount and evidence.
Use when:
- Approving or rejecting a monetary transaction
- Customer refunds, vendor payments, employee reimbursements
- The primary decision factor is the amount and supporting evidence
Example:
@approval_gate(layout="financial_decision")
def process_refund(state):
return {
"subject": "Refund for order #123",
"amount": {"value": 500.00, "currency": "USD"},
"customer": {"id": "C123", "display_name": "Alice"},
"agent_reasoning": "Damage reported by customer.",
"evidence": [...]
}For contracts, policies, legal redlines. Leads with the document and flagged clauses.
Use when:
- Approving a document before sending
- Reviewing contracts, policies, terms of service
- The primary decision factor is document content and specific clauses
Example:
@approval_gate(layout="document_review")
def review_contract(state):
return {
"subject": "NDA with Acme Corp",
"document": {
"title": "Non-Disclosure Agreement",
"content": "...",
"url": "https://example.com/documents/nda"
},
"flagged_items": [
{
"type": "clause",
"summary": "5-year confidentiality period",
"recommendation": "standard"
}
]
}For audit steps, compliance checks, quality gates. Leads with checklist and exceptions.
Use when:
- Signing off on a procedural step
- Compliance audits, quality gates, deployment checkouts
- The primary decision factor is a set of conditions and exceptions
Example:
@approval_gate(layout="procedure_signoff")
def compliance_signoff(state):
return {
"subject": "Q2 compliance audit",
"checklist": [
{"item": "API security scan passed", "checked": True},
{"item": "Data privacy review complete", "checked": True},
{"item": "Exception: Third-party dependency has CVE", "checked": False}
]
}Problem: Submitting an interrupt returns a 400 error with "No policy matched".
Cause: No policy rule's when expression matched, and DEFAULT_APPROVER_EMAIL is not set.
Solution:
- Check your policy YAML syntax (indentation, field names)
- Verify the interrupt payload includes the fields referenced in
whenexpressions - Test your expression: does
amount.value >= 100match? Missing fields evaluate tofalse. - Add a catch-all rule at the end:
- name: fallback when: "true" # Always matches approvers: any_of: [default_approver_id]
Problem: Approver doesn't receive Slack or email notification.
Cause: Notification config is missing or disabled.
Solution:
- Check env vars:
SLACK_BOT_TOKEN,SMTP_HOST, etc. - View notification attempts: query the
notification_attemptstableSELECT * FROM notification_attempts WHERE approval_id = '...' ORDER BY created_at DESC;
- Check logs:
docker compose logs server | grep notification - For Slack: verify bot has
chat:write,users:read,channels:readscopes - For Email: test SMTP config manually:
import smtplib with smtplib.SMTP("smtp.example.com", 587) as s: s.starttls() s.login("user", "pass") print("SMTP OK")
Problem: Approval sits pending indefinitely, doesn't escalate at timeout.
Cause: Worker service is not running, or no timeout is configured.
Solution:
- Check worker is running:
docker compose logs worker - Add timeout to your policy rule:
- name: my_rule when: "..." approvers: ... timeout: 4h on_timeout: escalate escalate_to: backup_approver
- Verify escalate_to points to a valid approver in
approvers.yaml
Problem: Browser shows 404 when clicking the approval link.
Cause: Approval ID is wrong, or the approval was created in a different environment.
Solution:
- Check the approval URL format:
http://localhost:3000/a/{token}(Approval URLs support both UUIDs and signed JWT tokens) - Verify the approval exists: query the database
SELECT id, status FROM approvals WHERE id = '550e8400-e29b-41d4-a716-446655440000';
- Ensure the UI and server are both running and connected
Problem: Agent submitted interrupt, approver decided, but agent thread is still paused.
Cause: Agent is not polling, or polling timed out.
Solution:
- Check agent logs for errors:
uv run python agent.py 2>&1 | grep -i error - Verify
DELIBERATE_SERVER_URLandDELIBERATE_API_KEYare set in the agent's environment - Check server logs:
docker compose logs server | grep ERROR - If polling timeout is too short, increase
timeout_secondsin the decorator:@approval_gate(layout="...", timeout_seconds=7200) # 2 hours
Problem: Running pytest shows database connection errors.
Solution:
- Start Docker Compose:
docker compose up -d - Set
DATABASE_URLenvironment variable before running tests:export DATABASE_URL=postgresql+asyncpg://deliberate:deliberate@localhost:5432/deliberate - For the SDK:
cd sdk && uv run pytest - For the server:
cd server && uv run pytest
- Build a custom layout — Custom Layouts guide to create approval UIs for your own workflow types
- Review security — Security guide for threat model, key management, and production hardening
- Explore the CONTRIBUTING guide — CONTRIBUTING.md for dev setup, code style, and PR process
- Discord — Join the community
- GitHub Issues — Report a bug
- Email —
hello@deliberate.dev
Built with love by Beomwoo Kang.