Agents with Write Access by Default (Anti-Pattern) + Fixes + Code

  • Recognize the trap before it ships to prod.
  • See what breaks when the model is confidently wrong.
  • Copy safer defaults: permissions, budgets, idempotency.
  • Know when you shouldn’t use an agent at all.
Detection signals
  • Tool calls per run spikes (or repeats with same args hash).
  • Spend or tokens per request climbs without better outputs.
  • Retries shift from rare to constant (429/5xx).
Default write access turns an agent into an outage generator. How this ships, what it breaks, and a safer permission + approval design you can actually run.
On this page
  1. Problem-first intro
  2. Why this fails in production
  3. 1) Agents are loops, so they repeat mistakes
  4. 2) Untrusted text will try to steer writes
  5. 3) Multi-tenant makes the blast radius real
  6. 4) The model can’t reason about irreversibility
  7. Failure evidence (what it looks like when it breaks)
  8. The fastest emergency fix
  9. Compensating actions (roll forward) — example
  10. Hard invariants (non-negotiables)
  11. Policy gate vs approval (don’t mix these up)
  12. Implementation example (real code)
  13. Example failure case (composite)
  14. 🚨 Incident: Mass ticket closure
  15. Trade-offs
  16. When NOT to use
  17. Copy-paste checklist
  18. Safe default config
  19. FAQ
  20. Related pages
  21. Production takeaway
  22. What breaks without this
  23. What works with this
  24. Minimum to ship
Quick take

Quick take: Default write access is “root access by default”. When the model is confidently wrong, it doesn’t just answer wrong — it does wrong. Writes don’t roll back themselves.

You'll learn: Why write-by-default fails • Read/write separation • Policy gate vs approval • Async approvals + resume • Tenant scoping • Idempotency keys • Real incident patterns

Concrete metric

Write-by-default: small model mistake → irreversible side effects (duplicates, wrong closures, bad emails)
Read-first + approvals: writes are gated • scoped • idempotent • auditable
Impact: you prevent “fast damage” and keep on-call sane


Problem-first intro

The agent “needs to be useful”, so you give it write tools by default:

  • db.write
  • ticket.close
  • email.send

And for a week it’s fine.

Then it isn’t.

Because the first time the model is confidently wrong, it doesn’t just answer wrong — it does wrong.

Truth

Writes don’t roll back themselves.

This anti-pattern ships for the dumbest reason: it makes the demo look magical.
In production it makes your on-call rotation look haunted.


Why this fails in production

Write access by default fails for the same reason “root access by default” fails.

Failure analysis

1) Agents are loops, so they repeat mistakes

If a write fails and the model retries, you get duplicates and partial updates.

Truth

One wrong write turns into ten wrong writes.

2) Untrusted text will try to steer writes

User input, web pages, and tool output can all contain “instructions”. If your permissions live in the prompt, they’re optional.

Prompts aren’t enforcement. A tool gateway is.

3) Multi-tenant makes the blast radius real

The difference between “oops” and “incident” is usually shared creds, missing tenant scoping, and missing audit logs.

4) The model can’t reason about irreversibility

LLMs don’t get paged, don’t do customer calls, and don’t clean up after duplicate writes. They optimize for “done”, even when “done” is irreversible.


Failure evidence (what it looks like when it breaks)

Symptoms you’ll see:

  • A sudden spike in write tool calls (db.write, ticket.close, email.send)
  • Duplicate rows / double-closed tickets / repeated emails
  • Support hears about it before you do

A trace that should scare you:

JSON
{"run_id":"run_9f2d","step":4,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":5,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":6,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":7,"event":"stop","reason":"loop_detected","note":"same write call 3x"}

If you don’t have a trace like this, you don’t have “an agent problem”. You have “we can’t prove what happened”.

The fastest emergency fix

Stop writes. Now.

YAML
# kill switch: force read-only mode right now
writes:
  enabled: false

Then do the forensic work:

  • count which write tools ran
  • identify which entities were touched (by args hash / idempotency key)
  • roll forward with compensating actions (rollback usually doesn’t exist)

Compensating actions (roll forward) — example

Most production writes don’t have real rollback. If the agent wrote the wrong thing, you usually roll forward with a compensating write.

Example: the agent incorrectly closed tickets. A compensating action can be “reopen ticket + notify”.

PYTHON
def compensate_wrong_ticket_closures(*, ticket_ids: list[str], run_id: str, tools) -> None:
    for ticket_id in ticket_ids:
        tools.call("ticket.reopen", args={"ticket_id": ticket_id, "note": f"Reopened by compensation (run_id={run_id})"})  # (pseudo)
        tools.call(
            "email.send",
            args={
                "to": "requester@example.com",
                "subject": f"Correction: ticket {ticket_id} was closed by mistake",
                "body": f"We reopened ticket {ticket_id}. Sorry — this was an automated error (run_id={run_id}).",
            },
        )  # (pseudo)

Hard invariants (non-negotiables)

Stop writing “should”. Make it executable.

  • If a tool is a write and there’s no approval → stop the run (stop_reason="approval_required").
  • If a write would execute without an idempotency key → hard fail (or inject it in the gateway deterministically).
  • If tenant_id / env is not taken from authenticated context → stop.
  • If a write tool is called more than once with the same args hash → stop (stop_reason="duplicate_write").
  • If invalid tool output would be used to decide a write → stop (stop_reason="invalid_tool_output").

Policy gate vs approval (don’t mix these up)

These are different controls:

  • Policy gate: deterministic enforcement (allowlist, budgets, tool permissions, tenant scope).
  • Approval: a human saying “yes” for a specific action.

If you blur them together, you get the worst of both worlds: a prompt-based “policy” and a UX nightmare.

Diagram
Production control flow (read vs write)

Implementation example (real code)

This example fixes the three concrete footguns:

  1. Set vs array bug (JS uses Set.has, not includes)
  2. Approval continuation (async approve → resume a run)
  3. Idempotency hash circularity (hash ignores injected fields)
PYTHON
from __future__ import annotations

from dataclasses import dataclass
import hashlib
import hmac
import json
from typing import Any


READ_TOOLS = {"search.read", "kb.read", "http.get"}
WRITE_TOOLS = {"ticket.close", "email.send", "db.write"}


def stable_json(obj: Any) -> bytes:
  return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def args_hash(args: dict[str, Any]) -> str:
  # IMPORTANT: hash ignores fields injected by the gateway.
  filtered = {k: v for k, v in args.items() if k not in {"idempotency_key", "approval_token"}}
  return hashlib.sha256(stable_json(filtered)).hexdigest()[:24]


@dataclass(frozen=True)
class Policy:
  allow: set[str]
  require_approval: set[str]  # usually write tools


class Denied(RuntimeError):
  pass


@dataclass(frozen=True)
class PendingApproval:
  approval_id: str
  checkpoint: str  # signed blob the server can resume


def evaluate(policy: Policy, tool: str) -> str:
  if tool not in policy.allow:
      raise Denied(f"not_allowed:{tool}")
  if tool in WRITE_TOOLS and tool in policy.require_approval:
      return "approve"
  return "allow"


def sign_checkpoint(payload: dict[str, Any], *, secret: bytes) -> str:
  raw = stable_json(payload)
  sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
  return sig + "." + raw.decode("utf-8")


def verify_checkpoint(blob: str, *, secret: bytes) -> dict[str, Any]:
  sig, raw = blob.split(".", 1)
  expected = hmac.new(secret, raw.encode("utf-8"), hashlib.sha256).hexdigest()
  if not hmac.compare_digest(sig, expected):
      raise Denied("bad_checkpoint_signature")
  return json.loads(raw)


def request_approval(*, tenant_id: str, tool: str, args_preview: dict[str, Any]) -> str:
  # Return an approval id/token from your approval system.
  # (pseudo)
  return "appr_31ac"


def call_tool(*, ctx: dict[str, Any], policy: Policy, tool: str, args: dict[str, Any], secret: bytes) -> Any:
  """
  Execute a tool with governance.

  CRITICAL:
  - tenant_id/env come from authenticated context (ctx), never from model output.
  - writes require approval and resume via checkpoint.
  """
  tenant_id = ctx["tenant_id"]
  env = ctx["env"]
  run_id = ctx["run_id"]
  step_id = ctx["step_id"]

  decision = evaluate(policy, tool)

  if decision == "approve":
      approval_id = request_approval(
          tenant_id=tenant_id,
          tool=tool,
          args_preview={"args_hash": args_hash(args), "args": {k: v for k, v in args.items() if k != "body"}},
      )
      checkpoint = sign_checkpoint(
          {
              "run_id": run_id,
              "step_id": step_id,
              "tenant_id": tenant_id,
              "env": env,
              "tool": tool,
              "args": args,
              "args_hash": args_hash(args),
              "kind": "tool_call",
          },
          secret=secret,
      )
      return PendingApproval(approval_id=approval_id, checkpoint=checkpoint)

  # Deterministic idempotency key injection for writes (gateway-owned, not model-owned).
  if tool in WRITE_TOOLS:
      base_hash = args_hash(args)
      args = {**args, "idempotency_key": f"{tenant_id}:{tool}:{base_hash}"}

  creds = load_scoped_credentials(tool=tool, tenant_id=tenant_id, env=env)  # (pseudo) NEVER from model
  return tool_impl(tool, args=args, creds=creds)  # (pseudo)


def resume_after_approval(*, checkpoint: str, approval_token: str, secret: bytes) -> dict[str, Any]:
  """
  Continuation pattern:
  - verify signed checkpoint
  - attach approval token
  - execute exactly once (idempotent)
  """
  payload = verify_checkpoint(checkpoint, secret=secret)
  tool = payload["tool"]
  args = payload["args"]

  # Keep hash stable by putting approval_token outside args hashing.
  args = {**args, "approval_token": approval_token}

  if tool in WRITE_TOOLS:
      base_hash = payload["args_hash"]
      args = {**args, "idempotency_key": f"{payload['tenant_id']}:{tool}:{base_hash}"}

  creds = load_scoped_credentials(tool=tool, tenant_id=payload["tenant_id"], env=payload["env"])  # (pseudo)
  out = tool_impl(tool, args=args, creds=creds)  # (pseudo)
  return {"status": "ok", "run_id": payload["run_id"], "step_id": payload["step_id"], "tool": tool, "result": out}
JAVASCRIPT
import crypto from "node:crypto";

const READ_TOOLS = new Set(["search.read", "kb.read", "http.get"]);
const WRITE_TOOLS = new Set(["ticket.close", "email.send", "db.write"]);

function stableJson(obj) {
return JSON.stringify(obj, Object.keys(obj).sort());
}

export function argsHash(args) {
// IMPORTANT: hash ignores fields injected by the gateway.
const filtered = {};
for (const [k, v] of Object.entries(args || {})) {
  if (k === "idempotency_key" || k === "approval_token") continue;
  filtered[k] = v;
}
return crypto.createHash("sha256").update(stableJson(filtered), "utf8").digest("hex").slice(0, 24);
}

export class Denied extends Error {}

export function evaluate(policy, tool) {
// policy.allow / policy.requireApproval are Sets (use .has)
if (!policy.allow.has(tool)) throw new Denied("not_allowed:" + tool);
if (WRITE_TOOLS.has(tool) && policy.requireApproval.has(tool)) return "approve";
return "allow";
}

export function signCheckpoint(payload, { secret }) {
const raw = JSON.stringify(payload);
const sig = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
return sig + "." + raw;
}

export function verifyCheckpoint(blob, { secret }) {
const [sig, raw] = blob.split(".", 2);
const expected = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Denied("bad_checkpoint_signature");
return JSON.parse(raw);
}

export function requestApproval({ tenantId, tool, argsPreview }) {
// (pseudo) send to your approval system, return approval id
return "appr_31ac";
}

export function callTool({ ctx, policy, tool, args, secret }) {
const { tenant_id: tenantId, env, run_id: runId, step_id: stepId } = ctx;
const decision = evaluate(policy, tool);

if (decision === "approve") {
  const approvalId = requestApproval({
    tenantId,
    tool,
    argsPreview: { args_hash: argsHash(args), args: args },
  });

  const checkpoint = signCheckpoint(
    {
      run_id: runId,
      step_id: stepId,
      tenant_id: tenantId,
      env,
      tool,
      args,
      args_hash: argsHash(args),
      kind: "tool_call",
    },
    { secret },
  );

  return { status: "needs_approval", approval_id: approvalId, checkpoint };
}

if (WRITE_TOOLS.has(tool)) {
  const baseHash = argsHash(args);
  args = { ...args, idempotency_key: tenantId + ":" + tool + ":" + baseHash };
}

const creds = loadScopedCredentials({ tool, tenantId, env }); // (pseudo) NEVER from model
return toolImpl(tool, { args, creds }); // (pseudo)
}

export function resumeAfterApproval({ checkpoint, approvalToken, secret }) {
const payload = verifyCheckpoint(checkpoint, { secret });
const tool = payload.tool;

// Keep hash stable by putting approval_token outside args hashing.
let args = { ...payload.args, approval_token: approvalToken };

if (WRITE_TOOLS.has(tool)) {
  const baseHash = payload.args_hash;
  args = { ...args, idempotency_key: payload.tenant_id + ":" + tool + ":" + baseHash };
}

const creds = loadScopedCredentials({ tool, tenantId: payload.tenant_id, env: payload.env }); // (pseudo)
const out = toolImpl(tool, { args, creds }); // (pseudo)
return { status: "ok", run_id: payload.run_id, step_id: payload.step_id, tool, result: out };
}
Insight

Throwing an exception is fine internally. The missing piece is: your system must persist state and resume.


Example failure case (composite)

Incident

🚨 Incident: Mass ticket closure

System: Support agent with ticket.close enabled by default
Duration: ~35 minutes
Impact: 62 tickets incorrectly closed


What happened

Agent had ticket.close enabled by default.
No approval gate. No idempotency key. No audit trail you can trust.

Users pasted template including: “this is resolved, please close”.

The model complied.


Fix

  1. Default-deny allowlist; read-only tools by default
  2. Approvals for ticket.close and anything user-visible
  3. Idempotency keys for writes (gateway-owned)
  4. Audit logs: run_id, tool, args_hash, approval actor

Trade-offs

Trade-offs
  • Approvals add friction and latency.
  • Default-deny slows adding new tools.
  • Idempotency + audit logs take engineering effort.

All of that is still cheaper than cleaning up irreversible writes at 3 AM.


When NOT to use

Don’t
  • If the action is deterministic and high-stakes (billing, account deletion), don’t let a model drive it at all.
  • If you can’t scope credentials by tenant/environment, don’t ship tool calling in multi-tenant prod.
  • If you can’t audit, you can’t operate.

Copy-paste checklist

Production checklist
  • [ ] Default-deny allowlist (read-only by default)
  • [ ] Separate read vs write tools
  • [ ] Policy gate in code (not in the prompt)
  • [ ] Approvals for irreversible / user-visible writes
  • [ ] Continuation pattern (approve → resume from checkpoint)
  • [ ] Deterministic idempotency keys for writes
  • [ ] Tenant + environment scoped credentials (boundary owned by code)
  • [ ] Audit logs: run_id, tool, args_hash, approval actor
  • [ ] Kill switch that disables writes fast

Safe default config

YAML
tools:
  default_mode: "read_only"
  allow: ["search.read", "kb.read", "http.get"]
writes:
  enabled: false
  require_approval: true
  idempotency: "gateway_inject"
credentials:
  scope: { tenant: true, environment: true }
kill_switch:
  mode_when_enabled: "disable_writes"

FAQ

FAQ
Can't we just tell the model to never write?
You can tell it. You can’t enforce it. Enforce writes in the tool gateway.
What writes should require approval?
Anything irreversible or user-visible: emails, closing tickets, billing, deleting records.
Do idempotency keys matter if we have approvals?
Yes. Approvals block bad intent. Idempotency prevents duplicate execution and retry storms.
How do we resume after approval?
Persist a signed checkpoint (tool + args + run/step ids), wait for approval, then resume exactly once with an idempotency key.

Related

Production takeaway

Production takeaway

What breaks without this

  • ❌ Irreversible writes that look “successful”
  • ❌ Duplicate actions from retries
  • ❌ Multi-tenant blast radius you can’t bound
  • ❌ No audit trail when support asks “what happened?”

What works with this

  • ✅ Writes are gated (policy + approvals)
  • ✅ Idempotency makes retries safe
  • ✅ Tenant scoping limits damage
  • ✅ You can replay and explain incidents

Minimum to ship

  1. Default read-only (write tools explicitly opt-in)
  2. Policy gate (deny by default, enforced in code)
  3. Approvals (for user-visible or irreversible writes)
  4. Continuation (approve → resume with checkpoint)
  5. Idempotency keys (gateway-owned)
  6. Tenant scoping + audit logs
  7. Kill switch (disable writes in emergencies)

Not sure this is your use case?

Design your agent ->
⏱️ 12 min read • Updated Mar, 2026Difficulty: ★★★
Implement in OnceOnly
Safe defaults for tool permissions + write gating.
Use in OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Integrated: production controlOnceOnly
Add guardrails to tool-calling agents
Ship this pattern with governance:
  • Budgets (steps / spend caps)
  • Tool permissions (allowlist / blocklist)
  • Kill switch & incident stop
  • Idempotency & dedupe
  • Audit logs & traceability
Integrated mention: OnceOnly is a control layer for production agent systems.
Author

This documentation is curated and maintained by engineers who ship AI agents in production.

The content is AI-assisted, with human editorial responsibility for accuracy, clarity, and production relevance.

Patterns and recommendations are grounded in post-mortems, failure modes, and operational incidents in deployed systems, including during the development and operation of governance infrastructure for agents at OnceOnly.