Permissions and modes
A permissions policy decides, for every tool call the model makes, whether it runs (allow), is refused (deny), or waits for a decision from a person or system (ask). Use one whenever the agent has tools that change things, such as writing files, running shell commands or deleting accounts, and when different users of one shared agent should get different rights.
With no policy configured, every call is allowed (AllowAll).
Modes
from loomflow import Mode
Mode.DEFAULT # gate destructive tools through ask → approval
Mode.ACCEPT_EDITS # auto-approve file edits; other destructive tools ask
Mode.BYPASS # allow everything (CI / sandbox only)| Mode | Non-destructive tools | Edit tools (write, edit) | Other destructive tools |
|---|---|---|---|
BYPASS | allow | allow | allow |
ACCEPT_EDITS | allow | allow | ask_(...), then the approval handler decides |
DEFAULT | allow | ask_(...) | ask_(...) |
A tool is “destructive” when it’s marked @tool(destructive=True)
or constructed with Tool(destructive=True). The built-in
write_tool, edit_tool, and bash_tool are all destructive.
ACCEPT_EDITS works like the Agent SDK’s acceptEdits: it
auto-approves only the file-edit tools, by default the built-in
write and edit. Every other destructive tool, bash included,
still asks. If you renamed the built-ins or ship your own editors,
name them with edit_tools=:
StandardPermissions(mode=Mode.ACCEPT_EDITS, edit_tools=["write", "edit", "apply_patch"])StandardPermissions
Mode plus an allow-list or deny-list:
from loomflow import Agent, Mode, StandardPermissions
permissions = StandardPermissions(
mode=Mode.DEFAULT,
denied_tools=["delete_account", "send_email"], # blocklist
# allowed_tools=[...], # or allowlist
)
agent = Agent("...", model="claude-opus-4-7", permissions=permissions)Resolution order inside check:
- If the tool is in
denied_tools: deny. - If
allowed_toolsis set and the tool isn’t in it: deny. - If mode is
BYPASS: allow. - If mode is
ACCEPT_EDITSand the tool is inedit_tools: allow. - If the tool is destructive:
ask_(...), handled by the approval handler. - Otherwise: allow.
PerUserPermissions
Routes the policy decision per user_id, so one Agent can run in
BYPASS for staff while still gating destructive tools for end users:
from loomflow import Agent, Mode, StandardPermissions
from loomflow.security import PerUserPermissions
policies = {
"admin_alice": StandardPermissions(mode=Mode.BYPASS),
"service_account": StandardPermissions(
mode=Mode.DEFAULT,
allowed_tools=["read", "search"],
),
}
perms = PerUserPermissions(
policies=policies,
default=StandardPermissions(
mode=Mode.DEFAULT,
denied_tools=["delete_account", "send_email"],
),
)
agent = Agent("...", model="claude-opus-4-7", permissions=perms)
await agent.run("clean up old records", user_id="admin_alice")Unknown user_ids, and runs with no user_id at all, fall through to
default, so make default your strictest policy. The framework
forwards the live user_id from the active RunContext into every
permissions.check(...) call; you don’t wire it manually.
Custom policies
Any object with this one async method satisfies the Permissions
protocol, no base class needed. This one blocks destructive tools
outside business hours:
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from loomflow import Agent
from loomflow.core.types import PermissionDecision, ToolCall
class BusinessHoursPermissions:
"""Block destructive tools outside 9am-5pm local time."""
async def check(
self,
call: ToolCall,
*,
context: Mapping[str, Any],
user_id: str | None = None,
) -> PermissionDecision:
if not call.is_destructive():
return PermissionDecision.allow_()
now = datetime.now()
if 9 <= now.hour < 17:
return PermissionDecision.allow_()
return PermissionDecision.deny_(
f"destructive calls disabled outside business hours (now {now:%H:%M})"
)
agent = Agent("...", model="claude-opus-4-7", permissions=BusinessHoursPermissions())The same pattern covers geofencing, role-based access, cost-tier
gating, and so on. Use user_id to look up the caller’s role, and
return ask_(...) instead of deny_(...) when a person should
decide. A custom policy can also be one of the values inside a
PerUserPermissions mapping.
PermissionDecision shapes
from loomflow.core.types import PermissionDecision
PermissionDecision.allow_() # tool runs
PermissionDecision.deny_("reason") # tool result = denied
PermissionDecision.ask_("destructive call requires approval") # → approval handlerApproval handlers
An approval handler turns an ask into a real yes or no.
StandardPermissions(mode=Mode.DEFAULT) returns ask_(...) for
destructive tools; the agent then calls your handler with the pending
ToolCall and the run’s user_id. The handler is async, so it can
wait on a terminal prompt, a Slack approval or a ticket queue without
blocking the agent loop. Return True to run the call, False to
refuse it. Only True, an action name ("allow", "deny", …) or an
ApprovalDecision can
approve; any other return value denies.
import anyio
from loomflow import Agent, Mode, StandardPermissions, tool
from loomflow.core.types import ToolCall
@tool(destructive=True)
def delete_user_data(user: str) -> str:
"""Delete all stored data for a user."""
return f"deleted data for {user}"
async def approve(call: ToolCall, user_id: str | None) -> bool:
question = f"{user_id} wants {call.tool}({call.args}). Allow? [y/N] "
answer = await anyio.to_thread.run_sync(input, question)
return answer.strip().lower() == "y"
agent = Agent(
"You manage user accounts.",
model="claude-opus-4-7",
tools=[delete_user_data],
permissions=StandardPermissions(mode=Mode.DEFAULT),
approval_handler=approve,
)What happens when approval fails
A broken approval flow must never quietly let a gated tool run. Every failure path below ends in a deny.
- Handler returns
False: the call doesn’t run. The model gets a denied tool result and the run continues, so it can react on its next turn. - No handler wired (
approval_handler=None):askis treated as deny. - Handler raises: treated as deny, and a warning is logged.
- Handler returns something other than a
bool, an action name or anApprovalDecision("no",{"approved": False},None, …): treated as deny, and a warning is logged. Before 0.17 the result was read withbool(), so a truthy value like"deny"approved the call. - Handler returns an unrecognised
ApprovalDecisionaction: treated as deny, and a warning is logged.
The denied result’s reason is the first one available: the reason
on your ApprovalDecision, the reason the policy passed to
ask_(...) (for StandardPermissions,
"<tool>: destructive call requires approval"), or a fallback of
"approval declined" (or "approval required; no approver" when no
handler is wired).
ApprovalDecision: edit, remember, or explain
A handler can return a plain bool (True allows, False denies) or
an action name as a string ("allow", "deny", "edit",
"remember_allow", "remember_deny"), which counts as the matching
ApprovalDecision. Return an ApprovalDecision when you need more
than allow/deny:
from loomflow import ApprovalDecision
async def approve(call, user_id):
verdict = await review_ui.ask(call, user_id)
match verdict.kind:
case "yes": return ApprovalDecision(action="allow")
case "no": return ApprovalDecision(
action="deny", reason="reviewer declined")
case "edited": return ApprovalDecision(
action="edit", edited_args=verdict.args)
case "yes-always": return ApprovalDecision(action="remember_allow")
case "no-never": return ApprovalDecision(action="remember_deny")action | Effect |
|---|---|
"allow" / "deny" | Same as True / False, plus an optional reason surfaced in the deny message and audit log. |
"edit" | Run the tool with edited_args instead of the model-planned args. edited_args=None keeps the originals (plain allow). |
"remember_allow" / "remember_deny" | Decide and cache the decision for (user_id, tool_name) for the remainder of this run. |
Edited args are audited. When the handler edits args, the tool
host re-validates them at execute time exactly as it would the
originals, and the model’s transcript is not rewritten. The audit
log gets an explicit tool_call_edited entry carrying both args
(what actually ran) and original_args (what the model planned), so
the trail always reflects the call that executed.
Approval memory is per run. A remembered decision answers later
ask gates for the same user and tool without calling the handler
again: the “yes, and don’t ask me again” option familiar from CLI
tools. The cache starts empty on every run, is never persisted, and
is keyed per user_id, so one tenant’s “always allow” never leaks
into another’s run.
Out-of-band approvals: park the run on a signal
When the answer comes from somewhere else (a Slack button, a web dashboard, a ticket), the handler can park the run until it arrives. Handlers are async and the runtime signal channel is public, so no polling or extra machinery is needed:
from loomflow import (
Agent, ApprovalDecision, InProcRuntime, Mode, StandardPermissions,
get_run_context,
)
runtime = InProcRuntime()
async def approve(call, user_id):
ctx = get_run_context()
await notify_slack(call, user_id, session_id=ctx.session_id) # out-of-band ask
try:
payload = await runtime.wait_for_signal( # park
ctx.session_id, "approval", timeout_s=900,
)
except TimeoutError:
return ApprovalDecision(action="deny", reason="no answer in 15 minutes")
return ApprovalDecision(action=payload["action"])
agent = Agent(
"deploy when approved",
model="claude-opus-4-7",
permissions=StandardPermissions(mode=Mode.DEFAULT),
runtime=runtime,
approval_handler=approve,
)
# The run parks inside approve():
# await agent.run("deploy v2", session_id="deploy-1")
# Meanwhile, when the human reacts in Slack, from any task in the process:
await agent.signal("deploy-1", "approval", {"action": "allow"})The run genuinely parks mid-approval, so no tokens burn while it
waits, and agent.signal(session_id, name, payload) unblocks it from
any task in the process. timeout_s= makes wait_for_signal raise
TimeoutError instead of waiting forever, so an unanswered request
fails closed. Runtimes without signal support log a warning and do
nothing instead of deadlocking.
Permissions and budgets are separate
Permissions and budgets are independent layers. A run can be allowed by permissions but stopped by its budget, and the reverse. See Per-user budget caps.
BYPASS is for CI and sandboxes only. It allows every tool
call, including destructive ones, without any gate. Production
configs should use DEFAULT (with an approval handler) or
ACCEPT_EDITS (when you genuinely want the agent to edit files
without asking; other destructive tools still go to the handler).