Agentic Systems · Write Access
How to give a LangGraph multi-agent system the power to write to the outside world — create issues, send mail, issue refunds — without a slow API turning one action into fifty.
A read you repeat by accident wastes a few tokens. A write you repeat by accident bills a customer twice. The danger isn't a bug in your logic — a perfectly-reasoned, perfectly-formed write can still fire twice when the API is slow. Safety comes from idempotency controls around the call, not from the decision to make it.
The whole lesson, compressed for copying by hand. Every point below is unpacked in the Detailed Notes.
A support system runs flawlessly for three months. It reads customer records, searches a knowledge base, classifies tickets — ten thousand a day, every action read-only, never a wrong move. Then one sprint adds a single capability: the agent can now issue partial refunds through the payment API when it confirms a billing error. It ships Tuesday. By Thursday the billing team notices forty-seven customers were refunded twice, roughly twenty-three thousand dollars gone, over forty-eight hours. No attacker. No bug in the refund logic. The agent did exactly what it was designed to do. What it lacked was the discipline that write access quietly demands — and that discipline is the whole of this lesson.
A read can be repeated harmlessly; a write cannot. This is the asymmetry every write-capable agent is built on. Everything you have wired up so far — pulling a record from Mongo, running a text-to-SQL query, classifying a ticket — is a read. Reads are idempotent: run one five times and you get the same answer five times and the system on the other end is untouched. That is a plain-English way of saying an operation you can safely repeat.
Writes break that promise. The formal test: an operation is idempotent only if executing it N times leaves
exactly the same system state as executing it once. An HTTP GET passes — five identical GETs, same
data, no change. An HTTP POST fails — five identical POSTs typically create five records, charge
five times, send five notifications.
Calling a lift is idempotent — jab the button five times and the same lift still comes; the state doesn't change. Submitting a wire transfer is not. Query a balance twice and you've just read the same number twice. Submit a transfer twice and the account is debited twice, and now you're phoning the receiving bank, filing a recall, and updating ledgers on both sides.
The read-vs-write line is not a performance concern, it's a correctness and liability concern. No unit test catches these failures, because each individual write is correct. What's wrong is the aggregate — many correct calls combining into an incorrect outcome. At 10,000 tickets/day, a mere 0.1% duplicate-fire rate is 10 bad writes a day, ~300 erroneous mutations a month, quietly accumulating.
Write amplification is when an agent fires the same write more times than it meant to — with no logic bug and no malformed call. The decision was right and the request was well-formed; the execution layer simply lacked the control to stop a duplicate. The behavioral name for the agent doing this is the stuttering agent: it repeats itself not from malice but because it never heard a clear confirmation that the first attempt was received.
You scan to pay at a shop, the screen hangs, and — half from embarrassment — you toggle airplane mode and pay again. Years ago that meant two deductions. Today it usually doesn't, because banks added idempotency checks: the second attempt carries the same identity as the first, so the backend recognizes it and refuses to double-charge. Your agent needs the exact same trick.
Here is the precise causal chain, because you'll want to recognize it on sight. The agent decides an
engineering escalation is needed and calls create_github_issue. GitHub is under peak load, so the
API takes 6 seconds. The LangGraph tool node is configured with a 5-second timeout. At 5s it gives up and
returns a timeout message to the agent. The agent's working memory is just its message history, and
that history now reads "attempt, then timeout" — indistinguishable from a genuine failure. It reasons, "my call
failed, I should retry," and fires the identical call again. Both calls eventually succeed. Two identical issues
exist. The agent only ever saw the second success, reports one issue, and the first sits in the repo as a
ghost: no owner, no reference from the app, invisible to the normal resolution flow.
Three factors must all be controlled — fixing one or two is not enough for production safety:
On a timeout the agent retries. Fix: a deterministic idempotency key so the API ignores the duplicate.
The tool never checks if this op already completed. Fix: pass thread_id + content hash as the key.
The loop never inspects its own state before retrying. Fix: an internal state guard on prior findings.
Set the tool timeout higher than the API's documented p99 response time, plus a buffer. If GitHub documents a p99 of 3s, set the tool timeout to 8–10s — never lower. A tool that times out before the API replies and then retries is far more dangerous than one that simply waits a little too long.
An idempotency key is a unique, deterministic name for one logical write, sent alongside the request so the API can recognize a repeat. The external API stores the key together 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 single mechanism is what turns "fired twice" into "fired once, acknowledged twice."
To be correct the key must satisfy two properties at the same time:
Two different tickets that each deserve an issue must produce different keys. A constant string would make every issue after the first look like a duplicate — so none would ever be created.
The same logical op must always produce the same key. A retry has to compute the identical key as the first attempt, or the API can't tell they're the same request.
LangGraph hands you a natural base for this: the thread_id, which is unique per conversation and
stable across retries within it. That satisfies both properties — until a single thread legitimately needs
several issues (one complex ticket touching multiple components). So the key must also encode the
content of the specific operation, not just the thread. The standard recipe hashes all three together:
import hashlib
def generate_idempotency_key(thread_id: str, title: str, body: str) -> str:
"""Deterministic key for one logical write.
Same inputs -> same key (retries collapse); different op -> different key."""
payload = f"{thread_id}:{title}:{body}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()
Three anti-patterns defeat idempotency, and you must catch them in review:
uuid4() key — random by design, unrelated to content; original + two retries
look like three distinct ops.Classify each write by how reversible it is, then spend controls in proportion. A system that slaps maximum controls on everything wastes effort on cheap operations and, worse, tends to under-protect the expensive ones. The fix is a risk-proportionate taxonomy with three tiers and escalating requirements.
| Tier | Reversibility | Example | Controls (cumulative) |
|---|---|---|---|
| T1 · low | Fully reversible, fully audited | Create GitHub issue, CRM ticket, calendar event, record comment | Idempotency key · structured return metadata · write logging for dup detection |
| T2 · medium | Partly reversible; may reach an external consumer | Send an email, update a CRM record, publish to a queue | All T1 · human-in-the-loop confirm before execute · rate-limit per thread_id |
| T3 · high | Irreversible; direct financial / data loss | Issue refund, delete DB record, trigger a deploy, charge a card | All T2 · mandatory approval graph node · cryptographic signing · dual audit logging |
A T3 approval is a hard node in the graph topology that pauses execution and surfaces the proposed action to a human, waiting for an explicit sign-off before the tool can fire. It is deliberately not a confirmation prompt in the conversation — the LLM can rationalize its way past a prompt, but it cannot execute past a paused graph. (The real incident behind this: an AI coding assistant wiped a startup's production database and its backups in seconds because nothing paused it.)
Give the LLM exactly what it needs to decide the call, and nothing else — never credentials. The semantic arguments (issue title, body, repo label, the idempotency key) are the model's domain. The authentication that says who is allowed to perform the operation is the Python runtime's domain. These two concerns must never cross.
Why non-negotiable: an LLM's inputs and outputs are logged everywhere. A single tool call lands in the LangChain callback trace, the LangSmith run tree, the persisted checkpoint state, and the provider's own request logs. A token passing through those tool arguments has been written to at least four storage systems, any of which could leak it. A key that passes through an LLM call is a compromised key — not immediately, but inevitably.
import os
# Injected by the runtime at MODULE LOAD, never passed through the LLM.
GITHUB_TOKEN = os.getenv("GITHUB_ACCESS_TOKEN")
if not GITHUB_TOKEN:
# Fail at startup / in CI, not on a user's ticket at 3am.
raise RuntimeError("GITHUB_ACCESS_TOKEN not set; refusing to start.")
Put the credential check at module load, not inside the tool function. Inside the function, a missing token fails only when a user submits a ticket in production — likely when nobody's watching. At module load it fails at application startup: the deploy pipeline catches it, the error is unambiguous, and no user ever reaches the misconfigured system.
A production-grade write tool has five parts, each doing one job. This is the same
tool-calling pattern you already know, tightened for a write. The parts: the @tool decorator, the
docstring, the Pydantic argument schema, the function body with runtime credential injection, and the structured
return value.
The docstring is not developer documentation — it is the specification the LLM reads to decide whether and how to call the tool. For a write tool it must carry three things: what the tool does in imperative form, when to call it, and the exact return format on success and failure. The Pydantic schema earns its place two ways: it validates argument types before the API call fires (a malformed key is rejected up front), and its per-field descriptions are what the LLM reads while constructing the values.
from langchain_core.tools import tool
from pydantic import BaseModel, Field
class CreateIssueArgs(BaseModel):
title: str = Field(description="Short imperative summary of the bug.")
body: str = Field(description="Structured technical description of the issue.")
idempotency_key: str = Field(
description="32+ char hex string uniquely identifying this request."
)
@tool(args_schema=CreateIssueArgs)
def create_github_issue(title: str, body: str, idempotency_key: str) -> str:
"""Create a GitHub issue in the engineering bug-tracker repository.
Call ONLY when knowledge-base search returned no applicable resolution
for a confirmed technical issue needing engineering intervention.
Do NOT call for billing questions, account questions, or fraud reports.
On success return JSON with: issue_number, issue_url, repository,
created_at, idempotency_key, success=true.
On failure return JSON with: success=false, error_type, message.
"""
... # body follows in 2.7
Tool scope isolation: each agent is bound (via llm.bind_tools(...)) to only the tools it's
authorized to use. The triage agent classifies and gets no write tools; the fraud agent checks signals and
can't touch the bug tracker; the synthesizer merges findings and has no tools at
all. This isn't only security — an agent with fewer, relevant tools makes more accurate tool choices.
A write that returns only "success" throws its result into a void. The operation happened, the system mutated, but the agent, the synthesizer, and any downstream consumer learn nothing about what was created, where to find it, or how to reference it. This bites hardest for operations that create addressable resources — things with an id and a URL a user could open. GitHub assigned an issue number and a full URL; if the tool returns "issue created successfully," that information is discarded before it can be used. The user can't navigate to it, the synthesizer can't cite it, the audit log can't record which issue, and a later attempt can't detect it already exists.
A bank wire confirmation never says just "done." It returns a transaction reference, the exact amount, a masked destination, a value date, and a SWIFT code — each field serving a specific downstream consumer (tracking, reconciliation, compliance). A confirmation that said only "transfer completed" would be considered negligent. Hold your write tool to the same standard.
| Field | Type | Why it exists |
|---|---|---|
issue_number |
int | Unique within the repo; lookup without the full URL |
issue_url |
string | The artifact the user can open, bookmark, share |
repository |
owner/repo | Confirms which repo received it; enables routing + audit |
created_at |
ISO 8601 | Establishes the audit timeline; correlates with other events |
idempotency_key |
hex | Confirms which logical op this response answers — not a stale cached one |
success |
bool | Lets the caller branch cleanly on outcome |
from github import Github, GithubException
import os, json
def create_github_issue(title, body, idempotency_key):
try:
repo = Github(GITHUB_TOKEN).get_repo(os.getenv("GITHUB_REPO"))
issue = repo.create_issue(title=title, body=body)
return json.dumps({ # structured metadata, no void
"success": True,
"issue_number": issue.number,
"issue_url": issue.html_url,
"repository": repo.full_name,
"created_at": issue.created_at.isoformat(),
"idempotency_key": idempotency_key,
})
except GithubException as e:
transient = e.status in (500, 502, 503) # retryable vs permanent
return json.dumps({
"success": False,
"error_type": "transient" if transient else "permanent",
"status": e.status,
"message": str(e.data),
})
A write tool must sort failures into "retry" and "don't," and always leave the user with something useful. External APIs fail in kinds: some are transient (a brief server overload that clears in seconds), some are permanent (an auth failure that needs a config change), and some are in between (a validation error the agent can fix and re-send). Treating them alike is how you get an infinite loop — retrying a 401 forever will only produce another 401.
| Status | Meaning | Retry? | Fix |
|---|---|---|---|
401 |
Token invalid / expired | No — permanent | Fix the token; log for ops |
403 |
Token valid, lacks scope | No — permanent | Fix the token's permission |
404 |
Repo missing / no visibility | No — permanent | Fix repo owner/path in env |
422 |
Payload violates validation | Yes, once corrected | Agent fixes the malformed field, re-sends |
500 / timeout |
Transient server / network | Yes — with the same key | Back off and retry idempotently |
Both are permanent, both mean "do not retry" — but the distinction matters for whoever debugs it. 401 = fix the token itself; 403 = the token is fine, fix its permissions. Return the specific error type so ops isn't guessing.
Graceful degradation is the guarantee that when the write fails, the user still gets a useful response — not that failures are hidden. A support system that crashes or dumps a raw error when GitHub is briefly down has failed its user twice: once because the issue wasn't created, and again because they got no idea what happened or what to do. The correct degraded response for a failed issue creation has four parts:
Say plainly the technical issue can't be resolved right now.
Include the full analysis the tech agent already produced — that work shouldn't be lost.
Say automatic escalation was attempted and is temporarily unavailable.
Give a concrete next step: contact support, reference the thread_id, request escalation.
The internal state guard and the server-side idempotency key are complementary, not redundant — they defend different failures. The guard checks the agent's own prior findings before it even constructs a retry, so it stops the stuttering failure (a deliberate retry after reading a timeout) and saves the network round-trip entirely. The server key stops concurrent double-fires — two calls reaching GitHub at once, e.g. under parallel execution — that a local guard would miss. First line of defense and last line of defense.
When a write records its outcome as a finding, set confidence = 1 on both success
and failure. Here 1 means "this is a confirmed fact, not an LLM inference" — the write really
happened, or really failed. A finding of type github_issue_created with confidence 0.3 is
incoherent: the issue either exists or it doesn't. The type carries the outcome; the score
carries certainty — and for a confirmed write that certainty is total.
A read is idempotent, so repeating it only wastes tokens and time while the system state stays put; a write mutates state, so repeating it multiplies the mutation — five refunds, five records — and the damage scales with the number of calls and is often irreversible.
The agent fires a write; the API is under load and takes 6s; the tool node's 5s timeout expires first and returns a "timeout" message. The agent's message history now looks like "attempt then failure," so it retries the identical call. Both eventually succeed. The agent only sees the second success and reports one action — the first becomes a ghost with no owner and no app record.
Uniqueness per logical operation, so distinct writes get distinct keys and aren't wrongly collapsed; and determinism, so a retry recomputes the identical key and the API can recognize it as the same request. Miss uniqueness and only one issue ever gets created; miss determinism and every retry looks brand new.
uuid4() as an
idempotency key?Both break determinism. A timestamp differs every instant, and uuid4() is random by design —
so the original call and each retry produce different keys, and the external API treats them as separate
operations. That's idempotency failure: the exact duplication you were trying to prevent.
Because a timeout that fires before the API replies makes a successful-but-slow call look like a failure, which triggers a retry and a duplicate write. Waiting slightly too long only costs latency; giving up too early costs correctness. Set the timeout to the documented p99 plus a buffer (p99 3s → 8–10s).
Inside the function, a missing token surfaces only when a live user triggers the tool — often in production with nobody watching. At module load the service fails to initialize, so the deploy pipeline catches it, the error is unambiguous, and no user reaches a misconfigured system. It also keeps the secret out of the LLM's arguments entirely.
They cover different failures. The guard stops the agent's deliberate retry after a timeout, but it can't help if two calls hit the API concurrently (say under parallel execution) — neither has seen the other's result yet. The server key catches that. Relying on the guard alone also means your safety collapses the moment the API is unreachable or swapped out.
Retry transient failures — 500/502/503 and network timeouts — using the same idempotency key. Never retry permanent ones: 401 (bad token), 403 (missing scope), and 404 (wrong repo) will just repeat forever. A 422 validation error is retryable only after the agent corrects the offending field.
Because confidence here marks "confirmed fact," not probability. A completed or failed write is something that definitely happened, not an inference to hedge. The finding's type says whether it succeeded; the confidence of 1 says the system is certain of that outcome. A "created with 0.3 confidence" finding would be incoherent — the issue exists or it doesn't.
SHA-256(thread_id:title:body)), never a timestamp
or UUID.Reading proves you recognize the pattern. These prove you can build it. Attempt each before opening the walkthrough.
The tool below is deployed against an API whose p99 latency is 5s. Predict exactly what happens on a slow call, name the two independent defects, and state the fix for each.
Requirements:
import uuid
from langchain_core.tools import tool
TOOL_TIMEOUT = 3 # seconds
@tool
def create_github_issue(title: str, body: str) -> str:
key = str(uuid.uuid4()) # "idempotency" key
return github_api.create_issue(title, body, idempotency_key=key,
timeout=TOOL_TIMEOUT)
Root cause — two defects that reinforce each other: a non-deterministic key and a timeout below the API's latency.
Step 1 — predict the failure. The API's p99 is 5s but the timeout is 3s, so a normal-slow call is abandoned at 3s. The agent reads that as failure and retries. Meanwhile the first call is still completing. You end up with two issues in the repo; the user is told about one, and the first is a ghost.
Step 2 — why the key is useless. uuid.uuid4() generates a fresh
random value on every invocation, including the retry. The original and the retry send different
keys, so the API sees two distinct operations. The key satisfies uniqueness but violates determinism, so it
prevents nothing.
Step 3 — the timeout defect. A timeout under the p99 guarantees premature give-ups on healthy calls. It must sit above the p99 plus a buffer.
Step 4 — the fix.
import hashlib
from langchain_core.tools import tool
TOOL_TIMEOUT = 10 # > p99 (5s) + buffer
@tool
def create_github_issue(title: str, body: str, thread_id: str) -> str:
# deterministic: a retry recomputes the identical key
key = hashlib.sha256(f"{thread_id}:{title}:{body}".encode()).hexdigest()
return github_api.create_issue(title, body, idempotency_key=key,
timeout=TOOL_TIMEOUT)
Write two small pieces: the deterministic key generator, and the internal state guard that stops the tool from ever being called when this logical write has already succeeded in the agent's state.
Requirements:
generate_idempotency_key(thread_id, title, body) returns a hex SHA-256 digest; identical
inputs return identical output.guard(state, key) returns the existing issue_url if a prior successful finding
in state["findings"] carries that key, else None.{"idempotency_key": k, "success": True, "issue_url": "...", }, calling guard with
the same k returns that URL and no API call is made.
Step 1 — the key. Join the three inputs in a fixed order, encode, hash with SHA-256, return hex. Order matters: it must be identical on every call so retries collide.
Step 2 — the guard. Before building the tool call, scan prior findings for one that both succeeded and matches this key. If found, reuse its URL and skip the API entirely — this is the first line of defense that saves the round-trip.
import hashlib
def generate_idempotency_key(thread_id: str, title: str, body: str) -> str:
payload = f"{thread_id}:{title}:{body}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def guard(state: dict, key: str) -> str | None:
for f in state.get("findings", []):
if f.get("success") and f.get("idempotency_key") == key:
return f["issue_url"] # reuse; do NOT call the API
return None
# usage inside the agent, before firing the tool
key = generate_idempotency_key(thread_id, title, body)
existing = guard(state, key)
if existing:
return f"Issue already exists: {existing}"
# else: fall through to create_github_issue(title, body, key)