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.
ApprovalDecision. Rich HITL actions
A handler that returns bool keeps working exactly as before —
True allows, False denies. 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, the model’s transcript is not rewritten, and the audit
log gets an explicit tool_call_edited entry carrying both args
(what actually ran) and original_args (what the model planned) —
the trail always reflects the call that executed.
Approval memory is per-run. A remembered decision short-circuits
subsequent ask gates for the same user + tool without re-invoking
the handler — “approve once per run”, the CLI-style yes, and don’t
ask me again affordance. The cache is re-initialised on every run
and never persisted, and it’s keyed per user_id, so one tenant’s
“always allow” never leaks into another’s run.
A raising handler is still fail-closed: the pending call is denied and a warning is logged. So is an unrecognised action shape.
Park on a signal. Out-of-band approvals
Handlers are async and the runtime signal channel is public, so a handler can park the run until an out-of-band decision arrives — no polling, no framework machinery beyond the signal API:
from loomflow import Agent, ApprovalDecision, InProcRuntime, get_run_context
runtime = InProcRuntime()
async def approve(call, user_id):
ctx = get_run_context()
await notify_slack(call, user_id) # out-of-band ask
payload = await runtime.wait_for_signal( # park the run
ctx.session_id, "approval"
)
return ApprovalDecision(action=payload["action"])
agent = Agent(
"deploy when approved",
permissions=StandardPermissions(mode=Mode.DEFAULT),
runtime=runtime,
approval_handler=approve,
)
# ...meanwhile, when the human reacts in Slack:
await agent.signal("session-1", "approval", {"action": "allow"})The run genuinely parks mid-approval — no tokens burn while waiting —
and agent.signal(session_id, name, payload) unblocks it from any
task in the process. Runtimes without signal support warn and no-op
instead of deadlocking.
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).