intrupt-py-sdk
Add human-in-the-loop approval gates to any AI agent. Pause before high-stakes tool calls, notify a human via Slack, email, or your own channel, and resume automatically once they decide. Supports LangGraph, Google ADK, OpenAI Agents SDK, and CrewAI.
Overview
intrupt-py-sdk intercepts tool calls decorated with @approval_required, suspends execution on an asyncio Future, dispatches a notification to a human approver — via the Aegmis API, Slack, email, Telegram, or a custom callable — and resumes the agent once the human decides. Works with LangGraph, Google ADK, OpenAI Agents SDK, and CrewAI.
End-to-end flow
- 1Agent LLM decides to call a tool decorated with @approval_required
- 2Decorator calls gate.request_approval(client, session_id, payload) — tool suspends on a Future
- 3on_approval_async / ApprovalClient.acreate_approval sends the notification
- 4Human receives a Slack message, email, Telegram message, or custom notification
- 5Human clicks Approve or Reject
- 6gate.resolve(approval_id, approved=True/False) unblocks the Future
- 7Tool body executes (approved) or returns {"status": "cancelled"} (rejected)
Prerequisites
Before installing, confirm you have the following:
Installation & setup
Install the SDK
Install the SDK using your preferred package manager.
pip install intrupt-py-sdkSet environment variables
Create a .env file in your project root. The SDK reads these automatically via python-dotenv.
AEGMIS_BASE_URL=https://api.aegmis.com
AEGMIS_API_KEY=sk_org_xxxx_yyyy # from Account → API Keys
AEGMIS_APPROVAL=true # on by default; set false to auto-approve
AGENT_RESUME_SECRET=<random 32-byte hex> # authenticates /resume callbacksGenerate a secure secret with: python -c "import secrets; print(secrets.token_hex(32))"
Quick Start
1 — Decorate your tool
Stack @tool (outer) and @approval_required (inner). The decorator raises interrupt(payload) before any side-effects run.
from langchain_core.tools import tool
from intrupt_py_sdk.adapters.langgraph import approval_required
@tool
@approval_required(
action="transfer_funds",
message="Review this transfer before funds move",
channel="slack",
args=["account", "amount"], # kwargs forwarded to the approver
)
def transfer_funds(account: str, amount: float) -> dict:
"""Transfer funds to an external account."""
return bank.transfer(account, amount)2 — Wrap your graph
import os
from intrupt_py_sdk.adapters.langgraph import ApprovalGraph
from intrupt_py_sdk.adapters.approval_middleware import ApprovalMiddleware
ApprovalMiddleware(
base_url="https://api.aegmis.com",
api_key=os.getenv("AEGMIS_API_KEY"),
)
approval_graph = ApprovalGraph(
graph=compiled_graph,
client=ApprovalMiddleware.get_client(),
callback_url="https://your-agent.example.com/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)3 — Handle the response
result = approval_graph.invoke(
{"messages": [{"role": "user", "content": "Transfer $500 to acct-123"}]},
thread_id="thread-abc",
)
if result["status"] == "pending_approval":
print("Waiting for:", result["approval_id"])
# After human approves via Slack:
result = approval_graph.resume(
thread_id="thread-abc",
approved=True,
approval_id="...",
)Framework Adapters
All adapters share the same gate.py Future pattern and the same on_approval_async channel abstraction. Pick the adapter for your framework — the approval logic is identical.
Google ADK
No @tool wrapper — ADK registers functions directly and injects tool_context as a kwarg. Uses session_id instead of thread_id.
from intrupt_py_sdk.adapters.google_adk import approval_required, ApprovalRunner
@approval_required(
action="purchase_stock",
message="Approve stock purchase?",
channel="slack",
args=["symbol", "quantity"],
)
async def purchase_stock(symbol: str, quantity: int, tool_context=None) -> str:
return f"Purchased {quantity} shares of {symbol}"
runner = ApprovalRunner(
agent=my_adk_agent,
app_name="finance-bot",
session_service=session_service,
callback_url="https://your-agent.example.com/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)
result = await runner.run(session_id, "Buy 10 AAPL shares")
# {"status": "pending_approval", "session_id": "...", "approval_id": "..."}
result = await runner.resume(session_id, approved=True, approval_id="...")
# {"status": "complete", "session_id": "...", "result": "..."}OpenAI Agents SDK
Apply @approval_required inside @function_tool (not outside). Thread ID flows via a context var set before the background task starts.
from agents import Agent, function_tool
from intrupt_py_sdk.adapters.openai_agents import approval_required, ApprovalAgentRunner
@function_tool # ← outer
@approval_required(
action="purchase_stock",
message="Approve stock purchase?",
channel="slack",
args=["symbol", "quantity"],
)
async def purchase_stock(symbol: str, quantity: int) -> str:
return f"Purchased {quantity} shares of {symbol}"
runner = ApprovalAgentRunner(
agent=my_openai_agent,
callback_url="https://your-agent.example.com/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)
result = await runner.run(thread_id, "Buy 10 AAPL shares")
# {"status": "pending_approval", "thread_id": "...", "approval_id": "..."}
result = await runner.resume(thread_id, approved=True, approval_id="...")CrewAI
approval_required is a factory function (not a decorator) — it wraps a BaseTool instance and returns a new gated tool. Uses run_id.
from crewai.tools import BaseTool
from intrupt_py_sdk.adapters.crewai import approval_required, ApprovalCrew
class PurchaseTool(BaseTool):
name: str = "purchase_stock"
description: str = "Buy shares of a stock."
def _run(self, symbol: str, quantity: int) -> str:
return f"Purchased {quantity} shares of {symbol}"
# Factory function — not a decorator
gated_purchase = approval_required(
PurchaseTool(),
action="purchase_stock",
message="Approve stock purchase?",
channel="slack",
args=["symbol", "quantity"],
)
crew_wrapper = ApprovalCrew(
crew=Crew(agents=[...], tasks=[...]),
callback_url="https://your-agent.example.com/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)
result = await crew_wrapper.kickoff(run_id, inputs={"request": "buy 10 AAPL"})
# {"status": "pending_approval", "run_id": "...", "approval_id": "..."}
result = await crew_wrapper.resume(run_id, approved=True, approval_id="...")Custom Approval Channels
Pass on_approval_async to bypass the Aegmis API entirely. Return a dict with "approval_id" to signal pending.
async def on_approval_async(thread_id: str, v: dict) -> dict:
"""
v keys: action, message, channel,
tool = {"name": "...", "kwargs": {...}, "description": "..."}
"""
approval_id = str(uuid.uuid4())
await notify_human(thread_id, v, approval_id)
return {"approval_id": approval_id}
approval_graph = ApprovalGraph(graph=graph, on_approval_async=on_approval_async)Console (stdin)
port 8087Blocks on a y/n prompt in the terminal. Perfect for local dev and CLI agents.
import asyncio, uuid
from intrupt_py_sdk.adapters.langgraph import ApprovalGraph
_decisions: dict[str, bool] = {}
async def console_approval(thread_id: str, v: dict) -> dict:
approval_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
answer = await loop.run_in_executor(
None, input, f"Approve {v['action']}? [y/n]: "
)
_decisions[approval_id] = answer.strip().lower() in ("y", "yes")
return {"approval_id": approval_id}
approval_graph = ApprovalGraph(graph=graph, on_approval_async=console_approval)Local policy engine
port 8088Auto-approve low-risk actions, auto-reject blocked vendors, escalate the rest.
BLOCKED_VENDORS = {"BlockedCorp", "SanctionedLtd"}
_auto: dict[str, bool] = {}
async def policy_approval(thread_id: str, v: dict) -> dict:
approval_id = str(uuid.uuid4())
kwargs = v.get("tool", {}).get("kwargs", {})
amount = float(kwargs.get("amount", 0))
vendor = kwargs.get("vendor", "")
action = v.get("action", "")
if action.startswith("emergency_") or amount < 500:
_auto[approval_id] = True
elif vendor in BLOCKED_VENDORS or amount > 50_000:
_auto[approval_id] = False
return {"approval_id": approval_id}SMTP Email
port 8089Sends an HTML email with Approve / Reject links.
import smtplib, asyncio, uuid
from email.mime.text import MIMEText
async def smtp_email_approval(thread_id: str, v: dict) -> dict:
approval_id = str(uuid.uuid4())
approve_url = f"{BASE_URL}/decide?approval_id={approval_id}&approved=true"
reject_url = f"{BASE_URL}/decide?approval_id={approval_id}&approved=false"
msg = MIMEText(f"Approve: {approve_url}\nReject: {reject_url}")
msg["Subject"] = f"[Approval Required] {v['action']}"
msg["From"] = SMTP_USER
msg["To"] = APPROVER_EMAIL
await asyncio.get_event_loop().run_in_executor(None, _send, msg)
return {"approval_id": approval_id}Slack Block Kit
port 8090Posts an interactive Block Kit message with Approve / Reject buttons.
from slack_sdk import WebClient
import uuid, asyncio
_slack = WebClient(token=SLACK_BOT_TOKEN)
async def slack_approval(thread_id: str, v: dict) -> dict:
approval_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _post_slack, approval_id, v)
return {"approval_id": approval_id}
def _post_slack(approval_id: str, v: dict):
_slack.chat_postMessage(
channel=SLACK_CHANNEL_ID,
blocks=[
{"type": "section", "text": {"type": "mrkdwn",
"text": f"*{v['action']}* requires approval"}},
{"type": "actions", "elements": [
{"type": "button", "style": "primary",
"text": {"type": "plain_text", "text": "Approve"},
"value": f"approve:{approval_id}", "action_id": "approve"},
{"type": "button", "style": "danger",
"text": {"type": "plain_text", "text": "Reject"},
"value": f"reject:{approval_id}", "action_id": "reject"},
]},
],
)Telegram Bot
port 8091Sends an inline keyboard via the Telegram Bot API.
import httpx, uuid
_TG_API = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
async def telegram_approval(thread_id: str, v: dict) -> dict:
approval_id = str(uuid.uuid4())
async with httpx.AsyncClient() as http:
await http.post(f"{_TG_API}/sendMessage", json={
"chat_id": TELEGRAM_CHAT_ID,
"text": f"*{v['action']}* requires approval",
"parse_mode": "Markdown",
"reply_markup": {"inline_keyboard": [[
{"text": "✅ Approve", "callback_data": f"approve:{approval_id}"},
{"text": "❌ Reject", "callback_data": f"reject:{approval_id}"},
]]},
})
return {"approval_id": approval_id}Email Channel (via Aegmis API)
Switch any tool to email approval by changing one argument in @approval_required. No other agent-side code changes. The Aegmis API resolves the approver's email from the matching policy, sends a branded HTML email with HMAC-signed one-click links, and calls your /resume endpoint once the human decides.
@tool
@approval_required(
action="purchase_stock",
message="Review and approve this stock purchase before funds move",
channel="email", # ← only change needed on the agent side
args=["symbol", "quantity", "amount"],
)
async def purchase_stock(symbol: str, quantity: int, amount: float) -> dict:
"""Buy shares of a stock."""
return {"status": "success", "symbol": symbol, "quantity": quantity}channel | What the Aegmis API does |
|---|---|
"slack" | Posts an interactive Block Kit message to the approver's Slack DM or channel |
"email" | Sends an HTML email with one-click Approve / Reject links via Resend |
End-to-end email flow
- 1Agent sends POST /org/{id}/approval with channel="email"
- 2Aegmis API resolves approver email(s) from the matching policy (user DM or active group members)
- 3Resend delivers a branded HTML email showing tool name, action, message, and arguments
- 4Approver clicks Approve or Reject in their inbox — no login required
- 5Aegmis API verifies the HMAC signature, records the decision, and POSTs to your /resume endpoint
- 6Agent resumes tool execution (approved) or returns cancelled (rejected)
Agent setup
The agent is configured identically to the Slack flow — only the channel argument differs:
import os
from intrupt_py_sdk.adapters.approval_middleware import ApprovalMiddleware
from intrupt_py_sdk.adapters.langgraph import ApprovalGraph
ApprovalMiddleware(
base_url="https://api.aegmis.com",
api_key=os.getenv("AEGMIS_API_KEY"),
)
approval_graph = ApprovalGraph(
graph=graph,
callback_url="https://your-agent.example.com/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)
# Invoke — returns pending_approval while human reads their email
result = await approval_graph.ainvoke(
{"messages": [{"role": "user", "content": "Buy 10 shares of AAPL"}]},
thread_id="thread-abc",
)
# {"status": "pending_approval", "thread_id": "...", "approval_id": "..."}
# /resume is called automatically by the Aegmis API after the human clicksThe Aegmis API server needs four additional variables for the email channel (RESEND_API_KEY, RESEND_FROM_EMAIL, EMAIL_DECISION_SECRET, APPROVAL_API_BASE_URL). These are server-side only — your agent process does not need them. See the full list in intrupt_api/README.md.
/email-decide endpoint
A public GET endpoint on the Aegmis API that handles link clicks. No login required — the HMAC signature is the credential. Clicking a second time (or after the approval was already decided) returns a human-friendly "Already decided" page.
GET /email-decide
?approval_id=appr_abc123
&org_id=org_xyz
&decision=approved # or "rejected"
&sig=<hmac-sha256-hex>
→ 200 HTML page confirming the decision (browser-friendly)See example/resend_email_agent.py (port 8095) for the full runnable agent and intrupt_api/integrations/email/ for the server-side implementation.
Async Usage
All ApprovalGraph methods have async counterparts. Use ainvoke and aresume inside async FastAPI handlers.
approval_graph = ApprovalGraph(graph=graph, on_approval_async=my_async_fn)
@app.post("/call-tool")
async def call_tool(request: Request):
payload = await request.json()
thread_id = payload.get("thread_id") or str(uuid.uuid4())
return await approval_graph.ainvoke(
{"messages": [{"role": "user", "content": payload["message"]}]},
thread_id,
)
@app.post("/resume")
async def resume(request: Request):
payload = await request.json()
thread_id = payload.get("thread_id")
if not approval_graph.pending(thread_id):
raise HTTPException(status_code=409, detail="thread not paused")
return await approval_graph.aresume(
thread_id,
approved=bool(payload["approved"]),
approval_id=payload.get("approval_id"),
)Example Agents
Ten ready-to-run FastAPI agents in example/.
| File | Port | Channel | Framework |
|---|---|---|---|
agent.py | 8081 | intrupt API → Slack | LangGraph |
console_agent.py | 8087 | Interactive stdin | LangGraph |
policy_agent.py | 8088 | Rule-based engine | LangGraph |
smtp_email_agent.py | 8089 | SMTP email | LangGraph |
slack_direct_agent.py | 8090 | Slack Block Kit | LangGraph |
telegram_agent.py | 8091 | Telegram Bot | LangGraph |
google_adk_agent.py | 8092 | intrupt API → Slack | Google ADK |
openai_agents_agent.py | 8093 | intrupt API → Slack | OpenAI Agents SDK |
crewai_agent.py | 8094 | intrupt API → Slack | CrewAI |
resend_email_agent.py | 8095 | intrupt API → Email | LangGraph |
SDK Reference
Complete reference for the intrupt-sdk Python package. Covers the @approval_required decorator, ApprovalGraph, ApprovalClient, ApprovalMiddleware, and the event hook system.
Installation
Install the SDK and the extras group for your agent framework. The core package includes the LangGraph adapter. All other adapters require their own framework package.
pip install intrupt-py-sdk
# Full agent example also needs LangChain OpenAI:
pip install intrupt-py-sdk langchain-openailanggraph>=0.2packageoptionalIncluded in intrupt-py-sdk dependencies. Provides StateGraph, ToolNode, MemorySaver.
langchain-core>=0.3packageoptionalProvides @tool, BaseMessage, add_messages.
langchain-openai>=0.2packageoptionalProvides ChatOpenAI. Only needed when using OpenAI models in the agent.
AEGMIS_BASE_URLenvrequiredURL of the intrupt approval API (e.g. http://localhost:8080).
AEGMIS_API_KEYenvrequiredAPI key from your intrupt dashboard.
AEGMIS_APPROVALenvrequiredEnabled by default → send approval requests to the backend for a human decision. Set false to auto-approve.
AGENT_PUBLIC_URLenvrequiredPublic URL of your agent server — used as the callback base for /resume.
AGENT_RESUME_SECRETenvoptionalRandom secret echoed as X-Agent-Secret on /resume callbacks for authentication.
OPENAI_API_KEYenvrequiredOpenAI API key for ChatOpenAI.
ApprovalClient
The base HTTP wrapper. Sends approval requests to the approval API and exposes a hook registry. Use this directly when you don't need the LangGraph adapter.
from intrupt_py_sdk.core.client import ApprovalClient
client = ApprovalClient(
base_url="http://localhost:8080", # URL of the running approval API
api_key="sk_org_...", # API key from your dashboard
)base_urlstrrequiredBase URL of the approval API. Read from AEGMIS_BASE_URL if not passed.
api_keystrrequiredAPI key used in the Authorization header. Read from AEGMIS_API_KEY if not passed.
timeoutfloatoptionaldefault: 30.0HTTP timeout in seconds for each request to the approval API.
Methods
client.create_approval(...) → dict
Posts POST /approval to the approval API. Returns the created approval record including approval_id.
approval = client.create_approval(
thread_id="conv-abc-123",
action="purchase_stock",
message="Approve buying 10 shares of AAPL",
channel="slack",
tool={
"name": "purchase_stock",
"kwargs": {"symbol": "AAPL", "quantity": 10},
},
agent_callback_url="http://my-agent:8081/resume",
)
print(approval["approval_id"]) # uuidthread_idstrrequiredLangGraph thread ID (or any string that identifies the paused conversation checkpoint).
actionstrrequiredShort identifier for the action, shown in the approval UI and audit log.
messagestrrequiredHuman-readable description of what the agent wants to do.
channelstroptionaldefault: "slack"Delivery channel. Only "slack" is fully implemented today.
tooldictoptionalOptional dict with "name" and "kwargs" keys. Shown in the Slack message so approvers see exactly what will run.
agent_callback_urlstroptionalURL the approval API will POST to when the human decides. Should point to your agent's /resume endpoint.
agent_callback_secretstroptionalYour AGENT_RESUME_SECRET. The cloud stores it opaquely and echoes it back as X-Agent-Secret when calling /resume. Never needs to be set on the cloud API — stays entirely in your agent's environment.
client.on(event, handler) / client.emit(event, data)
Hook registry for approval lifecycle events. Register a handler before the first tool call; it fires synchronously when the event occurs.
# Register a handler
@client.on("approval.created")
def on_created(data: dict):
print(f"Approval created: {data['approval_id']}")
@client.on("approval.decided")
def on_decided(data: dict):
decision = "approved" if data["approved"] else "rejected"
print(f"Approval {data['approval_id']} was {decision}")approval.createdeventoptionalFired after create_approval returns. Payload includes approval_id, action, channel.
approval.decidedeventoptionalFired when the SDK receives the resume callback. Payload includes approval_id and approved (bool).
approval.erroreventoptionalFired when an HTTP call to the approval API fails. Payload includes the exception.
ApprovalMiddleware
LangGraph adapter. Wraps ApprovalClient as a singleton and exposes the @approval_required decorator. Import from intrupt_py_sdk.adapters.langgraph.
Singleton behaviour
ApprovalMiddleware uses __new__ to return the same instance on every construction. Construct it once at startup with your URL and API key; every subsequent call — including inside @approval_required — picks up the already-configured client. Re-constructing with different args mutates the shared client.from intrupt_py_sdk.adapters.langgraph import approval_required
from intrupt_py_sdk.adapters.approval_middleware import ApprovalMiddleware
# Construct once at startup — subsequent calls return the same instance
ApprovalMiddleware(
base_url=os.getenv("AEGMIS_BASE_URL", "http://localhost:8080"),
api_key=os.getenv("AEGMIS_API_KEY"),
)
# Retrieve the configured client anywhere
client = ApprovalMiddleware.get_client()ApprovalMiddleware.get_client()ApprovalClientoptionalReturns the singleton ApprovalClient. Raises RuntimeError if called before the first construction.
ApprovalGraph
LangGraph wrapper that handles interrupt detection, approval creation, and graph resumption in one place. Eliminates the need to write a _build_response helper in every agent. Import from intrupt_py_sdk.adapters.langgraph.
Recommended pattern
ApprovalGraph after compiling your LangGraph graph, then call approval_graph.invoke() and approval_graph.resume() from your FastAPI endpoints. All interrupt detection and create_approval() calls happen inside the wrapper.from intrupt_py_sdk.adapters.langgraph import ApprovalGraph
from intrupt_py_sdk.adapters.approval_middleware import ApprovalMiddleware
# HTTP approval flow (intrupt API)
approval_graph = ApprovalGraph(
graph=graph,
callback_url="http://localhost:8081/resume",
callback_secret=os.getenv("AGENT_RESUME_SECRET", ""),
)
# Inline async channel (console, email, Slack, policy engine)
approval_graph = ApprovalGraph(
graph=graph,
on_approval_async=my_async_fn,
timeout=1.5,
)graphCompiledGraphrequiredA compiled LangGraph StateGraph (result of .compile(checkpointer=...)).
callback_urlstroptionaldefault: ""Full URL of your agent's /resume endpoint. Used with the HTTP approval flow (intrupt API).
callback_secretstroptionaldefault: ""Value of AGENT_RESUME_SECRET. Sent as X-Agent-Secret on /resume callbacks for authentication.
on_approval_asynccallableoptionalAsync callback (thread_id, payload) -> {"approval_id": "..."}. Use instead of the HTTP API for inline channels (console, email, Slack, policy engine).
timeoutfloatoptionaldefault: 1.5Seconds to wait for an approval gate to fire before returning pending_approval. Set higher if your LLM or tool startup is slow.
clientApprovalClientoptionalDeprecated. Call ApprovalMiddleware(base_url=...) at startup instead — it is picked up automatically.
Methods
approval_graph.invoke(input, thread_id, config=None) → dict
Runs the graph synchronously, detects any approval interrupt, calls create_approval(), and returns a structured response. Use this in your /call-tool endpoint.
@app.post("/call-tool")
async def call_tool(request: Request):
payload = await request.json()
thread_id = payload.get("thread_id") or str(uuid.uuid4())
result = approval_graph.invoke(
{"messages": [{"role": "user", "content": payload["message"]}]},
thread_id,
# optional: pass recursion_limit, tags, callbacks, extra configurable keys
config={"recursion_limit": 50, "tags": ["prod"]},
)
return result
# Returns one of:
# {"status": "pending_approval", "thread_id": "...", "approval_id": "..."}
# {"status": "complete", "thread_id": "...", "messages": [...], "result": {...}}The optional config dict is merged with the internal LangGraph config. You can pass recursion_limit, tags, metadata, extra configurable keys (e.g. model choice), or your own callbacks (e.g. LangSmith tracers). Your callbacks are appended to the handler list — the approval handler is never dropped.thread_id always wins inside configurable.
Complete response shape
status == "complete", the response includes both a messages list (convenience shortcut) and a result dict containing the full graph state — including any custom state fields like last_purchase or invoice_id.await approval_graph.ainvoke(input, thread_id, config=None) → dict
Async equivalent of invoke(). Use with async def FastAPI handlers. Accepts the same optional config and returns the same response shape.
@app.post("/call-tool")
async def call_tool(request: Request):
payload = await request.json()
thread_id = payload.get("thread_id") or str(uuid.uuid4())
return await approval_graph.ainvoke(
{"messages": [{"role": "user", "content": payload["message"]}]},
thread_id,
)approval_graph.resume(thread_id, approved, approval_id=None, config=None) → dict
Issues Command(resume=...) to the graph and returns the same structured response as invoke(). Use this in your /resume endpoint.
@app.post("/resume")
async def resume(request: Request):
payload = await request.json()
return approval_graph.resume(
thread_id=payload["thread_id"],
approved=bool(payload["approved"]),
approval_id=payload.get("approval_id"),
# config={"tags": ["resume"]} # optional
)thread_idstrrequiredThe thread to resume. Must match the thread_id from the original invoke() response.
approvedboolrequiredTrue to continue tool execution, False to return a cancellation result to the LLM.
approval_idstroptionalApproval record ID from the original pending_approval response. Passed back into the graph as resume context.
configdictoptionalOptional LangGraph config merged with the internal one. Supports recursion_limit, tags, metadata, configurable keys, and callbacks.
await approval_graph.aresume(thread_id, approved, approval_id=None, config=None) → dict
Async equivalent of resume(). Same parameters and response shape.
approval_graph.stream / astream(input, thread_id, config=None)
Yields raw LangGraph chunks as they are produced. When the graph pauses on an approval interrupt, a final __approval__ sentinel chunk is emitted so callers can detect the pause without polling.
# Sync streaming
for chunk in approval_graph.stream(input_dict, thread_id):
if "__approval__" in chunk:
info = chunk["__approval__"]
# {"status": "pending_approval", "thread_id": "...", "approval_id": "..."}
else:
print(chunk) # normal graph output chunk
# Async streaming — ideal for FastAPI StreamingResponse
from fastapi.responses import StreamingResponse
import json
@app.post("/call-tool-stream")
async def call_tool_stream(request: Request):
payload = await request.json()
thread_id = payload.get("thread_id") or str(uuid.uuid4())
async def generate():
async for chunk in approval_graph.astream(
{"messages": [{"role": "user", "content": payload["message"]}]},
thread_id,
):
yield json.dumps(chunk) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")approval_graph.get_state / update_state
Direct access to the graph checkpoint — read or mutate state without bypassing ApprovalGraph to reach .graph directly.
# Read the current graph state for a thread
state = approval_graph.get_state(thread_id)
print(state.values) # full state dict
print(state.next) # which nodes run next
# Inject values directly into the checkpoint (tests / manual corrections)
approval_graph.update_state(thread_id, {"last_purchase": None})
# Inject as if a specific node produced the update
approval_graph.update_state(
thread_id,
{"messages": [AIMessage(content="Overridden")]},
as_node="chat_node",
)thread_idstrrequiredThread whose checkpoint to read or update.
valuesdictrequired(update_state only) State fields to inject. Merged according to each field's reducer.
as_nodestroptional(update_state only) Name of the node to attribute the update to. Affects which edges fire next.
approval_graph.pending(thread_id) → bool
Returns True if the thread is currently paused on an approval interrupt. Use this to guard against sending new messages to a thread that is waiting for a decision, or to verify the thread can be resumed before calling resume().
# Guard: reject new messages while approval is pending
if payload.get("thread_id") and approval_graph.pending(thread_id):
raise HTTPException(status_code=409, detail="thread has a pending approval")
# Guard: verify before resuming
if not approval_graph.pending(thread_id):
raise HTTPException(status_code=409, detail="thread is not paused on an approval")Handling pending_approval
When a tool triggers an approval gate, run() / ainvoke() returns {"status": "pending_approval", "thread_id": "...", "approval_id": "..."} within the configured timeout (default 1.5 s). There are two ways to deliver the final result back to the original caller.
Option 1 — long-poll in /call-tool (recommended for demos)
After receiving pending_approval, keep the HTTP request open and poll runner._results with asyncio.sleep. When the human decides, the background task finishes and the final result is stored in _results. The same HTTP response then returns it to the caller.
Use asyncio.sleep, not time.sleep
time.sleep blocks the entire event loop. While it sleeps, FastAPI cannot handle the /resume callback from Slack — creating a deadlock where the poll loop waits for a result that can never arrive. asyncio.sleep yields between iterations so /resume is handled concurrently.import asyncio
@app.post("/call-tool")
async def call_tool(body: CallToolRequest):
thread_id = body.thread_id or str(uuid.uuid4())
result = await runner.run(thread_id, body.message)
if result.get("status") != "pending_approval":
return result
# Long-poll up to 5 minutes for the human to approve/reject.
# asyncio.sleep yields the event loop so /resume can run concurrently.
for _ in range(300):
await asyncio.sleep(1)
final = runner._results.get(thread_id)
if final and final.get("status") != "pending_approval":
return final
raise HTTPException(status_code=408, detail="approval timed out")Option 2 — return immediately, expose GET /result/{thread_id}
Return pending_approval immediately and let the client poll GET /result/{thread_id}. Better for long approvals where keeping an HTTP connection open for minutes is impractical (browsers, mobile apps, short-timeout proxies).
@app.post("/call-tool")
async def call_tool(body: CallToolRequest):
thread_id = body.thread_id or str(uuid.uuid4())
result = await runner.run(thread_id, body.message)
# Return immediately — client polls /result/{thread_id} for the final state.
return result
@app.get("/result/{thread_id}")
async def get_result(thread_id: str):
result = runner._results.get(thread_id)
if result is None:
raise HTTPException(status_code=404, detail="No result for this thread_id")
return resultOption 3 — Server-Sent Events (real-time UIs / Google ADK)
The Google ADK adapter publishes every state transition to per-session queues via runner.subscribe(session_id). Expose a GET /events/{session_id} endpoint that streams these as Server-Sent Events. The client receives one JSON event per state change (in_progress → pending_approval → complete | error) without polling.
@app.get("/events/{session_id}")
async def event_stream(session_id: str):
async def generator():
queue = runner.subscribe(session_id)
try:
while True:
try:
data = await asyncio.wait_for(queue.get(), timeout=25.0)
yield f"data: {json.dumps(data)}\n\n"
if data.get("status") in ("complete", "error"):
break
except asyncio.TimeoutError:
yield ": keepalive\n\n" # prevent proxy from dropping idle connection
finally:
runner.unsubscribe(session_id, queue)
return StreamingResponse(generator(), media_type="text/event-stream")@approval_required
Decorator that gates a Python function behind a human approval. When the decorated function is called, it creates an approval request, pauses via LangGraph's interrupt(), and only executes the function body if the human approves. Import from intrupt_py_sdk.adapters.langgraph.
from intrupt_py_sdk.adapters.langgraph import approval_required
@approval_required(
action="delete_record",
message="Agent wants to delete a database record",
channel="slack",
)
def delete_record(record_id: str) -> dict:
db.delete(record_id)
return {"deleted": record_id}actionstrrequiredShort identifier for the action. Shown in the Slack message header and stored in the audit log. Keep it human-readable and unique per tool type.
messagestrrequiredSentence describing what the agent is trying to do. Displayed prominently in the Slack approval card.
channelstroptionaldefault: "slack"Notification channel. "slack" is the only fully-shipped option today.
argslist[str]optionaldefault: []List of kwarg names from the decorated function to surface in the approval message. If empty, no args are shown. Use this to omit sensitive or large values.
What happens on rejection
{"status": "cancelled", "tool": "<fn name>", "message": "<fn name> was not approved"} without executing the function body. The graph continues from the tool node with this result as the tool message — the LLM sees the cancellation and can decide what to do next (e.g. inform the user, try a different approach).Full LangGraph agent example
A minimal but complete agent using ApprovalGraph. Two tools, a LangGraph graph, and a FastAPI server with /call-tool and /resume — no boilerplate.
import hmac
import os
import uuid
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, StateGraph
from langgraph.prebuilt import ToolNode
from langgraph.graph.message import add_messages
from intrupt_py_sdk.adapters.approval_middleware import ApprovalMiddleware
from intrupt_py_sdk.adapters.langgraph import ApprovalGraph, approval_required
load_dotenv()
ApprovalMiddleware(
base_url=os.getenv("AEGMIS_BASE_URL"),
api_key=os.getenv("AEGMIS_API_KEY"),
)
AGENT_PUBLIC_URL = os.getenv("AGENT_PUBLIC_URL", "http://localhost:8081")
_RESUME_SECRET = os.getenv("AGENT_RESUME_SECRET", "")
# ── State ────────────────────────────────────────────────────────────────────
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# ── Tools ────────────────────────────────────────────────────────────────────
@tool
def get_stock_price(symbol: str) -> dict:
"""Fetch latest stock price for a given symbol."""
return {"symbol": symbol, "price": 150.00} # replace with real API call
@tool
@approval_required(
action="buy_stock",
message="Approve buying shares",
channel="slack",
args=["symbol", "quantity", "amount"],
)
def buy_stock(symbol: str, quantity: int, amount: float) -> dict:
"""Purchase a stock. Requires human approval before execution."""
return {"status": "success", "symbol": symbol, "quantity": quantity}
tools = [get_stock_price, buy_stock]
llm = ChatOpenAI().bind_tools(tools)
# ── Graph ────────────────────────────────────────────────────────────────────
def chat_node(state: AgentState):
return {"messages": [llm.invoke(state["messages"])]}
def route(state: AgentState) -> str:
last = state["messages"][-1]
return "tools" if getattr(last, "tool_calls", None) else "END"
memory = MemorySaver()
graph = (
StateGraph(AgentState)
.add_node("chat_node", chat_node)
.add_node("tools", ToolNode(tools))
.add_edge(START, "chat_node")
.add_conditional_edges("chat_node", route)
.add_edge("tools", "chat_node")
.compile(checkpointer=memory)
)
approval_graph = ApprovalGraph(
graph=graph,
callback_url=f"{AGENT_PUBLIC_URL}/resume",
callback_secret=_RESUME_SECRET,
)
# ── FastAPI ──────────────────────────────────────────────────────────────────
app = FastAPI()
@app.post("/call-tool")
async def call_tool(request: Request):
payload = await request.json()
if not payload.get("message"):
raise HTTPException(status_code=400, detail="'message' required")
thread_id = payload.get("thread_id") or str(uuid.uuid4())
if payload.get("thread_id") and approval_graph.pending(thread_id):
raise HTTPException(status_code=409, detail="thread has a pending approval")
result = await approval_graph.ainvoke(
{"messages": [{"role": "user", "content": payload["message"]}]},
thread_id,
)
# result["status"] == "pending_approval" → {"thread_id", "approval_id"}
# result["status"] == "complete" → {"thread_id", "messages", "result"}
# result["result"] contains the full graph state (custom fields included)
return result
@app.post("/resume")
async def resume(request: Request):
# Auth check is skipped when AGENT_RESUME_SECRET is not set
if _RESUME_SECRET and not hmac.compare_digest(request.headers.get("X-Agent-Secret", ""), _RESUME_SECRET):
raise HTTPException(status_code=401, detail="invalid X-Agent-Secret")
payload = await request.json()
thread_id = payload.get("thread_id")
if not thread_id or "approved" not in payload:
raise HTTPException(status_code=400, detail="thread_id and approved required")
if not approval_graph.pending(thread_id):
raise HTTPException(status_code=409, detail="thread is not paused on an approval")
return await approval_graph.aresume(
thread_id,
approved=bool(payload["approved"]),
approval_id=payload.get("approval_id"),
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8081)Error handling
The SDK raises standard Python exceptions. Catch them around your tool invocations or in your graph's error handler.
ApprovalErrorexceptionoptionalBase class for all SDK exceptions. Raised when the approval API returns a non-2xx status.
ApprovalTimeoutErrorexceptionoptionalRaised when an approval request exceeds the configured timeout (not yet shipped — timeouts are planned for v0.2).
ApprovalRejectedErrorexceptionoptionalOptionally raised by the decorator when the human rejects. By default the decorator returns a cancellation dict instead of raising.
from intrupt_py_sdk.core.client import ApprovalError
try:
result = graph.invoke({"messages": [...]}, config=config)
except ApprovalError as e:
# Approval API unreachable or returned an error
print(f"Approval failed: {e}")Observability (optional)
One init() call at startup streams every governed call to the Aegmis Observability dashboard — live status (waiting → approved / rejected / audited), who decided, latency, arguments, and the matched policy. Without it every hook is a no-op; approvals work unchanged.
import os
from intrupt_py_sdk.core import observability
# Safe to call unconditionally: a None endpoint disables emission.
observability.init(
os.getenv("AEGMIS_OTLP_ENDPOINT"), # e.g. https://obs.aegmis.com
service_name="finance-agent",
)Emission is fire-and-forget on background threads — an unreachable observability service never blocks or fails a tool call (the SDK logs a single warning and retries silently). Requests authenticate with AEGMIS_API_KEY; your org is derived from the key server-side.
AEGMIS_OTLP_ENDPOINTenvoptionalAegmis Observability endpoint (e.g. https://obs.aegmis.com). Pass it to observability.init(); unset → observability is off.
AEGMIS_ENABLE_TRACINGenvoptionaldefault: trueSet false to disable all observability emission even when init() is called. Has no effect on approvals themselves.
Security quick-reference
Required in production
- Set AGENT_RESUME_SECRET — a random 32-byte hex string — so only the approval API can resume your agent.
- Set SLACK_SIGNING_SECRET so the approval API can verify that button clicks genuinely came from Slack.
- Keep /resume behind a VPC or firewall; only the approval API needs to reach it.
- Never log or surface the values of args you omitted from the @approval_required args list.
What's next
Quickstart
Step-by-step walkthrough for running your first approval end-to-end.
Policy Engine
Write conditions that auto-route approvals to the right human.
Slack Setup
Create a Slack app, configure scopes, and point the webhook at your API.
JavaScript / TypeScript SDK
intrupt-js-sdk is the JS/TS counterpart of intrupt-py-sdk — it talks to the same approval API over the same wire protocol. A gated tool pauses before executing, the SDK requests approval, a human approves/rejects (e.g. in Slack), and the platform calls back into your agent's /resume endpoint to unblock it.
Installation
Requires Node ≥ 18. The package is ESM-first but also ships CommonJS, so both import and require work.
npm i intrupt-js-sdk@alphaCurrently a prerelease — install with the @alpha tag. Drop it once a stable release is published.
Framework glue loads from subpaths, so you only pull in the peer dependency you use:
intrupt-js-sdk/vercelpeer dep: ai
intrupt-js-sdk/openai-agentspeer dep: @openai/agents
intrupt-js-sdk/mastrapeer dep: @mastra/core
intrupt-js-sdk/langchainpeer dep: @langchain/core
intrupt-js-sdk/langgraphpeer dep: @langchain/langgraph
Environment variables
AEGMIS_APPROVALenvoptionaldefault: trueMaster switch. Enabled by default → gated tools send approval requests to the backend for a real human decision. Set false (or 0/no/off) to auto-approve in-process with no backend call. Read at runtime via approvalsEnabled().
AEGMIS_BASE_URLenvrequiredBase URL of the approval API (e.g. https://api.aegmis.com or http://localhost:8080). Required when approvals are enabled (the default).
AEGMIS_API_KEYenvrequiredOrg API key, format sk_org_{org_id}_{hash}. Required when approvals are enabled (the default).
AGENT_RESUME_SECRETenvoptionalShared secret the backend sends as X-Agent-Secret when it calls your /resume endpoint. Also the default resumeSecret for the optional createApprovalServer.
AGENT_PUBLIC_URLenvoptionaldefault: http://localhost:8081Public URL of your agent. The optional createApprovalServer derives its listen port from it; also handy for building the runner's callbackUrl.
AEGMIS_OTLP_ENDPOINTenvoptionalAegmis Observability endpoint (e.g. https://obs.aegmis.com). When set, the SDK pushes governed-call lifecycle events (waiting → approved/rejected, latency, args) so they appear live in the Observability dashboard. Requests are authenticated with AEGMIS_API_KEY; the org is derived from the key server-side.
AEGMIS_ENABLE_TRACINGenvoptionaldefault: trueSet false to disable the observability push entirely. Has no effect on approvals themselves.
The SDK reads process.env but does not load a .env file for you. Load it yourself — add import "dotenv/config" at the very top of your entrypoint (before any module that reads these vars), or run with node --env-file=.env. Then npm i -D dotenv.
Call preflightCheck() once at startup to catch missing / malformed config early (e.g. approvals enabled but no AEGMIS_API_KEY) with a clear message instead of a runtime Invalid or expired token.
Quick start (Vercel AI SDK)
import { generateText, stepCountIs, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { ApprovalMiddleware, ApprovalRunner } from "intrupt-js-sdk";
import { approvalRequired } from "intrupt-js-sdk/vercel";
ApprovalMiddleware.configure({
baseUrl: process.env.AEGMIS_BASE_URL, // http://localhost:8080
apiKey: process.env.AEGMIS_API_KEY, // sk_org_org_..._<hash>
});
const purchaseStock = approvalRequired(
{ action: "purchase_stock", message: "Approve buying shares", channel: "slack", args: ["symbol", "quantity"] },
"purchase_stock",
)(tool({
description: "Buy shares of a stock.",
inputSchema: z.object({ symbol: z.string(), quantity: z.number() }),
execute: async ({ symbol, quantity }) => ({ status: "success", symbol, quantity }),
}));
const runner = new ApprovalRunner({
callbackUrl: "http://localhost:8081/resume",
callbackSecret: process.env.AGENT_RESUME_SECRET,
invoke: (input) =>
generateText({ model: openai("gpt-4o-mini"), tools: { purchaseStock }, stopWhen: stepCountIs(5), prompt: String(input) }),
formatResult: (raw, threadId) => ({ status: "complete", thread_id: threadId, result: (raw as any).text }),
});
// 1. Start a run — returns { status: "pending_approval", approval_id } if a gated tool fires.
const pending = await runner.run("thread-1", "buy 10 shares of AAPL");
// 2. When the human decides (your /resume endpoint), unblock the tool:
await runner.resume("thread-1", true, pending.approval_id as string);
const final = await runner.waitForResult("thread-1");Uses the Vercel AI SDK v5+ tool API: inputSchema (not the old parameters) and stopWhen: stepCountIs(n) (not the removed maxSteps). On ai v3/v4, use parameters / maxSteps instead.
Mastra adapter example
Wrap a Mastra tool with approvalRequired (supports @mastra/core ≥ 1.0). The tool's id becomes the approval action name.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { ApprovalMiddleware } from "intrupt-js-sdk";
import { approvalRequired } from "intrupt-js-sdk/mastra";
ApprovalMiddleware.configure({
baseUrl: process.env.AEGMIS_BASE_URL,
apiKey: process.env.AEGMIS_API_KEY,
});
export const purchaseStock = approvalRequired({
action: "purchase_stock",
message: "Approve stock purchase?",
channel: "slack",
args: ["symbol", "quantity"],
})(
createTool({
id: "purchase_stock",
description: "Buy shares of a stock.",
inputSchema: z.object({ symbol: z.string(), quantity: z.number() }),
// Mastra >= 1.0 passes the validated input as the first arg.
execute: async (inputData) => ({ status: "success", ...inputData }),
}),
);LangChain.js & LangGraph.js
LangChain tools are Runnables, so prefer gateHandler — wrap the raw handler before building the tool (robust and version-proof). LangGraph.js tools are LangChain tools, so the same helper works from intrupt-js-sdk/langgraph.
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { gateHandler } from "intrupt-js-sdk/langchain"; // same export from /langgraph
const purchaseStock = tool(
gateHandler(
async ({ symbol, quantity }) => ({ status: "success", symbol, quantity }),
{ name: "purchase_stock", description: "Buy shares." },
{ action: "purchase_stock", message: "Approve buying shares", channel: "slack", args: ["symbol", "quantity"] },
),
{
name: "purchase_stock",
description: "Buy shares.",
schema: z.object({ symbol: z.string(), quantity: z.number() }),
},
);LangGraph also provides ApprovalGraph, which wraps a compiled graph with the run / resume flow and exposes getState / updateState:
import { ApprovalGraph } from "intrupt-js-sdk/langgraph";
const approvalGraph = new ApprovalGraph({
graph, // your compiled StateGraph
callbackUrl: process.env.AGENT_PUBLIC_URL + "/resume",
callbackSecret: process.env.AGENT_RESUME_SECRET,
});
let result = await approvalGraph.run(threadId, { messages: [{ role: "user", content: msg }] });
// if result.status === "pending_approval": wait for the /resume callback, then:
result = await approvalGraph.resume(threadId, true, result.approval_id);The two-step run / resume flow
ApprovalRunner.run() launches your agent as a background task and returns within timeout seconds (default 1.5s). If a gated tool opens an approval gate first, run() returns { status: "pending_approval", approval_id, thread_id } while the task stays parked on the gate.
resume() resolves the gate and returns immediately (Slack retries if you block > ~3s). The task finishes in the background — poll waitForResult(threadId) for the terminal result.
Serving it: POST /call-tool and /resume
Wrap the runner in a small HTTP server. /call-tool starts an agent turn; if a gated tool fires it returns pending_approval. When the human decides, the approval platform POSTs the decision to the /resume URL you set as the runner's callbackUrl, which resolves the gate and lets the tool run.
Optional built-in: createApprovalServer (from intrupt-js-sdk/server — zero-dependency, built on Node's http) exposes both endpoints for you:
import { ApprovalRunner } from "intrupt-js-sdk";
import { createApprovalServer } from "intrupt-js-sdk/server";
const runner = new ApprovalRunner({ /* callbackUrl, invoke, formatResult */ });
// port + resumeSecret default from AGENT_PUBLIC_URL / AGENT_RESUME_SECRET
createApprovalServer({ runner });Prefer your own server (express, fastify, Next.js route handlers)? It's fully optional — the runner only needs run / resume / waitForResult / pending. Equivalent wiring:
import express from "express";
import crypto from "node:crypto";
import { ApprovalMiddleware, ApprovalRunner } from "intrupt-js-sdk";
// ...plus the Mastra agent + gated purchaseStock tool from above...
ApprovalMiddleware.configure({
baseUrl: process.env.AEGMIS_BASE_URL,
apiKey: process.env.AEGMIS_API_KEY,
});
const runner = new ApprovalRunner({
callbackUrl: process.env.AGENT_PUBLIC_URL + "/resume", // platform POSTs decisions here
callbackSecret: process.env.AGENT_RESUME_SECRET,
invoke: (input) => agent.generate(String(input)),
formatResult: (raw, threadId) => ({ status: "complete", thread_id: threadId, result: raw.text }),
});
const app = express();
app.use(express.json());
// Start a turn — returns pending_approval if a gated tool opens a gate.
app.post("/call-tool", async (req, res) => {
const threadId = req.body.thread_id ?? crypto.randomUUID();
res.json(await runner.run(threadId, req.body.message));
});
// The approval platform calls this once a human approves / rejects.
app.post("/resume", async (req, res) => {
const secret = process.env.AGENT_RESUME_SECRET ?? "";
const got = req.header("X-Agent-Secret") ?? "";
if (secret && (got.length !== secret.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(secret)))) {
return res.status(401).json({ detail: "invalid X-Agent-Secret" });
}
const { thread_id, approved, approval_id } = req.body;
let result = await runner.resume(thread_id, approved, approval_id);
if (result.status === "accepted") result = await runner.waitForResult(thread_id);
res.json(result);
});
app.listen(8081, () => console.log("agent listening on :8081"));1 · Start a turn
curl -sX POST http://localhost:8081/call-tool \
-H 'Content-Type: application/json' \
-d '{"message": "buy 10 shares of AAPL"}'
# → { "status": "pending_approval", "approval_id": "appr_9f…", "thread_id": "b1c2…" }2 · Decide → the platform calls /resume
A human approves in Slack / email / the dashboard and the platform POSTs the decision to your /resume (with the X-Agent-Secret header). To simulate it yourself:
curl -sX POST http://localhost:8081/resume \
-H 'Content-Type: application/json' \
-H "X-Agent-Secret: $AGENT_RESUME_SECRET" \
-d '{"thread_id": "b1c2…", "approved": true, "approval_id": "appr_9f…"}'
# → { "status": "complete", "result": "Purchase order placed for 10 shares of AAPL." }A rejection ("approved": false) resolves the gate the same way; the gated tool returns a cancelled result instead of running.
Local / policy approval (no HTTP)
Approve without the backend API, Slack, or a /resume server — handy for local dev, tests, and the Mastra studio:
- Set
AEGMIS_APPROVAL=false→ gated tools auto-approve in-process. - Pass
onApprovalAsyncto a runner → decide in-process (a rule, a console prompt, a DB lookup) and resolve the gate yourself.
const runner = new ApprovalRunner({
invoke,
// Decide approvals in-process instead of calling the backend API.
onApprovalAsync: async (threadId, payload) => {
return { approval_id: "local_" + threadId };
},
});
// run() returns pending_approval with that id; resolve the gate however you decide:
await runner.resume(threadId, /* approved */ true, "local_" + threadId);See example/console_agent.ts — a self-contained terminal-approval demo that needs no API server or key.
Observability (optional)
Stream every governed call to the Aegmis Observability dashboard — live status (waiting → approved / rejected / audited), who decided, latency, arguments, and the matched policy. In the JS SDK this is zero-code: set AEGMIS_OTLP_ENDPOINT and the SDK auto-initializes on the first gated call.
AEGMIS_OTLP_ENDPOINT=https://obs.aegmis.com # unset → observability is off
# AEGMIS_ENABLE_TRACING=false # kill switch, default trueEmission is fire-and-forget — an unreachable observability service never blocks or fails a tool call (the SDK logs a single warning and resumes silently when it recovers). Requests authenticate with AEGMIS_API_KEY; your org is derived from the key server-side. Both variables are also listed in Environment variables.
Examples
The package ships a runnable agent per adapter — Vercel AI SDK, OpenAI Agents, Mastra, LangChain.js, and LangGraph.js — plus console_agent.ts, a self-contained terminal-approval demo that needs no API server or key. The HTTP examples share an Express _server.ts exposing /call-tool and /resume, mirroring the Python example/agent.py.
Coding CLI Tools
Beyond the framework SDKs, intrupt gates the tool calls of coding agents — Claude Code, Codex, Cursor, Gemini CLI and more — behind the same human approval and policy engine. Each CLI ships an integration hook that intercepts risky actions (shell commands, file writes) before they run, sends them to the approval API stamped with an adapter identifier, and blocks or allows the action based on the human's decision.
One approval platform, many sources
/org/{org_id}/approval endpoint. The only difference is the adapter value they set — which lets you enforce and route each tool independently.How it works
A coding agent's pre-tool-use hook fires before every shell command or file edit. All nine hooks share one gate (core/gate.py in the agent-hooks repo) — each CLI only gets a thin adapter for its native interception point and block protocol.
The coding agent is about to run a tool (e.g. a shell command or a file write).
The gate's local pre-filter decides what needs a human. By default (AEGMIS_FORWARD_ALL=false) routine project-local commands (ls, git status, rm -rf ./build) run free; risky ones — git push, sudo, terraform apply, curl | sh, exfil patterns, and anything touching $HOME or system dirs — are forwarded for approval.
The hook POSTs to /org/{org_id}/approval with adapter set to the tool's id, plus tool_name, action, and tool_kwargs (the command, path, etc.), then polls for the decision.
The approval API checks per-adapter enforcement, runs the policy engine, and notifies the approver via Slack or email (or auto-approves and audits).
The hook receives the decision and either lets the tool run (approved) or blocks it. Every hook fails closed: rejection, timeout, missing key, or an unreachable API all block the action.
Configuration
Every coding-tool hook reads the same AEGMIS_* environment variables from its .env.intrupt file. The first two are required whenever approvals are sent to the server; the rest have sensible defaults, so set them only to override behavior.
| Variable | Default | Purpose |
|---|---|---|
AEGMIS_BASE_URL | api.aegmis.com | intrupt API base URL. Defaults to https://api.aegmis.com — override for self-hosted deployments. |
AEGMIS_API_KEY | required* | Org-scoped API key (sk_org_{org_id}_{hash}) from Account → API Keys — the org id is extracted from the key. Needed whenever approvals are sent to the server. |
AEGMIS_APPROVAL | true | Master kill switch. false disables the gate entirely (allow all, no API calls). |
AEGMIS_FORWARD_ALL | false | false (default): the local risk-pattern pre-filter decides what needs approval. true: forward every gated tool call to the Aegmis policy engine instead (unmatched calls auto-approve). |
AEGMIS_CHANNEL | slack | Where approval requests are delivered — slack or email. |
AEGMIS_TIMEOUT | 600 | Max seconds to wait for a human decision. On timeout the action is blocked (fail-closed). |
AEGMIS_POLL_INTERVAL | 5 | How often (in seconds) the hook polls the API for the decision. |
AEGMIS_ENV_FILE | <hook dir>/.env | Path to the .env file the hook auto-loads at startup. |
AEGMIS_BYPASS_PATTERNS | — | Comma-separated regex list; matching shell commands skip approval entirely. |
AEGMIS_PROTECTED_PATHS | — | Extra dir(s) to gate on delete and write — each dir and its subtree, cwd-resolved. Comma-separated; prefix re: for a regex against the resolved path (e.g. re:^$HOME$). $HOME and system dirs are always gated regardless. |
AEGMIS_BLOCKED_PATHS | — | Hard-deny list: rm targets matching these are blocked locally and never sent for approval. |
AEGMIS_GATED_TOOLS | per tool | Comma-separated tool names to gate. Each hook ships a sensible per-CLI default. |
* Required only when approvals are sent to the intrupt server (AEGMIS_APPROVAL=true, the default). With AEGMIS_APPROVAL=false the gate is disabled entirely and no key is needed.
Per-tool defaults and caveats are documented in each hook's README.md under hooks/<agent>-intrupt-hook/ in the agent-hooks repo (see the links in each tool below).
Supported coding tools & installation
All nine hooks live in the Aegmis/agent-hooks monorepo. Each has an installer that copies its self-contained hook, wires it into the tool's own config, and drops an .env.intrupt starter file for your credentials. Expand a tool for its exact steps. The value in the adapter badge is what the per-adapter enforcement toggles key off.
Installing from a clone (all tools)
git clone https://github.com/Aegmis/agent-hooks.git
cd agent-hooks
python3 build.py # build every port's self-contained hook
bash hooks/<agent>-intrupt-hook/install.sh # swap <agent> for your CLIOr install a single tool without cloning — expand it below for its one-line curl installer.
A request that arrives with no adapter — or one that isn't recognized — is bucketed as unknown, which is itself a valid enforcement target.
The approval request
This is the payload a coding-CLI hook sends. It's the same shape the SDK adapters use — only adapter distinguishes the source.
POST /org/{org_id}/approval
Authorization: Bearer sk_org_abc123_xxxx
Content-Type: application/json
{
"adapter": "claude", // ← which coding tool sent this
"action": "bash_command",
"tool_name": "Bash",
"tool_kwargs": { "command": "git push --force origin main" },
"message": "Claude Code wants to run a shell command",
"channel": "slack"
}The API normalizes a few hook spellings onto the canonical adapter id it stores and enforces on — claude → claude_cli, gemini → gemini_cli, amazon-q → amazon_q. Everything in the dashboard uses the canonical ids shown in the table above.
Per-adapter enforcement
Enforcement can be switched on or off per tool from the dashboard (Policies → Adapter enforcement). Roll out approvals to one CLI at a time, or keep low-risk tools running unattended while high-risk ones are gated. When enforcement is off for an adapter, its requests are auto-approved and audited without ever touching the policy engine.
1. Adapter overrideAn explicit on/off set for this exact adapter (e.g. claude_cli).
2. Unknown overrideThe setting for the "unknown" bucket, when the adapter is missing or unrecognized.
3. Org defaultThe organization-wide enforce_policies flag.
Best practices
Start with one tool, then widen
Turn on enforcement for a single high-risk CLI first. Once the approval flow feels right, extend it to the rest from the dashboard — no code changes needed.
Gate on the command, not just the tool
Coding agents mostly call one generic tool (a shell). Write command regex rules in your policies so only genuinely risky commands (force pushes, rm -rf, sudo) require approval.
Keep a catch-all
As with the SDK flow, keep an unscoped catch-all policy at priority 999 so every gated tool call is at least recorded in the audit log.
Use groups for CLI approvals
Route coding-tool approvals to a team channel (approver_type: group) rather than one person — CLI actions happen fast and often, and a group avoids a single bottleneck.
Match the adapter id exactly
claude instead of claude_cli) means the setting silently never applies to that tool.What's next
Policy Engine
Full reference for triggers, conditions, and approver routing.
Slack Setup
Wire up the Slack app that delivers CLI approval prompts.
Audit Logs
Every CLI action is recorded with its adapter for a full trail.
Policy Engine
The policy engine decides whether an approval is required and who should approve it — without changing a line of agent code. Policies live in the database, are evaluated at request time, and can be updated without redeploying your agent.
How it fits into the flow
@approval_required fires, the SDK calls POST /approval with the tool name, action, and kwargs. The approval API runs those through the policy engine before sending the Slack message. The first matching policy determines the approver; a default catch-all policy can log the event without requiring any human action.Core concepts
Priority
Policies are evaluated lowest-number-first. The first match wins. A catch-all at priority 999 covers everything else.
Triggers
A policy activates only for matching tool_name or action values. Omit both to match everything.
Conditions
Conditions inspect the tool kwargs with AND/OR/nested logic. No conditions = default/audit-only.
Policy structure
A policy is a JSON object stored in the database. This is the shape returned by the API and accepted by POST /policies/org/{org_id}.
{
"policy_id": "pol_abc123", // set by the server on creation
"name": "Large stock purchase",
"description": "Route big trades to the finance team",
"enabled": true,
// Triggers — which tool calls this policy applies to
"trigger_tool_names": ["purchase_stock"], // match on tool name
"trigger_actions": ["purchase_stock"], // match on action string
// Conditions — which kwargs values activate this policy
"conditions": {
"logic": "OR",
"rules": {
"amount": { ">": 10000 },
"quantity": { ">": 100 }
}
},
// Who should approve when this policy matches
"approver_type": "user", // "user" or "group"
"approver_id": "U012ABC", // Slack user ID or group ID
"channel": "slack", // notification channel
"priority": 100, // lower = evaluated first
"created_by": "user_xyz"
}namestrrequiredHuman-readable label shown in the dashboard and audit log.
trigger_tool_nameslist[str]optionalPolicy only applies when the tool name is in this list. Omit to match all tool names.
trigger_actionslist[str]optionalPolicy only applies when the action string is in this list. Omit to match all actions.
conditionsdict | nulloptionalCondition tree evaluated against tool kwargs. null or omitted means always-match (audit-only / catch-all).
approver_type"user" | "group"requiredWhether approver_id refers to a single Slack user ("user") or a Slack group/channel ("group").
approver_idstrrequiredSlack User ID (e.g. U012ABC) or channel ID (e.g. C012XYZ) to notify.
channelstroptionaldefault: "slack"Delivery channel. Only "slack" is fully implemented today.
priorityintoptionaldefault: 100Evaluation order. Lower number = evaluated first. Policies with the same priority are evaluated in insertion order.
enabledbooloptionaldefault: trueDisabled policies are skipped entirely during evaluation. Useful for drafting or pausing rules.
Conditions
Conditions inspect the kwargs passed to the tool. The engine supports flat AND, flat OR, arbitrarily nested AND/OR groups, and a list form that lets the same field repeat (e.g. multiple command regexes). If a field is missing from kwargs, the condition for that field evaluates to false.
Require approval only when all conditions are true — e.g. amount > $10,000 and quantity > 100.
{
"logic": "AND",
"rules": {
"amount": { ">": 10000 },
"quantity": { ">": 100 }
}
}>Greater than. Numeric comparison.
<Less than. Numeric comparison.
>=Greater than or equal.
<=Less than or equal.
==Strict equality. Works for strings, numbers, and booleans.
!=Not equal.
regexPython re.match() against the field value. Field must be a string.
inField value must be in the given list. Example: {"in": ["USD", "EUR"]}.
not_inField value must not be in the given list.
Multiple operators on the same field all apply (implicit AND within a single field):
{ "amount": { ">=": 1000, "<=": 50000 } } // 1000 ≤ amount ≤ 50000Common patterns
Exact string match
Trigger when a field equals a specific value — e.g. email sent to a specific address.
{"logic": "AND", "rules": {"to": {"==": "[email protected]"}}}Email domain match
Use regex to catch any address at a domain.
{"logic": "AND", "rules": {"to": {"regex": ".*@acme\.com$"}}}External domain (anything not internal)
Negative regex — match addresses that are NOT from your company domain.
{"logic": "AND", "rules": {"to": {"regex": "^(?!.*@mycompany\.com).*$"}}}Amount above threshold
Numeric greater-than check — e.g. payments over $10,000.
{"logic": "AND", "rules": {"amount": {">": 10000}}}Amount range (between)
Two operators on the same field — both must pass.
{"logic": "AND", "rules": {"amount": {">=": 1000, "<=": 50000}}}Allowlist of values
Field must be one of the listed values — e.g. only flag these currencies.
{"logic": "AND", "rules": {"currency": {"in": ["USD", "EUR", "GBP"]}}}Blocklist of values
Field must NOT be in the list — e.g. skip approvals for internal environments.
{"logic": "AND", "rules": {"environment": {"not_in": ["dev", "staging"]}}}Boolean flag
Exact equality works on booleans too — e.g. only when targeting production.
{"logic": "AND", "rules": {"is_production": {"==": true}}}Recipient in VIP list
Trigger when sending to any address in a fixed set.
{"logic": "AND", "rules": {"to": {"in": ["[email protected]", "[email protected]", "[email protected]"]}}}Destructive action by name pattern
Regex on the action string — catch any delete/destroy/terminate action.
{"logic": "AND", "rules": {"action": {"regex": "^(delete|destroy|terminate|drop).*"}}}Amount OR recipient (either is enough)
OR logic — approve if the amount is large OR the recipient is a VIP.
{
"logic": "OR",
"rules": {
"amount": {">": 10000},
"to": {"in": ["[email protected]", "[email protected]"]}
}
}Nested: (amount > 10k AND currency = USD) OR quantity > 500
Nested groups let you mix AND and OR logic at different levels.
{
"logic": "OR",
"rules": {
"high_value_usd": {
"logic": "AND",
"rules": {"amount": {">": 10000}, "currency": {"==": "USD"}}
},
"bulk_order": {
"logic": "AND",
"rules": {"quantity": {">": 500}}
}
}
}Command rules: two different command regexes (list form)
List-form rules let the same field repeat — gate on a git push OR a recursive delete. This is what the Command rules builder generates.
{
"logic": "OR",
"rules": [
{"field": "command", "operator": "regex", "value": "^git push"},
{"field": "command", "operator": "regex", "value": "^rm .*-rf"}
]
}Command rules: require ALL patterns (AND)
Switch logic to AND so every regex must match — e.g. a git command that is also a push.
{
"logic": "AND",
"rules": [
{"field": "command", "operator": "regex", "value": "^git"},
{"field": "command", "operator": "regex", "value": "^git push"}
]
}Dangerous shell commands (many patterns, OR)
One policy covering several risky commands — sudo, disk wipes, or piping a download straight into a shell.
{
"logic": "OR",
"rules": [
{"field": "command", "operator": "regex", "value": "^sudo "},
{"field": "command", "operator": "regex", "value": "^dd if="},
{"field": "command", "operator": "regex", "value": ".*curl.*\| *sh"}
]
}Evaluation flow
When POST /approval is received, the engine evaluates policies in this order:
Sort all enabled policies by priority (lowest first).
For each policy: check trigger_tool_names — skip if tool name not in list (if list is set).
Check trigger_actions — skip if action not in list (if list is set).
Evaluate conditions against tool_kwargs using the AND/OR logic tree.
First policy where all checks pass → return approver assignment.
If conditions is null → match immediately; set require_approval=false (audit-only).
If no policy matches → log warning, return None.
# Returned by engine.evaluate(context)
{
"policy_id": "pol_abc123",
"approver_type": "user", # "user" | "group"
"approver_id": "U012ABC", # Slack user or channel ID
"require_approval": True # False for audit-only / default policies
}Example policy sets
A realistic deployment uses a stack of policies: specific high-risk rules at low priority numbers, and a catch-all at the highest number.
[
{
// Priority 50: very large trades → senior approver
"name": "Very large trade",
"priority": 50,
"trigger_tool_names": ["purchase_stock"],
"conditions": { "logic": "AND", "rules": { "amount": { ">": 100000 } } },
"approver_type": "user",
"approver_id": "U_SENIOR_TRADER"
},
{
// Priority 100: normal large trades → finance channel
"name": "Large trade",
"priority": 100,
"trigger_tool_names": ["purchase_stock"],
"conditions": {
"logic": "OR",
"rules": { "amount": { ">": 10000 }, "quantity": { ">": 50 } }
},
"approver_type": "group",
"approver_id": "C_FINANCE_APPROVALS"
},
{
// Priority 999: everything else — log only, no block
"name": "Default audit",
"priority": 999,
"conditions": null,
"approver_type": "group",
"approver_id": "C_AUDIT_LOG"
}
]Best practices
Always have a catch-all at priority 999
If no policy matches, the engine returns None and no one is notified. A catch-all with null conditions ensures every tool call is at minimum recorded in the audit log.
Scope triggers as tightly as possible
Use both trigger_tool_names and trigger_actions together. This avoids a policy for purchase_stock accidentally firing for get_stock_price if they share an action string.
Use OR conditions for risky thresholds
Users often think in OR terms: a trade needs approval if the amount is large OR the quantity is large. AND conditions are stricter — both must be true before the policy fires.
Test conditions before production
Instantiate PolicyEngine directly in a unit test with representative kwargs and assert the correct policy_id is returned. The engine is pure Python and needs no running server.
Disable, don't delete
Disabling a policy (enabled: false) preserves the audit trail. Deleting is permanent and removes the policy from historical reports.
What's next
Quickstart
Step-by-step walkthrough for running your first approval.
SDK Reference
Complete Python SDK docs — @approval_required, ApprovalClient, hooks.
Slack Setup
Configure your Slack app and wire up the /events webhook.
Using Intrupt with Slack
Once your workspace has the Intrupt Slack app installed, you can receive approval requests, approve or reject them, and check status — all without leaving Slack. This guide walks you through setting it up and using it day-to-day.
Setting up your Slack connection
This takes about 2 minutes. You only need to do it once per account.
Make sure the Intrupt app is in your workspace
Ask your Aegmis admin whether the Intrupt Slack app has been installed in your workspace. If it hasn't, they can install it from the Aegmis dashboard under Integrations → Slack.
You'll know it's installed when…
Find your Slack User ID
Aegmis uses your Slack User ID to send you approval requests directly. Here's how to find it:
- 1Open Slack and click on your profile picture in the top-right corner.
- 2Click View profile.
- 3Click the three-dot menu (⋯) in your profile card.
- 4Click Copy member ID. It looks like
U0123ABCDEF.
Your Slack User ID looks like
U0123ABCDEF
Link your Slack ID in Aegmis
Tell Aegmis which Slack user you are so it knows where to send your approval requests.
- 1In the Aegmis dashboard, click your profile name in the bottom-left corner.
- 2Go to Account → Integrations.
- 3Under Slack, paste your Slack User ID and click Save.
Once saved, Aegmis will start routing approvals to you in Slack whenever you are set as the approver in a policy.
Choose your approval channel (admins only)
By default, approval messages are sent as a direct message to the approver. Admins can also route approvals to a shared Slack channel per policy — useful for team reviews.
- 1Go to Policies and open or create a policy.
- 2Under Approver, set the channel to a Slack channel name (e.g.
#approvals). - 3Make sure the Aegmis's Intrupt bot has been invited to that channel: in Slack, type
/invite @Aegmis.
What an approval request looks like
When an AI agent triggers an action that requires your approval, you'll receive a message like this in Slack:
Intrupt
Today at 2:34 PM
⚠️ Approval Required
Your AI agent wants to execute buy_stock
ticker: AAPL
quantity: 10
order_type: market
Policy: Stock Trading · Requested by agent-prod
Action name
The tool the agent is trying to run (e.g. buy_stock, send_email).
Arguments (kwargs)
The exact parameters the agent passed — so you know exactly what it will do.
Policy & requester
Which policy triggered this and which agent made the request.
Approve / Reject
One click. The agent is immediately resumed or stopped. No login needed.
After you decide
Slash commands
You can also manage approvals directly from any Slack message box using these commands:
/intrupts[pending | approved | rejected | all]List your workspace's approval requests, newest first. Defaults to pending; add a status option to filter, or 'all' for everything.
/intrupts approved/intrupt-status<approval_id>Get the full details of a specific approval by its ID: status, action, tool, arguments, and who decided it (and when).
/intrupt-status 02ee6ecc-9561-4897-9026-72d9782d4cfa/intrupt-helpShow all available Intrupt slash commands and how to use them.
/intrupt-helpCommon questions
I'm not receiving approval messages in Slack
Make sure your Slack User ID is saved in Account → Integrations. Also check that the policy's approver is set to your user and the Aegmis bot is in the channel.
Can I approve on mobile?
Yes. The Approve and Reject buttons work in the Slack mobile app exactly the same as on desktop.
What happens if I don't respond in time?
The agent waits until a decision is made. There is no automatic timeout unless your admin has configured one on the policy. You'll keep receiving reminders if your admin has set that up.
Can multiple people approve the same request?
Only the first click counts. Once someone clicks Approve or Reject, the Slack message updates and the decision is final. Other team members can see who decided in the Audit Log.
Can I add a reason when rejecting?
Reason capture on reject is on our roadmap. For now, rejections are logged with the approver's identity and timestamp.
What's next
Quickstart
End-to-end walkthrough of the full approval flow.
Policy Engine
Learn how to control who gets notified and when.
Audit Logs
See every decision with full context and history.
Audit Logs
Every approval decision, policy change, and membership update is automatically recorded in your organization's audit log. This gives you a complete, searchable history of everything that happened — who did it, what they did, and when.
Recent activity
Your audit log in the dashboard looks like this. Click any row to see the full details — tool arguments, decision reason, policy matched, and more.
Approved buying 10 shares of AAPL via Stock Trading policy
New policy created with Slack approver routing
Rejected deletion of customer record ID #8821
New member invited to the organization
Agent approved to send campaign email to 142 recipients
How to access your audit log
From the sidebar
Click "Audit" in the left navigation. You'll see all events for your organization, most recent first.
Search
Type in the search box to filter by action name, actor email, resource, or any detail text.
Filter by status
Use the All / Success / Failed buttons to narrow down to events of interest — e.g. click "Failed" to see all rejected approvals at a glance.
Click a row for full detail
A side panel opens showing the exact tool arguments the agent passed, the policy that matched, the message, and the reason for the decision.
What gets recorded
Aegmis automatically captures all of these events — no configuration needed.
Approval Approved
A human clicked Approve on an agent request. The agent continued executing.
Approval Rejected
A human clicked Reject. The agent stopped cleanly without running the action.
Policy Created / Updated / Deleted
An admin made a change to an approval policy.
User Created / Updated / Deleted
A member was added to or removed from the organization.
API Key Created / Revoked
An API key was issued or revoked for agent authentication.
Login Attempt
A sign-in was recorded. Failed login attempts are also captured.
What the status badges mean
Each log entry has a status badge so you can spot issues at a glance.
The action completed normally — approval was given, or the change was saved without issues.
Something went wrong, or the approval was actively rejected. The agent did not proceed.
The action completed but with a non-critical issue, such as the agent callback being unreachable.
Decisions are final
Who can see what
- Full audit log — every event across the entire organization
- Can filter by actor, status, or event type
- Sees all approval decisions made by any team member
- Can view policy and user management history
- Only sees events related to their own activity
- Can view their own approval decisions in the dashboard
- Cannot see other members' actions or policy changes
How long logs are kept
Audit log retention depends on your plan. Older records are automatically removed.
7 days
retention
90 days
retention
Unlimited
retention
Need longer retention? Contact us about the Enterprise plan.
What's next
Quickstart
See a full end-to-end approval flow in action.
Policy Engine
Learn how to control who gets notified for each action.
Slack Setup
Approve and track requests without leaving Slack.
Keys & Credentials
Everything you need to authenticate your agent with the approval platform. The SDK reads these values from environment variables — you rarely need to pass them explicitly.
API key
Your API key identifies your agent to the approval platform and scopes all activity to your organisation. Get it from the dashboard under Account → API Keys.
AEGMIS_API_KEY=sk_org_abc123_xxxxxxxxxxxxxxxxsk_org_Fixed prefix that identifies this as an organisation-scoped API key.
{org_id}Your organisation identifier (e.g. org_abc123). The SDK extracts this automatically — you never pass org_id separately.
_{hash}Cryptographic suffix that authenticates the key. Treat this like a password.
SDK handles the header automatically
AEGMIS_API_KEY in your environment, ApprovalMiddleware picks it up and injects the Authorization: Bearer ... header on every request. You only need to pass it explicitly if you're constructing ApprovalClient programmatically.Base URL
Points the SDK at the approval platform. Use the cloud URL for production. A local override is only needed when running the approval API yourself during development.
https://api.aegmis.com# Production
AEGMIS_BASE_URL=https://api.aegmis.comAgent resume secret
Authenticates inbound callbacks from the approval platform to your agent's /resume endpoint. This secret lives only in your agent's environment — the approval platform never reads it from its own configuration; instead, your agent passes it per-request when creating an approval, and the platform echoes it back in the X-Agent-Secret header on callbacks.
python -c "import secrets; print(secrets.token_hex(32))"AGENT_RESUME_SECRET=your_64_char_hex_value_hereNever share this with the approval platform
AGENT_RESUME_SECRET must only exist in your agent's .env. Do not add it to any cloud-side configuration. The whole point is that the cloud cannot forge a callback — it can only echo back the value your agent sent in the original approval request.Complete .env reference
These are the only environment variables your agent needs. Everything else (Slack tokens, signing secrets) is configured once in the cloud platform, not in your agent.
# Required
AEGMIS_BASE_URL=https://api.aegmis.com
AEGMIS_API_KEY=sk_org_abc123_xxxxxxxxxxxxxxxx
# Approvals — ON by default (real human approval); set false to auto-approve
AEGMIS_APPROVAL=true
# Strongly recommended for production
AGENT_RESUME_SECRET=your_64_char_hex_value_here
AGENT_PUBLIC_URL=https://your-agent.example.com
# Your LLM provider
OPENAI_API_KEY=sk-...
# or
ANTHROPIC_API_KEY=sk-ant-...AEGMIS_BASE_URLrequiredURL of the approval platform. Use https://api.aegmis.com for production.
AEGMIS_API_KEYrequiredYour API key from the dashboard. Scopes all activity to your organisation.
AEGMIS_APPROVALrecommendedMaster switch. Enabled by default → send approval requests to the backend for a real human decision. Set false to auto-approve without calling the backend.
AGENT_RESUME_SECRETrecommendedSecret that authenticates inbound /resume callbacks. Strongly recommended in production.
AGENT_PUBLIC_URLrecommendedPublic base URL of your agent. Used to construct the /resume callback URL sent to the platform.
Key rotation
Rotate AEGMIS_API_KEY
Go to the dashboard → Account → API Keys → Revoke and generate a new key. Update your agent's .env and redeploy. The old key stops working immediately on revocation.
Rotate AGENT_RESUME_SECRET
Generate a new value with python -c "import secrets; print(secrets.token_hex(32))", update your .env, and redeploy. In-flight approvals created before the rotation will call /resume with the old secret — they will fail validation. Re-trigger those requests after the rotation.
What's next
Quickstart
End-to-end walkthrough of your first approval.
SDK Reference
Python SDK — decorator, client methods, hooks.
Audit Logs
What's captured and how to query approval history.
Ready to add HITL to your agent?
Create a free account and get your API key in under a minute.