Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cookbook/05_agent_os/interfaces/slack/hitl_approval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Slack HITL Approval Demo

An agent with a tool that requires user confirmation before executing.
When the agent calls the tool, Slack shows Approve/Reject buttons.
The agent only proceeds after the user clicks Approve.

Setup:
1. Set env vars: SLACK_TOKEN, SLACK_SIGNING_SECRET, OPENAI_API_KEY
2. Configure Slack app with:
- Event Subscriptions: <your_url>/slack/events
- Interactivity Request URL: <your_url>/slack/interactions
- Bot scopes: app_mentions:read, assistant:write, chat:write, im:history, im:read, im:write
3. Run: .venvs/demo/bin/python cookbook/05_agent_os/interfaces/slack/hitl_approval.py
4. Start ngrok: ngrok http 7778
5. DM the bot: "send an email to alice@example.com saying hello"
6. Bot will show an approval card with Approve/Reject buttons
"""

from agno.agent import Agent
from agno.approval import approval
from agno.models.openai import OpenAIChat
from agno.os.app import AgentOS
from agno.os.interfaces.slack import Slack
from agno.tools import tool


@approval
@tool(
description="Send an email to the specified recipient. This is an irreversible action."
)
def send_email(to: str, subject: str, body: str) -> str:
return f"Email sent to {to} with subject '{subject}'"


@tool(description="Draft an email without sending it.")
def draft_email(to: str, subject: str, body: str) -> str:
return f"Draft created for {to}: subject='{subject}', body='{body[:50]}...'"


agent = Agent(
name="Email Assistant",
model=OpenAIChat(id="gpt-4o-mini"),
tools=[send_email, draft_email],
instructions=[
"You are an email assistant.",
"When asked to send an email, use the send_email tool.",
"When asked to draft, use the draft_email tool.",
],
tool_call_limit=5,
)

agent_os = AgentOS(
agents=[agent],
interfaces=[
Slack(
agent=agent,
reply_to_mentions_only=False,
),
],
)
app = agent_os.get_app()

if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=7777)
128 changes: 128 additions & 0 deletions libs/agno/agno/os/interfaces/slack/blocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Block Kit message builders for interactive Slack components.

Builds structured Block Kit payloads for approval cards, user input forms,
and status updates. These complement the streaming task-card UX with
interactive elements (buttons, modals).
"""

from __future__ import annotations

import json
from typing import Any, Dict, List, Optional

from agno.models.response import ToolExecution


def approval_blocks(
tools: List[ToolExecution],
approval_id: str,
agent_name: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Build Block Kit blocks for a tool approval request.

Shows what the agent wants to do and presents Approve/Reject buttons.
"""
header = f"*{agent_name}* needs your approval" if agent_name else "Approval needed"

blocks: List[Dict[str, Any]] = [
{"type": "header", "text": {"type": "plain_text", "text": "Action Approval Required"}},
{"type": "section", "text": {"type": "mrkdwn", "text": header}},
{"type": "divider"},
]

for tool in tools:
if not tool.is_paused:
continue

tool_name = tool.tool_name or "unknown tool"
args_str = ""
if tool.tool_args:
# Truncate long args for readability
formatted = json.dumps(tool.tool_args, indent=2)
if len(formatted) > 500:
formatted = formatted[:500] + "\n..."
args_str = f"\n```{formatted}```"

pause_label = "Confirmation"
if tool.requires_user_input:
pause_label = "User Input"
elif tool.external_execution_required:
pause_label = "External Execution"

blocks.append(
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{pause_label}:* `{tool_name}`{args_str}",
},
}
)

blocks.append({"type": "divider"})
blocks.append(
{
"type": "actions",
"block_id": f"approval_{approval_id}",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve"},
"style": "primary",
"action_id": "hitl_approve",
"value": approval_id,
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Reject"},
"style": "danger",
"action_id": "hitl_reject",
"value": approval_id,
},
],
}
)

return blocks


def approved_blocks(
original_blocks: List[Dict[str, Any]],
user_id: str,
approved: bool,
) -> List[Dict[str, Any]]:
"""Update approval blocks after user clicks Approve/Reject.

Replaces the action buttons with a resolved status message.
"""
updated: List[Dict[str, Any]] = []
for block in original_blocks:
# Replace the actions block with a status context
if block.get("type") == "actions" and block.get("block_id", "").startswith("approval_"):
updated.append(
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": f"{'*Approved*' if approved else '*Rejected*'} by <@{user_id}>",
}
],
}
)
else:
updated.append(block)
return updated


def status_blocks(text: str, status: str = "info") -> List[Dict[str, Any]]:
"""Simple status message block."""
emoji = {"info": "information_source", "success": "white_check_mark", "error": "x"}.get(
status, "information_source"
)
return [
{
"type": "section",
"text": {"type": "mrkdwn", "text": f":{emoji}: {text}"},
}
]
10 changes: 10 additions & 0 deletions libs/agno/agno/os/interfaces/slack/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,15 @@ async def _on_memory_update_completed(chunk: BaseRunOutputEvent, state: StreamSt
return False


async def _on_run_paused(chunk: BaseRunOutputEvent, state: StreamState, stream: AsyncChatStream) -> bool:
# HITL: agent paused for tool confirmation. Signal the stream loop to stop
# and render an approval card. The actual Block Kit message is posted by
# the router after stream.stop(), using chunk data stored on state.
state.paused_event = chunk
state.terminal_status = "complete" # Close cards cleanly, not as error
return True # Break the stream loop


async def _on_run_completed(chunk: BaseRunOutputEvent, state: StreamState, stream: AsyncChatStream) -> bool:
return False # Finalization handled by caller after stream ends

Expand Down Expand Up @@ -387,6 +396,7 @@ async def _on_loop_execution_completed(chunk: BaseRunOutputEvent, state: StreamS
RunEvent.run_completed.value: _on_run_completed,
RunEvent.run_error.value: _on_run_error,
RunEvent.run_cancelled.value: _on_run_error, # Treat cancellation as terminal error
RunEvent.run_paused.value: _on_run_paused, # HITL: agent paused for confirmation
# -------------------------------------------------------------------------
# Workflow Lifecycle Events
# -------------------------------------------------------------------------
Expand Down
Loading
Loading