Documentation

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

  1. 1Agent LLM decides to call a tool decorated with @approval_required
  2. 2Decorator calls gate.request_approval(client, session_id, payload) — tool suspends on a Future
  3. 3on_approval_async / ApprovalClient.acreate_approval sends the notification
  4. 4Human receives a Slack message, email, Telegram message, or custom notification
  5. 5Human clicks Approve or Reject
  6. 6gate.resolve(approval_id, approved=True/False) unblocks the Future
  7. 7Tool body executes (approved) or returns {"status": "cancelled"} (rejected)

Prerequisites

Before installing, confirm you have the following:

Python 3.11+
3.11 recommended
LangGraph ≥ 0.2
StateGraph, interrupt(), Command
Aegmis API key
From Account → API Keys
httpx ≥ 0.27
Installed automatically with the SDK
Aegmis account
Sign up to get an org and API key
Slack app
Optional — only for the Slack channel

Installation & setup

1

Install the SDK

Install the SDK using your preferred package manager.

terminal
pip install intrupt-py-sdk
2

Set environment variables

Create a .env file in your project root. The SDK reads these automatically via python-dotenv.

.env
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 callbacks

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

python
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

python
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

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

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

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

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

python
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 8087

Blocks on a y/n prompt in the terminal. Perfect for local dev and CLI agents.

python
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 8088

Auto-approve low-risk actions, auto-reject blocked vendors, escalate the rest.

python
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 8089

Sends an HTML email with Approve / Reject links.

python
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 8090

Posts an interactive Block Kit message with Approve / Reject buttons.

python
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 8091

Sends an inline keyboard via the Telegram Bot API.

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

python
@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}
channelWhat 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

  1. 1Agent sends POST /org/{id}/approval with channel="email"
  2. 2Aegmis API resolves approver email(s) from the matching policy (user DM or active group members)
  3. 3Resend delivers a branded HTML email showing tool name, action, message, and arguments
  4. 4Approver clicks Approve or Reject in their inbox — no login required
  5. 5Aegmis API verifies the HMAC signature, records the decision, and POSTs to your /resume endpoint
  6. 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:

python
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 clicks

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

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

python
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/.

FilePortChannelFramework
agent.py8081intrupt API → SlackLangGraph
console_agent.py8087Interactive stdinLangGraph
policy_agent.py8088Rule-based engineLangGraph
smtp_email_agent.py8089SMTP emailLangGraph
slack_direct_agent.py8090Slack Block KitLangGraph
telegram_agent.py8091Telegram BotLangGraph
google_adk_agent.py8092intrupt API → SlackGoogle ADK
openai_agents_agent.py8093intrupt API → SlackOpenAI Agents SDK
crewai_agent.py8094intrupt API → SlackCrewAI
resend_email_agent.py8095intrupt API → EmailLangGraph
ReferencePython SDK

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.

bash
pip install intrupt-py-sdk

# Full agent example also needs LangChain OpenAI:
pip install intrupt-py-sdk langchain-openai
Required packages
langgraph>=0.2packageoptional

Included in intrupt-py-sdk dependencies. Provides StateGraph, ToolNode, MemorySaver.

langchain-core>=0.3packageoptional

Provides @tool, BaseMessage, add_messages.

langchain-openai>=0.2packageoptional

Provides ChatOpenAI. Only needed when using OpenAI models in the agent.

Environment variables
AEGMIS_BASE_URLenvrequired

URL of the intrupt approval API (e.g. http://localhost:8080).

AEGMIS_API_KEYenvrequired

API key from your intrupt dashboard.

AEGMIS_APPROVALenvrequired

Enabled by default → send approval requests to the backend for a human decision. Set false to auto-approve.

AGENT_PUBLIC_URLenvrequired

Public URL of your agent server — used as the callback base for /resume.

AGENT_RESUME_SECRETenvoptional

Random secret echoed as X-Agent-Secret on /resume callbacks for authentication.

OPENAI_API_KEYenvrequired

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

python
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
)
Constructor parameters
base_urlstrrequired

Base URL of the approval API. Read from AEGMIS_BASE_URL if not passed.

api_keystrrequired

API key used in the Authorization header. Read from AEGMIS_API_KEY if not passed.

timeoutfloatoptionaldefault: 30.0

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

python
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"])   # uuid
Parameters
thread_idstrrequired

LangGraph thread ID (or any string that identifies the paused conversation checkpoint).

actionstrrequired

Short identifier for the action, shown in the approval UI and audit log.

messagestrrequired

Human-readable description of what the agent wants to do.

channelstroptionaldefault: "slack"

Delivery channel. Only "slack" is fully implemented today.

tooldictoptional

Optional dict with "name" and "kwargs" keys. Shown in the Slack message so approvers see exactly what will run.

agent_callback_urlstroptional

URL the approval API will POST to when the human decides. Should point to your agent's /resume endpoint.

agent_callback_secretstroptional

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

python
# 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}")
Built-in events
approval.createdeventoptional

Fired after create_approval returns. Payload includes approval_id, action, channel.

approval.decidedeventoptional

Fired when the SDK receives the resume callback. Payload includes approval_id and approved (bool).

approval.erroreventoptional

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

python
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()
Class methods
ApprovalMiddleware.get_client()ApprovalClientoptional

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

python
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,
)
Constructor parameters
graphCompiledGraphrequired

A 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_asynccallableoptional

Async callback (thread_id, payload) -> {"approval_id": "..."}. Use instead of the HTTP API for inline channels (console, email, Slack, policy engine).

timeoutfloatoptionaldefault: 1.5

Seconds to wait for an approval gate to fire before returning pending_approval. Set higher if your LLM or tool startup is slow.

clientApprovalClientoptional

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

python
@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": {...}}
config merging

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.

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.

python
@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.

python
@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
    )
Parameters
thread_idstrrequired

The thread to resume. Must match the thread_id from the original invoke() response.

approvedboolrequired

True to continue tool execution, False to return a cancellation result to the LLM.

approval_idstroptional

Approval record ID from the original pending_approval response. Passed back into the graph as resume context.

configdictoptional

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

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

python
# 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",
)
Parameters
thread_idstrrequired

Thread 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().

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

python
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).

python
@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 result

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

python
@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.

python
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}
Decorator parameters
actionstrrequired

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

messagestrrequired

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

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.

agent.py
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.

Exception types
ApprovalErrorexceptionoptional

Base class for all SDK exceptions. Raised when the approval API returns a non-2xx status.

ApprovalTimeoutErrorexceptionoptional

Raised when an approval request exceeds the configured timeout (not yet shipped — timeouts are planned for v0.2).

ApprovalRejectedErrorexceptionoptional

Optionally raised by the decorator when the human rejects. By default the decorator returns a cancellation dict instead of raising.

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

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

Environment variables
AEGMIS_OTLP_ENDPOINTenvoptional

Aegmis Observability endpoint (e.g. https://obs.aegmis.com). Pass it to observability.init(); unset → observability is off.

AEGMIS_ENABLE_TRACINGenvoptionaldefault: true

Set false to disable all observability emission even when init() is called. Has no effect on approvals themselves.

Security quick-reference

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.

ReferenceJavaScript / TypeScript SDK

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.

terminal
npm i intrupt-js-sdk@alpha

Currently 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:

Adapters & peer dependencies
Vercel AI SDKintrupt-js-sdk/vercel

peer dep: ai

OpenAI Agents (JS)intrupt-js-sdk/openai-agents

peer dep: @openai/agents

Mastraintrupt-js-sdk/mastra

peer dep: @mastra/core

LangChain.js toolsintrupt-js-sdk/langchain

peer dep: @langchain/core

LangGraph.jsintrupt-js-sdk/langgraph

peer dep: @langchain/langgraph

Environment variables

AEGMIS_APPROVALenvoptionaldefault: true

Master 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_URLenvrequired

Base URL of the approval API (e.g. https://api.aegmis.com or http://localhost:8080). Required when approvals are enabled (the default).

AEGMIS_API_KEYenvrequired

Org API key, format sk_org_{org_id}_{hash}. Required when approvals are enabled (the default).

AGENT_RESUME_SECRETenvoptional

Shared 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:8081

Public URL of your agent. The optional createApprovalServer derives its listen port from it; also handy for building the runner's callbackUrl.

AEGMIS_OTLP_ENDPOINTenvoptional

Aegmis 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: true

Set 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)

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

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

typescript
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:

typescript
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:

typescript
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:

typescript
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

bash
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:

bash
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 onApprovalAsync to a runner → decide in-process (a rule, a console prompt, a DB lookup) and resolve the gate yourself.
typescript
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.

.env
AEGMIS_OTLP_ENDPOINT=https://obs.aegmis.com   # unset → observability is off
# AEGMIS_ENABLE_TRACING=false                # kill switch, default true

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

GuideCoding CLI Tools

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.

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.

1

The coding agent is about to run a tool (e.g. a shell command or a file write).

2

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.

3

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.

4

The approval API checks per-adapter enforcement, runs the policy engine, and notifies the approver via Slack or email (or auto-approves and audits).

5

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.

VariableDefaultPurpose
AEGMIS_BASE_URLapi.aegmis.comintrupt API base URL. Defaults to https://api.aegmis.com — override for self-hosted deployments.
AEGMIS_API_KEYrequired*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_APPROVALtrueMaster kill switch. false disables the gate entirely (allow all, no API calls).
AEGMIS_FORWARD_ALLfalsefalse (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_CHANNELslackWhere approval requests are delivered — slack or email.
AEGMIS_TIMEOUT600Max seconds to wait for a human decision. On timeout the action is blocked (fail-closed).
AEGMIS_POLL_INTERVAL5How often (in seconds) the hook polls the API for the decision.
AEGMIS_ENV_FILE<hook dir>/.envPath to the .env file the hook auto-loads at startup.
AEGMIS_BYPASS_PATTERNSComma-separated regex list; matching shell commands skip approval entirely.
AEGMIS_PROTECTED_PATHSExtra 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_PATHSHard-deny list: rm targets matching these are blocked locally and never sent for approval.
AEGMIS_GATED_TOOLSper toolComma-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)

terminal
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 CLI

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

http — CLI hook → approval API
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.

Resolution order (first match wins)
1. Adapter override

An explicit on/off set for this exact adapter (e.g. claude_cli).

2. Unknown override

The setting for the "unknown" bucket, when the adapter is missing or unrecognized.

3. Org default

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

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.

GuidePolicy Engine

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.

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

json
{
  "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"
}
Field reference
namestrrequired

Human-readable label shown in the dashboard and audit log.

trigger_tool_nameslist[str]optional

Policy only applies when the tool name is in this list. Omit to match all tool names.

trigger_actionslist[str]optional

Policy only applies when the action string is in this list. Omit to match all actions.

conditionsdict | nulloptional

Condition tree evaluated against tool kwargs. null or omitted means always-match (audit-only / catch-all).

approver_type"user" | "group"required

Whether approver_id refers to a single Slack user ("user") or a Slack group/channel ("group").

approver_idstrrequired

Slack User ID (e.g. U012ABC) or channel ID (e.g. C012XYZ) to notify.

channelstroptionaldefault: "slack"

Delivery channel. Only "slack" is fully implemented today.

priorityintoptionaldefault: 100

Evaluation order. Lower number = evaluated first. Policies with the same priority are evaluated in insertion order.

enabledbooloptionaldefault: true

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

json — conditions
{
  "logic": "AND",
  "rules": {
    "amount":   { ">": 10000 },
    "quantity": { ">": 100 }
  }
}
Comparison operators
>

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.

regex

Python re.match() against the field value. Field must be a string.

in

Field value must be in the given list. Example: {"in": ["USD", "EUR"]}.

not_in

Field value must not be in the given list.

Multiple operators on the same field all apply (implicit AND within a single field):

json
{ "amount": { ">=": 1000, "<=": 50000 } }   // 1000 ≤ amount ≤ 50000

Common patterns

Exact string match

Trigger when a field equals a specific value — e.g. email sent to a specific address.

json
{"logic": "AND", "rules": {"to": {"==": "[email protected]"}}}

Email domain match

Use regex to catch any address at a domain.

json
{"logic": "AND", "rules": {"to": {"regex": ".*@acme\.com$"}}}

External domain (anything not internal)

Negative regex — match addresses that are NOT from your company domain.

json
{"logic": "AND", "rules": {"to": {"regex": "^(?!.*@mycompany\.com).*$"}}}

Amount above threshold

Numeric greater-than check — e.g. payments over $10,000.

json
{"logic": "AND", "rules": {"amount": {">": 10000}}}

Amount range (between)

Two operators on the same field — both must pass.

json
{"logic": "AND", "rules": {"amount": {">=": 1000, "<=": 50000}}}

Allowlist of values

Field must be one of the listed values — e.g. only flag these currencies.

json
{"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.

json
{"logic": "AND", "rules": {"environment": {"not_in": ["dev", "staging"]}}}

Boolean flag

Exact equality works on booleans too — e.g. only when targeting production.

json
{"logic": "AND", "rules": {"is_production": {"==": true}}}

Recipient in VIP list

Trigger when sending to any address in a fixed set.

json
{"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.

json
{"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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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:

1

Sort all enabled policies by priority (lowest first).

2

For each policy: check trigger_tool_names — skip if tool name not in list (if list is set).

3

Check trigger_actions — skip if action not in list (if list is set).

4

Evaluate conditions against tool_kwargs using the AND/OR logic tree.

5

First policy where all checks pass → return approver assignment.

6

If conditions is null → match immediately; set require_approval=false (audit-only).

7

If no policy matches → log warning, return None.

python — engine result
# 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.

json — three-policy stack
[
  {
    // 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.

IntegrationSlack

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.

Get notified
When an AI agent needs approval, you receive a Slack message instantly in your configured channel.
One-click decision
Click Approve or Reject directly in the Slack message. No login required.
Fully audited
Every decision is logged with who approved, when, and what the agent was trying to do.

Setting up your Slack connection

This takes about 2 minutes. You only need to do it once per account.

1

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.

2

Find your Slack User ID

Aegmis uses your Slack User ID to send you approval requests directly. Here's how to find it:

  1. 1Open Slack and click on your profile picture in the top-right corner.
  2. 2Click View profile.
  3. 3Click the three-dot menu (⋯) in your profile card.
  4. 4Click Copy member ID. It looks like U0123ABCDEF.

Your Slack User ID looks like

U0123ABCDEF

3

Link your Slack ID in Aegmis

Tell Aegmis which Slack user you are so it knows where to send your approval requests.

  1. 1In the Aegmis dashboard, click your profile name in the bottom-left corner.
  2. 2Go to Account → Integrations.
  3. 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.

4

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.

  1. 1Go to Policies and open or create a policy.
  2. 2Under Approver, set the channel to a Slack channel name (e.g. #approvals).
  3. 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 · #approvals

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.

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.

Example/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).

Example/intrupt-status 02ee6ecc-9561-4897-9026-72d9782d4cfa
/intrupt-help

Show all available Intrupt slash commands and how to use them.

Example/intrupt-help

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

ComplianceAudit Logs

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.

Search logs…
AllSuccessFailed
Approval Approvedbuy_stock

Approved buying 10 shares of AAPL via Stock Trading policy

Policy CreatedEmail Sender Guard

New policy created with Slack approver routing

Approval Rejecteddelete_record

Rejected deletion of customer record ID #8821

User Created[email protected]

New member invited to the organization

Approval Approvedsend_email

Agent approved to send campaign email to 142 recipients

Click any row to see full details →

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.

success

The action completed normally — approval was given, or the change was saved without issues.

failure

Something went wrong, or the approval was actively rejected. The agent did not proceed.

warning

The action completed but with a non-critical issue, such as the agent callback being unreachable.

Who can see what

Admin
  • 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
Member
  • 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.

Starter

7 days

retention

Team

90 days

retention

Enterprise

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.

ReferenceKeys & Credentials

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.

.env
AEGMIS_API_KEY=sk_org_abc123_xxxxxxxxxxxxxxxx
Key format
sk_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.

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
.env
# Production
AEGMIS_BASE_URL=https://api.aegmis.com

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

terminal — generate a secure value
python -c "import secrets; print(secrets.token_hex(32))"
.env
AGENT_RESUME_SECRET=your_64_char_hex_value_here

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.

.env — 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-...
Variable reference
AEGMIS_BASE_URLrequired

URL of the approval platform. Use https://api.aegmis.com for production.

AEGMIS_API_KEYrequired

Your API key from the dashboard. Scopes all activity to your organisation.

AEGMIS_APPROVALrecommended

Master 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_SECRETrecommended

Secret that authenticates inbound /resume callbacks. Strongly recommended in production.

AGENT_PUBLIC_URLrecommended

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