Interactive Tool Approval
Approve or deny agent tool calls interactively with persistent project rules
Interactive approval is now the default for dangerous built-in tools — no configuration needed. When an agent calls a sensitive or external tool, PraisonAI pauses and asks you before it runs. Your choices can be saved as project rules for next time. See Approval for the full list of gated tools and bypass options, and Approval Backends → Which class runs the prompt? for how the Python (ConsoleBackend) and CLI (InteractiveCLIApprovalBackend) paths differ.
from praisonaiagents import Agent
agent = Agent(
name="Coder",
instructions="Edit files as requested",
approval=True,
)
agent.start("Refactor utils.py")The user requests a change; PraisonAI pauses on dangerous tools until they allow or deny in the terminal.
Quick Start
- Simple Usage
python from praisonaiagents import Agent agent = Agent( name="Coder", instructions="Edit files as requested", approval=True, ) agent.start("Refactor utils.py") - With Configuration
bash praisonai --approval console run "Show me git status"A prompt appears:
🔒 Tool Approval Required Function: execute_command Risk Level: MEDIUM Agent: Coder Arguments: command: git status -s Allow execute_command? [o] once [s] this session [a] always (bash:git status *) [n] no [d] deny & redirect Choice [o/s/a/n/d] (n):Each key chooses how long the approval lasts:
[o] once— approve this exact call and prompt again next time (default; backward compatible).[s] session— auto-approve matching calls (same permission target) for the rest of this run. Never persisted — restarting the process starts over.[a] always— persist an allow-rule toapprovals.json. The suggested pattern in parentheses (e.g.bash:git status *,edit:src/app.py) is what gets saved; it is auto-derived from the tool + arguments.[n] no— deny (also selected on Ctrl-C / EOF).[d] deny & redirect— deny this call and, at the follow-up prompt (What should the agent do instead?), type a one-line instruction. The agent’s next turn receives it as course-correction (viaApprovalDecision.feedback), so it doesn’t retry the same call as-is.
When approval is required
Approval runs when any of these apply:
- The agent has
approval=True(or a CLI--approvalbackend) - The tool is in the default dangerous-tools list (e.g.
bash,write,delete) - The tool has
trust_level == "external"in the tool registry - A repeated tool-call loop is detected — the interactive backend is asked (continue / stop) as a
doom_loopdecision on both sync (chat()/start()) and async (achat()/astart()) paths (PR #4858 restored async parity); see Approval → Doom-loop safety gate
Scope choice
Five options in one row — this diagram picks the right one.
| Choice | Where it lives | Scope key | Survives restart? |
|---|---|---|---|
[o] once | Nothing persisted | Cache key (tool_name, sha256(args)) (existing per-call cache) | No |
[s] session | In-memory ApprovalRegistry._session_scoped_targets | (scope_id, permission_target) — per Agent instance, not per display name | No — vanishes when the process ends |
[a] always | approvals.json via PermissionManager | Suggested pattern (e.g. bash:git status *) with reusable_scope=True | Yes |
[n] no | Nothing persisted | — | No |
[d] deny & redirect | Nothing persisted. feedback string is fed back to the agent as the denied tool’s error_msg for the next turn. | — | No — feedback is one-turn only, never remembered across calls |
Per-instance scoping
[s] session grants (and the in-context cache the fast path checks) are keyed per Agent instance, not per display name. Two Agent() objects that happen to share the same name — including two unnamed Agent() instances, which both default to the display name "Agent" — never inherit each other’s session-scoped approvals.
Name-keyed lookups — per-agent backend, risk level, ask rules, durable [a] always persistence — still use agent_name, so YAML rules and approvals.json entries scoped to agent_name: worker continue to match every Agent(name="worker") across runs.
graph TB
subgraph "Two same-named workers, one session grant"
H[👤 Human grants<br/>[s] session on Worker-1] --> W1[⚙️ Worker-1<br/>Agent name=Agent]
H -.->|before #4533<br/>silent leak| W2[⚙️ Worker-2<br/>Agent name=Agent]
H -->|since #4533<br/>never leaks| W1
end
classDef human fill:#F59E0B,stroke:#7C90A0,color:#fff
classDef safe fill:#10B981,stroke:#7C90A0,color:#fff
classDef leak fill:#8B0000,stroke:#7C90A0,color:#fff
class H human
class W1 safe
class W2 leakHow the suggested pattern is derived
The always (…) hint isn’t magic — it maps the tool + arguments to a reusable target.
| Tool | Mapping | Example target |
|---|---|---|
execute_command, acp_execute_command | bash:<command> (first non-empty of command/cmd/code/query) | bash:git status -s |
edit_file, acp_edit_file | edit:<path> | edit:src/app.py |
write_file, acp_create_file | write:<path> | write:src/new.py |
delete_file, acp_delete_file | delete:<path> | delete:tmp/old.log |
move_file | move:<src> (uses src, not dst) | move:a/old.py |
copy_file | copy:<src> (uses src, not dst) | copy:a/x.py |
apply_patch | tool:apply_patch (not an edit: target) | tool:apply_patch |
| Anything else | tool:<tool_name> | tool:scrape_page |
Session-only workflow
Press [s] to auto-approve a repeated call for the rest of the run — nothing is written to disk.
from praisonaiagents import Agent
agent = Agent(
name="Coder",
instructions="Fix the failing tests. Use `pytest` to check as you go.",
approval=True, # console prompt is the default
)
agent.start("Fix the broken tests in tests/unit")
# First `bash:pytest tests/unit` call → prompt appears.
# Press [s] once → every later `bash:pytest tests/unit` in this run is auto-approved.
# Close the process → next run prompts again from scratch.Pressing [s] on one worker never leaks to another same-named worker in the same process — session grants are keyed per Agent instance, so two asyncio.gather(...) agents both named "Coder" (or both unnamed) stay isolated.
import asyncio
from praisonaiagents import Agent
# Two unnamed workers — both default to name="Agent"
worker_1 = Agent(instructions="Fix tests", approval=True)
worker_2 = Agent(instructions="Run linters", approval=True)
# Pressing [s] on worker_1's first pytest call NEVER auto-approves
# worker_2's pytest calls in the same process. Session grants are
# keyed per Agent instance since PR #4533.
async def run_both():
await asyncio.gather(
worker_1.astart("Fix broken tests in tests/unit"),
worker_2.astart("Run ruff on src/"),
)
asyncio.run(run_both())Press [a] instead when the grant should persist across runs.
# Same agent, same task — press [a] instead of [s].
# The prompt suggests "always (bash:pytest *)" — press Enter to accept.
# On the next run: pytest calls no longer prompt.
# Undo with: praisonai permissions revoke "bash:pytest *"Change Preview
Before you approve a file-mutating tool, PraisonAI shows a preview so you approve the actual change — not just the tool name. The same preview renders across the CLI approval backend keystroke prompt, the Rich frontend, and the Textual dialog.
For edit / apply_patch, PraisonAI shows a unified diff — either the one the caller supplied (diff / patch argument), or one synthesised on the fly from old_string / new_string (the shape most edit_file calls take):
Preview (diff):
--- a/utils.py
+++ b/utils.py
@@ -1,3 +1,3 @@
-def add(a, b): return a+b
+def add(a: int, b: int) -> int: return a + bWhen the caller supplies only old_string / new_string (the default edit_file shape):
Preview (diff):
--- a/utils.py
+++ b/utils.py
@@ -1 +1 @@
-def add(a, b): return a+b
+def add(a: int, b: int) -> int: return a + bFor write (up to 2000 chars, then a \n... (truncated) sentinel is appended), the content is prefixed with the target path:
# utils.py
def add(a: int, b: int) -> int:
return a + bNo preview is shown for read-only or non-file tools.
Reject with a reason
Denying isn’t just “stop” — you can steer. Two entry points collect guidance and hand it back to the agent as the denied tool’s result, so the next turn course-corrects instead of retrying blindly.
- Terminal CLI approval backend (single-key prompt) — pick
[d] deny & redirectat the main prompt, then answer the follow-upWhat should the agent do instead?prompt. Empty input / Ctrl-C / EOF at the follow-up falls back to a plain deny (feedback=None). - Rich / Textual TUI — deny still opens the existing “Reason for denial (optional)” inline prompt; behaviour unchanged.
from praisonaiagents import Agent
agent = Agent(
name="Coder",
instructions="Edit files as requested.",
approval=True,
)
agent.start("Refactor utils.py — feel free to run tests")The agent proposes bash: rm -rf .venv, you deny with a redirect, and the agent picks a safer alternative on its next turn.
sequenceDiagram
participant User
participant Console as CLI approval backend
participant Agent
Agent->>Console: Approve tool call?
User->>Console: [d] deny & redirect
Console->>User: What should the agent do instead? _
User->>Console: "edit tests/app.py instead"
Console->>Agent: ApprovalDecision(approved=False, feedback="edit tests/app.py instead")
Note over Agent: Tool result becomes:<br/>"Tool 'execute_command' was denied by the user.<br/>Do not retry it as-is. User guidance: edit tests/app.py instead"
Agent-->>User: retries with the correction
classDef user fill:#6366F1,stroke:#7C90A0,color:#fff
classDef console fill:#189AB4,stroke:#7C90A0,color:#fff
classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
class User user
class Console console
class Agent agentHow it flows
| Step | What happens |
|---|---|
| 1. Agent proposes a sensitive tool call | Prompt appears with the [d] deny & redirect key |
2. You pick [d] | The backend prompts What should the agent do instead? |
| 3. You type a one-line instruction (or press Enter to skip) | Blank input, EOF, or Ctrl-C falls back to a plain deny (feedback=None) |
4. Backend returns ApprovalDecision(approved=False, feedback=...) | feedback is a new Optional[str] field, defaulting to None |
5. tool_execution.py folds feedback into the denied tool’s error_msg | The agent’s next turn sees the correction as the tool’s result |
When the prompt appears
| Where you deny | Reason prompt? |
|---|---|
Console backend (single-key [o/s/a/n/d]) | Pick [d] deny & redirect for the guidance follow-up. [n] stays a plain deny with no prompt. |
Rich frontend (praisonai code, praisonai chat in Rich mode) | Yes — inline prompt after Deny |
Textual dialog (ApprovalDialog.on_button_pressed) | Accepts a reason argument |
Live Textual TUI (y/n key handler) | Not yet — denies without collecting a reason |
| Ctrl-C / EOF at main prompt | No prompt — treated as plain denial |
| Bot/chat-channel (Telegram / Slack / Discord) | Not covered by #4101 — button reject only |
Rich / Textual TUI (praisonai code, praisonai chat)
praisonai code and praisonai chat in Rich or Textual mode render approvals as a scrollable option list (Rich) or a button dialog (Textual) — not the single-key [o]/[s]/[a]/[n] prompt. The Rich/Textual TUI ships with praisonai-code, so this dialog is available standalone (PR #3818) — no wrapper required.
praisonai code "Refactor utils.py"When the agent runs git status, the dialog shows the exact pattern each option will persist:
Approval Required — shell_command: git status -s
▸ Once
▸ Session Only (shell_command:git status *)
▸ Always Allow (shell_command:git status *)
▸ Always Allow All (tool:shell_command)
▸ DenyBoth Rich and Textual now render the same change preview the CLI approval backend shows — a unified diff for edit / apply_patch, or truncated content for write — above the approval options, so you approve the concrete change, not just the tool name. Rich draws it as a syntax-highlighted Panel (plain-text fallback when colour is unavailable); Textual shows it inside a scrollable region above the buttons. Non-mutating tools show no preview.
- Once — approve this exact call, ask again next time.
- Session Only — reuse the narrow command-scoped pattern in memory for this run only; never written to disk.
- Always Allow — persist the narrow command-scoped pattern (
shell_command:git statusfor shell commands;edit:<path>/write:<path>for file tools; a literal single-use target when nothing safe can be derived). It never persiststool:. - Always Allow All — the only option that persists a blanket
tool:<tool_name>rule (formerlyaction_type:*). Pick it deliberately when you truly want every future call of the tool approved. - Deny — reject the call, then prompts for an optional one-line reason so the agent knows why and can adjust its next step. Ctrl-C / EOF still counts as a plain denial with no reason.
Each option displays its pattern before you choose, so the value shown is exactly the value stored — the Textual ApprovalDialog derives both the narrow and blanket patterns once in __init__ to guarantee they match.
Approval modes
| CLI flag | PermissionMode | Value | Behaviour |
|---|---|---|---|
--approval console | DEFAULT | default | Prompt for each sensitive call |
--approval plan | PLAN | plan | Block write, edit, delete, bash, shell |
--approval accept-edits | ACCEPT_EDITS | accept_edits | Auto-approve edit/write tools |
--approval bypass | BYPASS | bypass_permissions | Skip all checks |
Persistence
Press [a] to write an allow-rule to approvals.json via PermissionManager (scoped to the approving agent). The suggested pattern (e.g. bash:git status *, edit:utils.py) is what gets saved:
| Choice | Scope | Persisted pattern example |
|---|---|---|
[o] once | This call only | (not persisted) |
[s] this session | In-memory only — nothing written | (not persisted) |
[a] always | Narrow command-prefix / path | bash:git status *, edit:utils.py, write:.env |
[n] no | This call only | (not persisted) |
[d] deny & redirect | This call only, with one-turn feedback to the agent | (not persisted — feedback is passed to the model as the denied tool’s result and forgotten after the next turn) |
[a] uses the shared suggest_scope_pattern helper so the CLI, YAML --allow/--deny, and Python PermissionManager all scope identically. Compound commands (&&, |, ;, $(...)) fall back to a literal single-use pattern so a persisted rule can only match the exact invocation you approved. For a blanket <tool>: rule, author it directly with praisonai permissions allow "bash:".
Session grants live in an in-memory _session_scoped_targets set and are cleared by clear_approved() at run teardown. Always grants persist across restarts and survive clear_approved().
Manage rules with:
praisonai permissions list
praisonai permissions allow "bash:git *"| File | Shared? |
|---|---|
rules.json | Yes — commit for team rules |
approvals.json | No — local session data |
approvals.json entry format
Each entry carries pattern, approved, scope, created_at, expires_at, agent_name, and a derived flag. derived: true marks approvals whose pattern was auto-generated by reusable command-prefix scopes — user-edited or hand-authored patterns save with derived: false. Old files without the field load cleanly (derived defaults to False).
Non-interactive and CI
praisonai --yes --approval console run "Check deployment"
PRAISONAI_NON_INTERACTIVE=1 praisonai --approval console run "Check deployment"Without a TTY, prompts default to deny so CI pipelines fail closed.
Best Practices
Start with plan for new repos
Use --approval plan until you trust the agent’s behaviour in a codebase.
Review external tools
Tools marked external always prompt — verify third-party integrations before allowing.
Share rules.json in git
Team-wide allow/deny patterns belong in version control.
`Always allow` is now narrow by default — pick `Always Allow All` explicitly for a blanket rule
In both the CLI approval backend keystroke prompt and the Rich/Textual TUI dialog, the default “Always allow” option persists the narrowest reusable pattern (e.g. shell_command:git status , edit:src/app.py) — never the blanket action_type:. It covers every git status … variant but never expands to git push or other subcommands. To allow every future call of the tool, pick the distinct Always Allow All button (Textual) / tool: menu entry (Rich), or author the rule directly with praisonai permissions allow "shell_command:".
A [a] always grant picked at the ConsoleBackend prompt is now recorded into the registry (_persist_scoped_decision) and honoured by the fast path on every subsequent matching call in the same run. Before PR #4878 the backend path never recorded it, so the reusable pattern was re-prompted on the very next call. See Standing grants and attached backends.
Prefer [s] session over [a] always for one-off runs
[s] grants stay in memory and vanish when the process ends — ideal for a repeated call in a single task. Reserve [a] always for calls you want persisted across every future run.
A [s] session grant picked at the ConsoleBackend prompt is now persisted into the registry (_persist_scoped_decision) and honoured by the fast path on every subsequent matching call in the same run. Before PR #4878 this grant was forgotten immediately and the user was re-prompted on the very next call. See Standing grants and attached backends.
Skim the change preview before pressing [a]
The preview is your last chance to catch an unintended edit. For edit / apply_patch you get a unified diff (either supplied by the caller or synthesised from old_string / new_string). For write you get up to 2000 characters of the new content, prefixed with the target path. Read-only or non-file tools have no preview.
Give a name to your agent before pressing [a]
A nameless always grant can’t be persisted — it would match any later agent, so the registry keeps it in-memory for this run only. Set Agent(name=...) so [a] writes a durable, agent-scoped rule.
Reach for [d] when the agent is close but wrong
Use [d] deny & redirect when the agent’s proposal is nearly right but you can point it at the correct target in a sentence — “edit tests/app.py instead”, “use staging.env, not production.env“, “run pytest -k login first”. The next turn sees your instruction as the denied tool’s result and adjusts, instead of retrying the same call. Use plain [n] when the agent just needs to stop.
Bot/chat-channel approvals
This page covers the CLI/terminal approval flow. When running PraisonAI on Telegram, Slack, or Discord, approvals render as interactive buttons and are actor-bound — only the requester and configured admins can resolve them.
Interactive Callback AuthorizationLock approval buttons to specific users in shared chats — covers Telegram, Slack, and Discord bots.
Cardderive_pattern — shared narrow-pattern derivation used by CLI, YAML, and Python rules