Real-Time Tools Break Claude Agents — Build the Audit Guard
Around 2,600 people upvoted Claude losing its mind over a clock. The post is funny — but the failure it shows is the same one that quietly corrupts production agents whenever you wire in anything that changes between reasoning steps: a live price feed, a session counter, a weather API, or yes, a clock.
This post reproduces the failure in a minimal sandbox, explains the exact mechanism, then builds two concrete guards that make stateful tool use safe. By the end you’ll have a sanity assertion tool and a tool-call audit wrapper you can drop into any agent loop.
What you need
A working Python environment, a Claude API client, and an Anthropic API key. No frameworks — the whole point is to understand what’s happening at the bare loop level.
The failure mode, precisely stated
Claude’s reasoning is over a context window. When a tool returns a value, Claude treats that return value as ground truth at the moment it was read. If the same tool is called again and returns something different — even slightly — and Claude doesn’t explicitly reconcile the two values, it can act on both simultaneously, producing decisions that contradict each other with no error raised.
This is not a bug in Claude. It’s a property of any stateless inference call operating on a mutable world. The model doesn’t have a persistent “memory” of what the tool said five seconds ago — it only has what’s written into the context. If you append two tool results that disagree, you’ve handed Claude an inconsistent world to reason about.
sequenceDiagram
participant Agent
participant Claude
participant ClockTool
Agent->>Claude: "Schedule a task for 5 minutes from now"
Claude->>ClockTool: get_current_time()
ClockTool-->>Claude: 14:00:00
Note over Claude: Reasons: schedule at 14:05:00
Claude->>ClockTool: get_current_time() (confirm)
ClockTool-->>Claude: 14:00:47
Note over Claude: Two ground truths now in context
Claude-->>Agent: Inconsistent or over-hedged output
Step 1: Reproduce the failure in a sandbox
This agent asks Claude to reason about a schedule based on the current time, then calls the clock tool a second time with artificial drift injected.
import anthropic
import json
client = anthropic.Anthropic(api_key="<your_api_key>")
# Simulate a clock that drifts between calls
call_count = 0
def get_current_time() -> str:
global call_count
call_count += 1
# First call: top of the minute. Second call: 47 seconds later.
return "14:00:00" if call_count == 1 else "14:00:47"
tools = [
{
"name": "get_current_time",
"description": "Returns the current wall-clock time as HH:MM:SS.",
"input_schema": {"type": "object", "properties": {}, "required": []},
}
]
messages = [
{"role": "user", "content": "What time is it right now? Then confirm by checking again, and tell me exactly when I should schedule a task for 5 minutes from now."}
]
# Agent loop — two tool-call rounds
for _ in range(4):
response = client.messages.create(
model="<claude-model-id>", # substitute your target Claude model ID
max_tokens=512,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
print("Final output:", response.content[0].text)
break
# Process tool calls
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = get_current_time()
print(f"Tool call #{call_count}: get_current_time() → {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
Run this and Claude will either hedge (“the time appears to have changed”), schedule against the first reading, or produce a contradictory answer — all without any error. That’s the bug.
Step 2: Guard #1 — the sanity assertion tool
The fix isn’t to log the inconsistency after the fact. It’s to give Claude a required validation step before it acts on any real-time value. The assertion tool rejects values that fall outside expected bounds and refuses to proceed.
import time
# Track the last seen timestamp for drift detection
_last_timestamp: float | None = None
MAX_DRIFT_SECONDS = 5.0
def assert_time_sanity(reported_time_iso: str) -> dict:
"""
Claude must call this before scheduling anything based on get_current_time().
Returns {"ok": true} or {"ok": false, "reason": "..."}.
"""
global _last_timestamp
try:
# Parse HH:MM:SS into today's epoch seconds (simplified)
h, m, s = map(int, reported_time_iso.split(":"))
now_epoch = time.time()
today_midnight = now_epoch - (now_epoch % 86400)
reported_epoch = today_midnight + h * 3600 + m * 60 + s
except ValueError:
return {"ok": False, "reason": "Unparseable time format."}
# Guard 1: not in the past
if reported_epoch < now_epoch - 60:
return {"ok": False, "reason": "Reported time is more than 60s in the past."}
# Guard 2: drift between calls
if _last_timestamp is not None:
drift = abs(reported_epoch - _last_timestamp)
if drift > MAX_DRIFT_SECONDS:
return {
"ok": False,
"reason": f"Clock drift of {drift:.1f}s detected between calls. Do not proceed — re-fetch and reassert.",
}
_last_timestamp = reported_epoch
return {"ok": True}
Add this to your tools list and instruct Claude in the system prompt: “Before acting on any value returned by get_current_time, you must call assert_time_sanity with that value. If it returns ok: false, stop and report the reason to the user.”
Claude will now surface the drift instead of silently reasoning over two incompatible truths.
Step 3: Guard #2 — the tool-call audit wrapper
The assertion tool catches problems at runtime. The audit wrapper makes post-hoc debugging tractable — essential when you’re chasing a failure that only shows up after ten tool calls in a long session.
import json
from datetime import datetime, timezone
from pathlib import Path
AUDIT_LOG = Path("tool_audit.jsonl")
def audited_tool_call(tool_name: str, tool_input: dict, raw_fn) -> str:
"""
Wraps any tool function. Logs name, input, output, and wall time.
raw_fn: the actual Python callable for the tool.
"""
wall_time = datetime.now(timezone.utc).isoformat()
result = raw_fn(**tool_input)
record = {
"ts": wall_time,
"tool": tool_name,
"input": tool_input,
"output": result,
}
with AUDIT_LOG.open("a") as f:
f.write(json.dumps(record) + "\n")
return result if isinstance(result, str) else json.dumps(result)
# Usage in your agent loop — replace direct tool dispatch with:
# result = audited_tool_call("get_current_time", {}, get_current_time)
# result = audited_tool_call("assert_time_sanity", {"reported_time_iso": val}, assert_time_sanity)
The JSONL log is intentionally simple — one record per line, machine-readable, easy to grep or replay. When something goes wrong, you load the log, reconstruct the exact sequence of tool values Claude saw, and the inconsistency jumps out immediately.
Where this breaks
The MAX_DRIFT_SECONDS threshold is domain-specific. Choose a bound that matches your domain’s tolerance — latency-sensitive systems may need much tighter limits. Set the threshold based on the business invariant your tool is meant to satisfy, not a generic default.
Next steps
The two guards here — sanity assertion and audit wrapper — are the minimum viable safety layer for any agent that touches real-time or stateful data. The next level is making the audit log queryable in real time so a supervisor agent can watch for anomalies while the primary agent runs. DeepClaude (github.com/aattaran/deepclaude) is one project worth investigating in this area. For most production use cases, though, start here: assert before you act, log everything, and never let two contradictory tool responses sit unreconciled in the same context.
← Back to blog