Skip to main content
Many agent scenarios require human oversight before executing high-risk operations. This guide shows how to implement human-in-the-loop patterns with Noēsis.

When to use human approval

Add human approval when:
  • Actions are irreversible (deletes, deployments, financial transactions)
  • Risk scores exceed thresholds
  • Policies flag operations as requiring review
  • Regulations demand audit trails with human sign-off

Basic approval pattern

The simplest pattern pauses execution and waits for approval:
import noesis as ns


def require_approval(episode_id: str, action: str) -> bool:
    """
    In production, replace with:
    - Slack interactive message
    - ServiceNow approval workflow
    - Custom approval UI
    """
    print(f"\n⚠️  Approval required for episode {episode_id}")
    print(f"   Action: {action}")
    response = input("   Approve? (y/n): ")
    return response.lower() == "y"


def run_with_approval(task: str, high_risk: bool = False):
    """Run an episode with optional approval gate."""
    
    # First run to check if approval needed
    episode_id = ns.run(
        task,
        intuition=True,
        tags={"high_risk": high_risk},
    )
    
    # Check events for approval requirement
    events = list(ns.events.read(episode_id))
    needs_approval = any(
        e.get("payload", {}).get("requires_approval")
        for e in events
    )
    
    if needs_approval:
        summary = ns.summary.read(episode_id)
        proposed_action = summary.get("flags", {}).get("proposed_action", task)
        
        if require_approval(episode_id, proposed_action):
            # Re-run with approval flag
            episode_id = ns.run(
                f"{task} [APPROVED]",
                intuition=True,
                tags={"approved": True, "original_episode": episode_id},
            )
        else:
            print("❌ Action rejected by human reviewer")
            return None
    
    return episode_id

Policy-driven approval

Create a policy that flags operations for approval:
import noesis as ns
from noesis.intuition import IntuitionEvent


class ApprovalPolicy(ns.DirectedIntuition):
    """Policy that requires approval for high-risk operations."""
    
    __version__ = "1.0"
    
    HIGH_RISK_KEYWORDS = {"delete", "drop", "truncate", "deploy", "rollback"}
    
    def advise(self, state: dict) -> IntuitionEvent | None:
        task = state.get("task", "").lower()
        
        # Check for high-risk keywords
        if any(kw in task for kw in self.HIGH_RISK_KEYWORDS):
            return self.intervene(
                advice="High-risk operation detected. Requiring human approval.",
                patch={
                    "requires_approval": True,
                    "risk_reason": "high_risk_keyword",
                },
                target="plan",
                rationale="Operations matching high-risk patterns need review.",
            )
        
        # Check for production environment
        if "production" in task or state.get("environment") == "production":
            return self.intervene(
                advice="Production operation detected. Requiring human approval.",
                patch={
                    "requires_approval": True,
                    "risk_reason": "production_environment",
                },
                target="plan",
                rationale="All production changes require human sign-off.",
            )
        
        return None

Slack integration

Send approval requests to Slack:
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
import noesis as ns


class SlackApproval:
    """Handle approvals via Slack interactive messages."""
    
    def __init__(self, channel: str):
        self.client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
        self.channel = channel
        self.pending = {}  # episode_id -> message_ts
    
    def request_approval(self, episode_id: str, action: str, context: dict) -> str:
        """Send approval request to Slack."""
        blocks = [
            {
                "type": "header",
                "text": {"type": "plain_text", "text": "🔒 Approval Required"},
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*Episode:* `{episode_id}`\n*Action:* {action}",
                },
            },
            {
                "type": "context",
                "elements": [
                    {"type": "mrkdwn", "text": f"*Reason:* {context.get('reason', 'Policy requirement')}"},
                ],
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "✅ Approve"},
                        "style": "primary",
                        "action_id": f"approve_{episode_id}",
                    },
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "❌ Reject"},
                        "style": "danger",
                        "action_id": f"reject_{episode_id}",
                    },
                ],
            },
        ]
        
        try:
            response = self.client.chat_postMessage(
                channel=self.channel,
                blocks=blocks,
                text=f"Approval required for {episode_id}",
            )
            self.pending[episode_id] = response["ts"]
            return response["ts"]
        except SlackApiError as e:
            raise RuntimeError(f"Slack API error: {e.response['error']}")
    
    def handle_response(self, episode_id: str, approved: bool, user: str):
        """Handle approval response from Slack."""
        if episode_id in self.pending:
            # Update the message
            status = "✅ Approved" if approved else "❌ Rejected"
            self.client.chat_update(
                channel=self.channel,
                ts=self.pending[episode_id],
                text=f"{status} by {user}",
            )
            del self.pending[episode_id]
        
        return approved


# Usage
slack = SlackApproval(channel="#approvals")

def run_with_slack_approval(task: str):
    episode_id = ns.run(task, intuition=ApprovalPolicy())
    
    # Check if approval needed
    events = list(ns.events.read(episode_id))
    approval_event = next(
        (e for e in events if e.get("payload", {}).get("requires_approval")),
        None,
    )
    
    if approval_event:
        slack.request_approval(
            episode_id,
            task,
            {"reason": approval_event["payload"].get("risk_reason")},
        )
        return {"status": "pending_approval", "episode_id": episode_id}
    
    return {"status": "completed", "episode_id": episode_id}

Async approval workflow

For production systems, use an async workflow:
import asyncio
from dataclasses import dataclass
from enum import Enum
import noesis as ns


class ApprovalStatus(Enum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"
    EXPIRED = "expired"


@dataclass
class ApprovalRequest:
    episode_id: str
    action: str
    context: dict
    status: ApprovalStatus = ApprovalStatus.PENDING
    approver: str | None = None


class ApprovalQueue:
    """Async approval queue with timeout support."""
    
    def __init__(self, timeout_seconds: int = 3600):
        self.requests: dict[str, ApprovalRequest] = {}
        self.timeout = timeout_seconds
        self._events: dict[str, asyncio.Event] = {}
    
    async def request_approval(self, episode_id: str, action: str, context: dict) -> ApprovalRequest:
        """Submit an approval request and wait for response."""
        request = ApprovalRequest(episode_id, action, context)
        self.requests[episode_id] = request
        self._events[episode_id] = asyncio.Event()
        
        # Send to your approval channel (Slack, email, etc.)
        await self._notify_approvers(request)
        
        # Wait for response with timeout
        try:
            await asyncio.wait_for(
                self._events[episode_id].wait(),
                timeout=self.timeout,
            )
        except asyncio.TimeoutError:
            request.status = ApprovalStatus.EXPIRED
        
        return request
    
    async def approve(self, episode_id: str, approver: str):
        """Mark request as approved."""
        if episode_id in self.requests:
            self.requests[episode_id].status = ApprovalStatus.APPROVED
            self.requests[episode_id].approver = approver
            self._events[episode_id].set()
    
    async def reject(self, episode_id: str, approver: str):
        """Mark request as rejected."""
        if episode_id in self.requests:
            self.requests[episode_id].status = ApprovalStatus.REJECTED
            self.requests[episode_id].approver = approver
            self._events[episode_id].set()
    
    async def _notify_approvers(self, request: ApprovalRequest):
        """Send notification to approvers."""
        # Implement your notification logic here
        print(f"Approval requested: {request.episode_id}")


# Usage
queue = ApprovalQueue(timeout_seconds=300)


async def run_async_approval(task: str):
    episode_id = ns.run(task, intuition=ApprovalPolicy())
    
    events = list(ns.events.read(episode_id))
    if any(e.get("payload", {}).get("requires_approval") for e in events):
        request = await queue.request_approval(episode_id, task, {})
        
        if request.status == ApprovalStatus.APPROVED:
            # Continue with approved action
            return ns.run(f"{task} [APPROVED by {request.approver}]", intuition=True)
        elif request.status == ApprovalStatus.EXPIRED:
            print("Approval timed out")
            return None
        else:
            print(f"Rejected by {request.approver}")
            return None
    
    return episode_id

Recording approval in events

The approval decision should be captured in the event timeline:
import noesis as ns


def record_approval(episode_id: str, approved: bool, approver: str):
    """Record approval decision in a new episode that references the original."""
    
    summary = ns.summary.read(episode_id)
    original_task = summary.get("task", "")
    
    # Create a linked episode with the decision
    approval_episode = ns.run(
        f"Human review of: {original_task}",
        intuition=False,
        tags={
            "type": "approval",
            "original_episode": episode_id,
            "approved": approved,
            "approver": approver,
        },
    )
    
    return approval_episode

UI for approvals

Build a simple approval UI with Gradio:
import gradio as gr
import noesis as ns


def get_pending_approvals():
    """Get episodes awaiting approval."""
    episodes = ns.list_runs(limit=50)
    pending = []
    
    for ep in episodes:
        events = list(ns.events.read(ep["episode_id"]))
        if any(e.get("payload", {}).get("requires_approval") for e in events):
            summary = ns.summary.read(ep["episode_id"])
            if not summary.get("tags", {}).get("approved"):
                pending.append({
                    "episode_id": ep["episode_id"],
                    "task": summary.get("task", ""),
                    "timestamp": ep["timestamp"],
                })
    
    return pending


def approve_episode(episode_id: str, approver: str):
    """Approve an episode."""
    ns.run(
        f"[APPROVED] {ns.summary.read(episode_id).get('task', '')}",
        tags={"approved": True, "approver": approver, "original": episode_id},
    )
    return f"✅ Approved by {approver}"


def reject_episode(episode_id: str, approver: str, reason: str):
    """Reject an episode."""
    ns.run(
        f"[REJECTED] {ns.summary.read(episode_id).get('task', '')}",
        tags={"rejected": True, "approver": approver, "reason": reason, "original": episode_id},
    )
    return f"❌ Rejected by {approver}: {reason}"


# Build Gradio interface
with gr.Blocks(title="Approval Queue") as app:
    gr.Markdown("# 🔐 Approval Queue")
    
    with gr.Row():
        refresh_btn = gr.Button("Refresh")
        pending_list = gr.Dataframe(headers=["Episode ID", "Task", "Timestamp"])
    
    with gr.Row():
        episode_input = gr.Textbox(label="Episode ID")
        approver_input = gr.Textbox(label="Your name")
        reason_input = gr.Textbox(label="Rejection reason (if rejecting)")
    
    with gr.Row():
        approve_btn = gr.Button("✅ Approve", variant="primary")
        reject_btn = gr.Button("❌ Reject", variant="stop")
    
    result = gr.Textbox(label="Result")
    
    refresh_btn.click(
        lambda: [[p["episode_id"], p["task"], p["timestamp"]] for p in get_pending_approvals()],
        outputs=pending_list,
    )
    approve_btn.click(approve_episode, inputs=[episode_input, approver_input], outputs=result)
    reject_btn.click(reject_episode, inputs=[episode_input, approver_input, reason_input], outputs=result)


if __name__ == "__main__":
    app.launch()

Best practices

Set reasonable timeouts. Pending approvals shouldn’t block indefinitely—expire them after a reasonable period.
Authenticate approvers. In production, verify the identity of approvers through SSO or similar.
Create audit trails. Record all approval decisions with timestamps, approvers, and reasons in the event timeline.

Next steps