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.

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

Diagram

Yes

session

always

deny & redirect

No

Agent

Tool call

Needs approval?

Terminal prompt

once / session / always / no / deny & redirect

Reuse this run

Persist rule

Feedback → next turn

Execute

Quick Start

  1. Simple Usage
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        name="Coder",
        instructions="Edit files as requested",
        approval=True,
    )
    agent.start("Refactor utils.py")
  2. 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 to approvals.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 (via ApprovalDecision.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 --approval backend)
  • 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_loop decision on both sync (chat() / start()) and async (achat() / astart()) paths (PR #4858 restored async parity); see Approval → Doom-loop safety gate
Diagram
AgentPermissionManagerApprovalRegistryCLI approval backendUserAgentPermissionManagerApprovalRegistryCLI approval backendUserPrompt shows suggested pattern:always (bash:git status *)Next turn sees:“Tool ‘execute_command’ was denied by the user.Do not retry it as-is. User guidance: edit tests/app.py instead”alt[scope == “once” (approve)][scope == “session”][scope == “always”][choice == “n” (plain deny)][choice == “d” (deny & redirect)]Allow? [o] once [s] session [a] always [n] no [d] deny & redirectchoiceallow this call only_session_scoped_targets.add((agent, target))allow + reuse this runapprove(target, reusable_scope=True, pattern=…)append to approvals.jsonallow + persist for future runsApprovalDecision(approved=False, feedback=None)What should the agent do instead?“edit tests/app.py instead”ApprovalDecision(approved=False, feedback=”edit tests/app.py instead”)

Scope choice

Five options in one row — this diagram picks the right one.

Diagram

No, just this once

Yes, several times this run

Yes, forever across runs

Not what I meant to run

Wrong call, but I know a better one

Will you re-approve
this same call soon?

[o] once

[s] session
in-memory only

[a] always
writes approvals.json

[n] no

[d] deny & redirect
type what to do instead

ChoiceWhere it livesScope keySurvives restart?
[o] onceNothing persistedCache key (tool_name, sha256(args)) (existing per-call cache)No
[s] sessionIn-memory ApprovalRegistry._session_scoped_targets(scope_id, permission_target) — per Agent instance, not per display nameNo — vanishes when the process ends
[a] alwaysapprovals.json via PermissionManagerSuggested pattern (e.g. bash:git status *) with reusable_scope=TrueYes
[n] noNothing persisted—No
[d] deny & redirectNothing 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.

Diagram
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 leak

How the suggested pattern is derived

The always (…) hint isn’t magic — it maps the tool + arguments to a reusable target.

ToolMappingExample target
execute_command, acp_execute_commandbash:<command> (first non-empty of command/cmd/code/query)bash:git status -s
edit_file, acp_edit_fileedit:<path>edit:src/app.py
write_file, acp_create_filewrite:<path>write:src/new.py
delete_file, acp_delete_filedelete:<path>delete:tmp/old.log
move_filemove:<src> (uses src, not dst)move:a/old.py
copy_filecopy:<src> (uses src, not dst)copy:a/x.py
apply_patchtool:apply_patch (not an edit: target)tool:apply_patch
Anything elsetool:<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.

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

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

python
# 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 + b

When 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 + b

For 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 + b

No preview is shown for read-only or non-file tools.

Diagram

edit / apply_patch

yes

no

yes

no

write

other

Tool call

Tool name?

diff/patch
supplied?

Show supplied diff

old_string +
new_string?

Synthesise unified diff

No preview

Show content
≤2000 chars, then truncate

No preview

Your choice

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 & redirect at the main prompt, then answer the follow-up What 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.
python
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.

Diagram
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 agent

How it flows

StepWhat happens
1. Agent proposes a sensitive tool callPrompt 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_msgThe agent’s next turn sees the correction as the tool’s result

When the prompt appears

Where you denyReason 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 promptNo 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.

bash
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)
  ▸ Deny

Both 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 status for shell commands; edit:<path> / write:<path> for file tools; a literal single-use target when nothing safe can be derived). It never persists tool:.
  • Always Allow All — the only option that persists a blanket tool:<tool_name> rule (formerly action_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.
Diagram

Approval prompt
shell_command: git status -s

Always Allow

Session Only

Always Allow All

Deny

Narrow: shell_command:git status *
persisted to approvals.json

Narrow: shell_command:git status *
in-memory this run only

Blanket: tool:shell_command
persisted to approvals.json

No rule saved

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

Diagram

Exploring read-only

Editing files

Full control

Trusted sandbox only

What are you doing?

plan

accept-edits

console / default

bypass

CLI flagPermissionModeValueBehaviour
--approval consoleDEFAULTdefaultPrompt for each sensitive call
--approval planPLANplanBlock write, edit, delete, bash, shell
--approval accept-editsACCEPT_EDITSaccept_editsAuto-approve edit/write tools
--approval bypassBYPASSbypass_permissionsSkip 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:

ChoiceScopePersisted pattern example
[o] onceThis call only(not persisted)
[s] this sessionIn-memory only — nothing written(not persisted)
[a] alwaysNarrow command-prefix / pathbash:git status *, edit:utils.py, write:.env
[n] noThis call only(not persisted)
[d] deny & redirectThis 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:

bash
praisonai permissions list
praisonai permissions allow "bash:git *"
FileShared?
rules.jsonYes — commit for team rules
approvals.jsonNo — 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

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

Lock approval buttons to specific users in shared chats — covers Telegram, Slack, and Discord bots.

Card

derive_pattern — shared narrow-pattern derivation used by CLI, YAML, and Python rules

Related