Permissions and modes
Permissions decide yes / no / ask for every tool call.
Modes
from loomflow import Mode
Mode.DEFAULT # gate destructive tools through ask → approval
Mode.ACCEPT_EDITS # auto-approve destructive tools (no ask)
Mode.BYPASS # allow everything (CI / sandbox only)| Mode | Non-destructive tools | Destructive tools |
|---|---|---|
BYPASS | allow | allow |
ACCEPT_EDITS | allow | allow |
DEFAULT | allow | ask_(...) → approval handler decides |
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.
StandardPermissions
Mode + allow-list / 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("...", 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 the tool is destructive and mode isn’t
ACCEPT_EDITS→ask_(...)(handled by the approval handler). - Otherwise → allow.
PerUserPermissions
Route the policy decision per user_id. One Agent can run in
BYPASS for staff while 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("...", permissions=perms)The framework forwards the live user_id from the active
RunContext into every permissions.check(...) call automatically.
Custom policies
Satisfy the Permissions protocol. One async method:
from collections.abc import Mapping
from typing import Any
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_()
from datetime import datetime
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("...", permissions=BusinessHoursPermissions())Same pattern for geofencing, role-based access, cost-tier gating, etc.
PermissionDecision shapes
PermissionDecision.allow_() # tool runs
PermissionDecision.deny_("reason") # tool result = denied
PermissionDecision.ask_("destructive call requires approval") # → approval handlerApproval handler. Turning ask into a real decision
StandardPermissions(mode=Mode.DEFAULT) returns ask_(...) for
destructive tools. Without an approval handler, ask falls back to
deny. The agent never silently bypasses the gate. Wire a handler
to surface the decision to a human / Slack / ticket queue:
async def approve(call, user_id):
return await my_slack_app.request_approval(call.tool, user_id)
agent = Agent(
"...",
permissions=StandardPermissions(mode=Mode.DEFAULT),
approval_handler=approve,
)See Approval handlers for the full failure-mode contract.
Per-user budget caps interact
Permissions and budgets are independent layers. A run can be allowed
by permissions but blocked by BudgetExceeded. And vice versa. 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 write without
asking).