How a read-only support agent earns the right to write — refunds, tickets, deletions — without doubling them, leaking its keys, or failing its user in silence.
A read is consequence-free; a write can spend real money or destroy real data. The danger is almost never a bug in the agent's reasoning — it is the missing engineering discipline around the write itself: an idempotency key so a retry can't double-fire, a state guard so the agent doesn't retry blindly, credentials the model never sees, structured metadata coming back, and a graceful answer when the write fails. Controls scale with irreversibility.
| retry behaviour | → deterministic idempotency key |
| no dup awareness | → key = hash(thread_id + title + body) |
| no state check | → internal state guard on findings |
⭐Set tool timeout > max documented API time + buffer. GitHub p99 3s → timeout 8–10s. Timing out early & retrying is worse than waiting a bit too long.
| T1 reversible | GitHub issue, CRM ticket, calendar → idempotency key + structured return + write log |
| T2 partial | email, CRM update, queue → +human-in-loop confirm + rate-limit / thread |
| T3 irreversible | refund, DB delete, deploy → +hard approval node + signing + dual audit log |
Rule: engineering investment ∝ irreversibility.
@tool · docstring (does / when / return-shape) · Pydantic args · body w/ cred injection · structured return. Bind minimum tools per agent (accuracy + security).
| ✗ timestamp | changes → breaks determinism |
| ✗ uuid4() | random → every retry looks new |
| ✗ internal state only | fine until API down / swapped → single point |
| state guard | 1st line — stops the retry decision (stuttering) |
| server key | last line — stops 2 concurrent calls hitting API |
Complementary, not redundant.
200 alone = output vanishes. Return: success · issue_number · issue_url · repository · created_at · idempotency_key.
Analogy: wire transfer confirmation — ref no, amount, SWIFT code. "Transfer completed" alone = negligent.
| 401 / 403 | bad token / bad perm → no retry |
| 404 | wrong repo/owner → no retry |
| 422 | bad field → fix & retry |
| timeout / 500 | transient → retry (backoff) |
Degrade well = (1) admit unresolved (2) still give diagnosis (3) say escalation failed (4) give a next action. Failing silently fails the user twice.
💡Write-finding confidence = 1.0 always = "confirmed fact" (success or failure), not a probability. Type carries outcome; confidence carries certainty.
A support agent runs read-only for three months, handling 10,000 tickets a day. Flawless. One sprint, the team gives it a single new power: issue partial refunds through the payment gateway when a billing error is confirmed. It deploys Tuesday morning. By Thursday afternoon the billing team spots something odd — 47 customers received double refunds over the last 48 hours, roughly $23,000 gone. No attacker. No bug in the refund logic. The agent behaved exactly as designed. What it lacked was the engineering discipline that writing to the world demands.
That is this whole lesson. We will build up the controls on a deliberately low-stakes write — creating a GitHub issue — then see how the same patterns harden the operations that can actually hurt you.
The session also cited a widely-discussed incident where an AI coding agent wiped a company's production database and its backups in seconds. The point is sound and worth remembering. The specific model and company names given in the recording appear garbled, so treat the attribution as unreliable — memorise the lesson (an unguarded write can be catastrophic and instant), not the names.
An operation is idempotent when doing it many times leaves the system in exactly the same state as doing it once. Reads are idempotent by nature: ask for a customer's balance five times and you get the same number five times, and nothing in the world changed. Writes usually are not: submit the same refund five times and you have debited — or credited — five times.
Calling a lift is a read: press the button once or ten times, the same lift still arrives at the same floor. Submitting a wire transfer is a write: press "send" twice by mistake and the customer's account is debited twice, and now you are on the phone to the receiving bank initiating a recall. The physical act of reading is consequence-free; the act of writing is not.
In HTTP terms, GET is idempotent and POST is not. This
asymmetry decides how a failure hurts you.
Read-vs-write is not a performance question, it is a correctness and liability question. That reframing is the reason a write needs machinery a read never does.
Write amplification is when an agent fires the same write more times than intended. It is worth being precise about what it is not: not a wrong decision (a logic bug), not a malformed request (an integration bug). The agent made the right call and formed the request correctly — the execution layer simply lacked the control to stop a duplicate from firing. The nickname is the stuttering agent: it repeats itself not from malice or error, but because it never heard confirmation that the first attempt landed.
Here is the exact chain, because every link matters. LLM agents are unusually exposed to this: an inference step in a production loop already takes seconds, and the external write API adds its own latency — seconds under normal load, longer at peak (think a sale day when everyone is checking out at once).
The cruel part: the agent only ever sees the second success and reports one issue URL to the user. The first issue is a ghost — sitting in the repo with no owner, no reference from the application, no log of its existence, so the normal workflow will never close it.
At a conservative 0.1% duplicate-fire rate over 10,000 tickets/day, that is 10 bad writes a day, ~300 erroneous state mutations a month. No unit test catches them, because each individual tool call is correct. These are architectural failures: correct actions combining into an incorrect aggregate.
Three things conspire, and you have to fix all three — defence in depth. Fixing one or two is not enough for production.
On a timeout the agent retries. Fix: a deterministic idempotency key the API can dedupe on.
The tool never checks whether this op already succeeded. Fix: key = hash(thread_id + title + body).
The loop can't see a prior success in its own state. Fix: an internal state guard on findings.
Always set the tool timeout higher than the target API's maximum documented response time, plus a conservative buffer. If GitHub documents a p99 of 3 seconds, set the timeout to 8–10 seconds. A tool that times out before the API answers and then retries is far more dangerous than one that waits slightly too long — the first creates duplicates, the second just costs a little latency.
The agent treats a missing confirmation as a failed call. It has no way to distinguish "the write failed" from "the write succeeded but I didn't hear back in time." Never let retry logic assume a timeout means nothing happened — that assumption is exactly what doubles the refund.
Match the controls to how hard the write is to undo. Applying maximum controls to every write wastes effort on the harmless ones and, worse, tempts you to under-protect the dangerous ones. The discipline is a risk-proportionate taxonomy: classify each write by reversibility, then apply the minimum controls that make that tier production-safe.
| Tier | Examples | Required controls (cumulative) |
|---|---|---|
| T1 · reversible | GitHub issue, CRM support ticket, comment on a record, calendar event | Idempotency key + structured return metadata + write logging so duplicates are detectable. Lightweight — one engineer, an afternoon. |
| T2 · partially reversible | Send an email (can't be unsent), update/create a CRM record (prior value lost without a changelog), post to a queue (may already be consumed) | All of T1 plus human-in-the-loop confirmation before execution (show the operator the exact draft / field change) + rate limiting per thread ID. |
| T3 · irreversible | Payment refund or charge, delete a DB record with no backup, trigger a deployment | All of T2 plus a hard human-approval node in the graph + cryptographic action signing (non-repudiation) + dual-write audit logging. |
For T3, the human approval is a hard node in the LangGraph topology that pauses execution and waits for an explicit sign-off — not a confirmation sentence in the prompt that the model could talk itself past. If the agent can reinterpret it, it isn't a control.
Give the LLM exactly the information it needs to make a correct tool decision, and nothing else. In practice that is an absolute rule: the model never sees API keys, tokens, or secrets of any kind. Split the concerns cleanly — the semantic arguments (issue title, body, repository label) are the model's domain; the authentication (who is allowed to perform this) is the Python runtime's domain. These two never cross.
The model's inputs and outputs are logged everywhere in production: the LangChain tool name and arguments
appear in the callback trace, in the LangSmith run tree, in the LangGraph checkpoint persisted by MemorySaver, and in the provider's request logs. A key routed through tool arguments
has just been written to four-plus storage systems. It won't leak immediately — it will leak
inevitably, through the cumulative exposure surface of all of them.
So the credential is read from the environment at module load time, never passed as a function argument and never in the tool's signature.
import os
# Read ONCE, at import time. If it's missing, the service fails to
# start — the deploy pipeline catches it, not a user at 2am.
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
if not GITHUB_TOKEN:
raise RuntimeError("GITHUB_TOKEN is not set; refusing to start.")
# The token is captured here in the runtime's scope. It is NEVER a
# parameter of the tool the LLM calls, so it can never appear in a
# tool-call argument, a trace, or a checkpoint.
If the "is the token set?" check lives inside the tool function, a missing token fails the first time a user submits a ticket in production — probably when no engineer is watching. Move the check to module load: the service fails to initialise, the deploy pipeline catches it, the error is unambiguous, and no user ever reaches the misconfigured system.
Every serious write tool is built from the same five pieces, each doing a distinct job: the
@tool decorator, a docstring, a Pydantic argument schema, a function body with
runtime credential injection, and a structured return value.
The docstring is not developer documentation — it is the tool's specification for the model, so it must carry three things: what the tool does (imperative), when to call it, and the exact shape of the return value on success and on failure. The Pydantic schema earns its place two ways: it type-validates arguments before the API is ever touched (a malformed key is rejected with a clear error, not a failed API call), and its field descriptions are what the model reads while filling the arguments in.
from langchain_core.tools import tool
from pydantic import BaseModel, Field
class CreateIssueArgs(BaseModel):
title: str = Field(description="Short, specific issue title.")
body: str = Field(description="Structured description of the technical problem.")
idempotency_key: str = Field(
description="32-char hex uniquely identifying THIS logical request."
)
@tool(args_schema=CreateIssueArgs)
def create_github_issue(title: str, body: str, idempotency_key: str) -> dict:
"""Create a GitHub issue in the engineering bug-tracker repo.
Call this ONLY when the knowledge base returned no resolution for a
confirmed technical issue needing engineering. Do NOT call it for
billing questions, account questions, or fraud reports.
On success returns: success, issue_number, issue_url, repository,
created_at, idempotency_key.
On failure returns: success=False, error_type, message.
"""
... # body uses the module-level GITHUB_TOKEN; see 2.4
Finally, bind each agent only to the tools it is authorised to use — tool scope isolation. The triage agent classifies tickets and has no business creating issues; the fraud agent has no business querying the bug tracker; the synthesizer has no tools at all.
| Agent / node | Tool binding |
|---|---|
| Ingress · classify · handoff · synthesizer | none |
| Tech analysis agent | knowledge-base search, and create_github_issue for high/critical findings |
| Fraud analysis agent | fraud-signal lookup only |
Binding fewer tools isn't only about limiting blast radius. Agents given fewer, more relevant tools make more accurate tool selections. Scope isolation improves behaviour and safety at once.
An idempotency key is a unique, deterministic identifier for one logical operation, sent to the API alongside the write. The API stores the key with the result of processing it; if a later request arrives carrying the same key, the API returns the stored result instead of doing the work again. That is the whole mechanism — and note it is something we design, not a magic LLM behaviour.
To be correct, a key must satisfy two properties at once:
Two different tickets that each need an issue must produce different keys. A constant/static key makes every call after the first look like a duplicate — nothing new is ever created.
The same logical op must always produce the same key. A retry has to generate the same key as the original so the API recognises it as a repeat.
LangGraph hands you a natural base: the thread_id. It is unique per conversation (uniqueness across threads) and stable for every call within that thread (determinism on retry). When one thread might legitimately create several issues — a complex ticket touching multiple components — fold the operation's content into the key too.
import hashlib
def generate_idempotency_key(thread_id: str, title: str, body: str) -> str:
"""Same inputs -> same key (deterministic). Different content ->
different key (unique per logical op). A retry reproduces it exactly."""
raw = f"{thread_id}|{title}|{body}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
The anti-patterns all fail one of the two properties — learn to spot them on sight in review.
| Anti-pattern | Why it breaks |
|---|---|
| datetime.now() timestamp | Breaks determinism — the same op at two moments yields two timestamps, so the API sees two distinct ops. The classic mistake of someone who gets idempotency but not determinism. |
| uuid.uuid4() | Random, unrelated to content. Original + two retries = three different UUIDs = three "distinct" ops. Same failure. |
| Internal state check only | Appears to work — until the API is unavailable (can't check the stored key) or a different API with weaker idempotency support is swapped in. Safety resting on a single mechanism. |
# WRONG — non-deterministic; a retry never matches the original
key = str(uuid.uuid4()) # random every call
key = datetime.now().isoformat() # different every millisecond
# RIGHT — deterministic hash of the logical operation's content
key = generate_idempotency_key(thread_id, title, body)
The internal state guard and the server-side idempotency key defend against different failures, so you keep both. They look redundant; they are complementary.
Defends the case where the agent makes a deliberate retry after reading a timeout in its context. It checks the findings for an existing issue and, if present, never builds the tool call — it just reads the existing URL. Eliminates the network round-trip, API cost, and the very timeout window that triggers retries.
Defends the case where two calls reach GitHub at the same instant — e.g. the graph runs with parallelism and the same agent is processed twice concurrently. The guard can't see that; the key makes GitHub reject the second.
def github_tool_node(state: dict) -> dict:
draft = state.get("github_draft")
if not draft:
return {"github_issue_url": ""} # nothing to do
# 1st line of defence: has this thread already produced the issue?
for f in state.get("findings", []):
if f.get("agent_finding_type") == "github_issue_created":
return {"github_issue_url": f["issue_url"]} # reuse, don't refire
key = generate_idempotency_key(THREAD_ID, draft["title"], draft["body"])
return create_github_issue.invoke({**draft, "idempotency_key": key})
A write that returns only "success" throws its own output into a void. A bare 200 tells the agent the mutation happened but nothing about what was created,
where to find it, or how to reference it later. The problem bites hardest for operations that create
addressable resources — things with an id and a URL a user could open.
If the tool returns "issue created successfully" and stops, that information is gone: the user can't navigate to the issue, the synthesizer can't cite it in the final answer, the audit log can't record which issue was created, and on a re-attempt the system can't even detect the issue already exists.
When a bank clears a wire, the confirmation carries a reference number (for tracking), the exact amount (for reconciliation), a masked destination, a value date, and a SWIFT code (for compliance). Every field serves a specific downstream consumer. A confirmation that said only "transfer completed" would be treated as negligent. Your write tool's return value is held to the same standard.
return {
"success": True,
"issue_number": issue.number, # int, unique in the repo
"issue_url": issue.html_url, # full URL the user can open
"repository": "owner/repo", # confirms where it landed
"created_at": issue.created_at.isoformat(), # ISO 8601, audit timeline
"idempotency_key": idempotency_key, # ties response to THIS op
}
| Field | Consumer it serves |
|---|---|
| success | Callers branching on outcome. |
| issue_number | Lookup without the full URL. |
| issue_url | The user — an artifact to open, bookmark, share. |
| repository | Audit and routing — confirms which repo received it. |
| created_at | Audit timeline; correlation with other events. |
| idempotency_key | Lets the caller verify this response matches the op it intended — not a cached result from a different op. |
A production write tool must sort failures into "retry" and "don't retry," because getting it wrong is catastrophic. Retry a transient blip and you recover; retry an authentication failure and you spin an infinite loop against a wall.
| Status | Meaning | Retry? |
|---|---|---|
| 401 Unauthorized | Token invalid / expired / lacks scope. | No — same token, same 401 forever. Fix the token. |
| 403 Forbidden | Token valid but lacks permission on the repo. | No — fix the token's permission. (401 vs 403 tells ops which to fix.) |
| 404 Not Found | Repo doesn't exist / no visibility. A config error. | No — return the repo path so ops can diagnose. |
| 422 Unprocessable | Title/body violates validation (too long, prohibited, missing field). | Yes, after fixing — surface GitHub's message so the agent corrects the field. |
| timeout / 5xx | Transient overload or network blip. | Yes — retry with backoff (and the same idempotency key). |
try:
issue = repo.create_issue(title=title, body=body)
except GithubException as e:
if e.status in (401, 403):
return {"success": False, "error_type": "authentication_failure",
"message": "Token invalid or lacks permission.", "retry": False}
if e.status == 404:
return {"success": False, "error_type": "not_found",
"message": f"Repository {REPO} not found.", "retry": False}
if e.status == 422:
return {"success": False, "error_type": "validation_error",
"message": str(e.data), "retry": True} # fix field, retry
return {"success": False, "error_type": "server_error",
"message": "Transient failure.", "retry": True} # 5xx / timeout
Degrading well is not hiding the failure; the user should know an automated action didn't complete. It is making sure one component's failure doesn't collapse the whole experience. A support system that crashes when GitHub is briefly down fails its user twice: no issue created, and no idea what to do next.
A good degraded response has four parts:
Both outcomes are written into the graph's shared state as a finding, and each carries confidence: 1.0.
# success
{"agent_finding_type": "github_issue_created",
"issue_number": 412, "issue_url": "...", "repository": "owner/repo",
"created_at": "2026-09-01T...", "idempotency_key": "...", "confidence": 1.0}
# failure
{"agent_finding_type": "github_issue_error",
"error_type": "network_error", "message": "...", "confidence": 1.0}
For a write, 1.0 means "this is a confirmed fact," and it applies to failures
just as much as successes — a confirmed failure is still 1.0. Never use a
low confidence to represent a failed write. A finding of type github_issue_created
with confidence 0.3 is incoherent — "created, but only 30% sure"? The type
carries the outcome; the confidence carries the certainty of the finding.
Every ticket flows through the same graph. Personally-identifying data is masked up front; only technical tickets ever reach the write tool, and only high/critical findings populate a draft.
A read is idempotent, so a repeat just wastes tokens and time and returns the same data — the failure mode is inefficiency. A write is not idempotent, so a repeat mutates state again: a second refund, a second record, a second email. The damage is irreversible and scales with the number of retries.
The API was slow (say 6s) but the tool node timed out first (5s) and returned "timeout." The agent read that as a failure and retried with identical arguments. Both calls actually succeeded. The agent only saw the second success, so it reported one URL; the first issue is a ghost with no owner, no reference, and no application log.
Both break determinism. A UUID is random and a timestamp changes every moment, so a retry of the same logical operation produces a different key. The external API then sees each attempt as a brand-new operation and processes all of them — exactly the duplication the key was meant to prevent. The key must be a deterministic function of the operation's content.
They cover different failures. The guard stops the agent from even building a retry after a timeout, and it saves the round-trip, API cost, and timeout window. The server key catches genuinely concurrent calls that both reach the API. Relying only on the guard leaves concurrency unprotected; relying only on the server key leaves you exposed when the API is down or gets swapped for one with weaker idempotency support.
Two reasons. First, exposure: a credential passed as a tool argument gets logged in the callback trace, the LangSmith run tree, the checkpoint, and the provider logs — effectively compromised. Second, failure timing: validating the token at import means a missing credential fails at startup where the deploy pipeline catches it, instead of failing on a live ticket at 2am.
Do not retry 401/403 (bad token or permission) or 404 (wrong repo) — a retry produces the identical error and, for auth, risks an infinite loop. Retry 422 only after correcting the offending field, using GitHub's validation message. Retry timeouts and 5xx with backoff, reusing the same idempotency key so the retry can be deduped.
(1) Acknowledge the issue can't be resolved immediately; (2) still deliver the full technical diagnosis already produced; (3) state that automatic escalation was attempted and is temporarily unavailable; (4) give the user a concrete next action, such as contacting support and quoting the thread ID. It informs rather than hides, and leaves the user with a path forward.
Because on a write the confidence field is a fact marker, not a probability. A confirmed success and a confirmed failure are both certain — both 1.0. The finding type carries the outcome; the confidence carries how sure we are of that outcome. A "created" finding at 0.3 confidence is meaningless.
Controls escalate with irreversibility. Tier 1 (reversible, e.g. a GitHub issue) needs an idempotency key, structured return, and write logging. Tier 2 (partially reversible, e.g. email) adds human-in-the-loop confirmation and per-thread rate limiting. Tier 3 (irreversible, e.g. a refund or DB delete) adds a hard human-approval node in the graph, cryptographic signing, and dual-write audit logging.
Write make_key(thread_id, title, body) that is safe to use as an idempotency key.
Requirements:
Expected: make_key("t1","A","B") == make_key("t1","A","B") is True, and make_key("t1","A","B") != make_key("t1","A","C") is True. A UUID or timestamp inside must fail these checks.
Step 1 — pin down what "same operation" means. The logical operation is identified by the thread it belongs to plus the content being written. So the key must be a pure function of exactly those inputs and nothing else — no clock, no randomness.
Step 2 — pick a deterministic transform. A cryptographic hash maps the same bytes to the same digest every time, and different bytes to a different digest with overwhelming probability. That gives determinism and uniqueness in one step.
Step 3 — join the inputs unambiguously. Use a separator that won't blur field boundaries, so ("ab","c") and ("a","bc") don't collide.
import hashlib
def make_key(thread_id: str, title: str, body: str) -> str:
raw = f"{thread_id}|{title}|{body}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert make_key("t1", "A", "B") == make_key("t1", "A", "B") # deterministic
assert make_key("t1", "A", "B") != make_key("t1", "A", "C") # content-sensitive
This node is supposed to be safe against retries. The API correctly implements server-side idempotency: it dedupes on the idempotency_key it receives. Yet under load, tickets still produce two issues. Predict the exact behaviour, name the root cause, and fix it.
import uuid
def github_node(state):
draft = state["github_draft"]
key = str(uuid.uuid4()) # "unique" key per call
return create_github_issue.invoke(
{**draft, "idempotency_key": key}
)
Predicted failure: on a timeout-triggered retry, github_node runs again and calls uuid.uuid4() a second time, producing a different key. The server has no record of that new key, so it treats the retry as a brand-new operation and creates a second issue. Two issues, both "successful."
Root cause: the key is non-deterministic. Server-side idempotency can only dedupe when the retry presents the same key as the original — a fresh UUID per call defeats it entirely. The server was never the problem; the key generation was.
Step 1 — replace the random key with a deterministic hash of the operation's content (thread + draft).
Step 2 — add the internal state guard so a retry doesn't even reach the API.
def github_node(state):
draft = state["github_draft"]
# 1st line: already created on this thread? reuse it.
for f in state.get("findings", []):
if f.get("agent_finding_type") == "github_issue_created":
return {"github_issue_url": f["issue_url"]}
# deterministic key -> retry reproduces it -> server dedupes
key = make_key(state["thread_id"], draft["title"], draft["body"])
return create_github_issue.invoke({**draft, "idempotency_key": key})
The GITHUB_TOKEN in production was rotated but not updated in the environment. Predict what this retry wrapper does when it runs, name the root cause, and fix the retry policy.
def create_with_retry(draft, key, attempts=5):
for _ in range(attempts):
try:
return repo.create_issue(draft["title"], draft["body"])
except GithubException:
continue # something failed, just try again
return {"success": False}
Predicted failure: every attempt raises a GithubException with status 401 (the token is invalid). The wrapper swallows it and retries all 5 times — five identical, doomed API calls — then returns a bare {"success": False} with no diagnosis. If attempts were unbounded, it would loop forever.
Root cause: the wrapper treats all errors as transient. A 401 is permanent — the same invalid token produces the same 401 on every attempt, so retrying can never succeed and only wastes calls (and hides the real problem).
Step 1 — branch on status: 401/403/404 are permanent (don't retry, return an actionable error); 422 is retryable only after fixing the field; 5xx and timeouts are transient (retry with backoff).
NO_RETRY = {401, 403, 404}
def create_with_retry(draft, key, attempts=5):
for _ in range(attempts):
try:
return repo.create_issue(draft["title"], draft["body"])
except GithubException as e:
if e.status in NO_RETRY:
return {"success": False, "error_type": "auth_or_config",
"message": f"HTTP {e.status}: fix token/repo, not a retry."}
# transient (5xx/timeout): fall through and retry with backoff
return {"success": False, "error_type": "exhausted", "message": "Gave up."}