Issue 41 · Building with AI
Agentic workflows in enterprise apps: a control loop for production
Plan, act, verify, escalate: budgets, idempotency keys, permission hooks and audit logs for an agent that touches enterprise systems, built on documented SDK features.
Mubarak Shajahan6 min read
An agent demo needs a model and a tool. An agent in an enterprise application needs a limit on what it can spend, a way to stop it doing the same irreversible thing twice, a check that the thing happened, and a person to hand over to when it is unsure. I am a Senior Software Engineer at Galent, and enterprise modernisation, with agents meeting legacy systems of record, is the setting I have in mind here. This post is a design argument built on documented SDK behaviour, not a description of any client system.
When should it be an agent at all?
Anthropic’s “Building effective agents” draws the line that matters. “Workflows are systems where LLMs and tools are orchestrated through predefined code paths.” Agents are systems where LLMs “dynamically direct their own processes and tool usage”. The post’s advice is to find “the simplest solution possible, and only increasing complexity when needed”, reserving agents for “open-ended problems where it’s difficult or impossible to predict the required number of steps”.
Most modernisation steps are predictable: fetch the record, validate it, map the fields, post it. I would write those as a workflow in ordinary code, and use an agent only for the step with real ambiguity, such as reading an unstructured exception and deciding what to do. The rest of this post is about that agent step.
What is the loop, exactly?
The tool-use docs describe it as a while loop on stop_reason: call the model, and while the reason is "tool_use", execute the tools, send back tool_result blocks and repeat. The loop exits on any other reason: end_turn, max_tokens, stop_sequence or refusal. “The model never executes anything on its own”, so every side effect passes through code you wrote. That is where the control goes.
The Agent SDK runs this loop for you and counts turns. A turn is one round trip: output with tool calls, execution, results fed back. Four things wrap it in production.
Plan: bound the work before it starts
max_turns caps tool-use turns and max_budget_usd caps estimated spend. The defaults are no limit for both, and the docs say “Setting a budget is a good default for production agents.” When a cap is hit, the final ResultMessage carries a subtype of error_max_turns or error_max_budget_usd and no result field, so those are escalations, not crashes. One caveat from the docs: spend is compared after each response arrives, so you can overshoot the cap by one response plus any subagents still running. Leave headroom.
Anthropic also lists “stopping conditions (such as a maximum number of iterations)” and the ability to “pause for human feedback at checkpoints or when encountering blockers”. Add your own caps the SDK does not give you: wall-clock time and calls per tool.
Act: make every side effect safe to repeat
Models retry. Networks retry. A crashed run gets resumed. So every tool with a side effect needs an idempotency key. Stripe’s documentation is the clearest statement of the pattern: use a key so that after a connection error “you can safely repeat the request without risk of creating a second object”. For API v1, Stripe saves the status code and body of the first request after execution begins and returns it for retries with the same key, including 500 errors. Keys are generated by the client, up to 255 characters, and it advises against putting sensitive data in them.
Two details matter for agents. Stripe may prune keys after at least 24 hours, so a long-running workflow cannot lean on a vendor’s window; keep your own ledger. And the key must describe the logical operation, not the attempt. I would derive it from the run ID, the tool name and a canonical form of the arguments, so a resumed run produces the same key. That is my design choice, not Stripe’s recommendation, which is a random V4 UUID.
Verify: the model’s claim is not evidence
Anthropic says it is “crucial for the agents to gain ‘ground truth’ from the environment at each step”. The agent saying “posted” is a sentence. After the run, read the system of record and compare: status, amount, identifiers. Treat tool results as untrusted too. The docs warn that results carry content from web pages, email and uploads, and an attacker may embed instructions in them, so keep that content inside tool_result blocks.
Escalate: make “I’m not sure” a first-class outcome
Handing over is a normal outcome, not a failure, and it should be cheap to do and cheap to audit. Three triggers hand control to a person: a limit subtype, a policy hook that defers a risky call, and a failed verification. Route them to a queue with the session ID, because the ResultMessage carries session_id for resuming later, and with the reason code. Queue design gets its own post, linked at the end.
How do you enforce policy outside the prompt?
Hooks. They are callbacks that “fire at specific points in the loop”, run in your application process and consume no context window. A PreToolUse hook can return permissionDecision of allow, deny, ask or defer. When several apply, deny beats defer, which beats ask, which beats allow. A denied call gives the model a rejection message as its tool result, so it can try another approach. A hook that times out fails closed: the tool call does not run.
Auditing is the other use. PostToolUse fires after execution, and the tool_use_id lets you correlate pre and post events. Async hook output cannot block anything, so use it for logging only. One trap from the docs: hooks may not fire when the agent hits max_turns, because the session ends first. Do not make the hook the only place cost and outcome are recorded; the ResultMessage also carries cost, usage and turn count.
Hands-on: the loop with a gate, an audit trail and an exit
This uses documented ClaudeAgentOptions fields and hook shapes. The mcp__erp__ tools and the ledger are hypothetical stand-ins for your own.
import hashlib, json
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher, ResultMessage
LIMIT_CENTS = 500_000
def idem_key(run_id, tool, args):
canon = json.dumps(args, sort_keys=True)
return hashlib.sha256(f"{run_id}|{tool}|{canon}".encode()).hexdigest()
def post_once(run_id, args): # your tool implementation
key = idem_key(run_id, "post_journal_entry", args)
if (hit := ledger.get(key)):
return hit # replay: return the stored result, post nothing
result = erp.post(args, idempotency_key=key) # downstream API
ledger.put(key, result)
return result
async def gate(input_data, tool_use_id, context):
audit.write("pre", tool_use_id, input_data["tool_name"], input_data["tool_input"])
args = input_data["tool_input"]
if input_data["tool_name"].endswith("post_journal_entry") and args.get("amount_cents", 0) > LIMIT_CENTS:
decision, why = "defer", "Above auto-post limit; needs approval"
else:
decision, why = "allow", "Within policy"
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": decision, "permissionDecisionReason": why}}
async def record(input_data, tool_use_id, context):
audit.write("post", tool_use_id, input_data["tool_name"])
return {}
options = ClaudeAgentOptions(
max_turns=25,
max_budget_usd=1.50,
hooks={"PreToolUse": [HookMatcher(matcher="^mcp__erp__", hooks=[gate])],
"PostToolUse": [HookMatcher(hooks=[record])]},
# mcp_servers=... omitted
)
async def run(run_id, prompt, expected_cents):
async with ClaudeSDKClient(options=options) as client:
await client.query(prompt)
async for msg in client.receive_response():
if isinstance(msg, ResultMessage):
if msg.stop_reason == "tool_deferred":
return "approval_queue", msg.session_id # check the docs for resuming a deferred call
if msg.subtype != "success":
return "escalate", msg.subtype, msg.session_id # error_max_turns, error_max_budget_usd, ...
entry = erp.get_entry(run_id) # verify against the system of record
ok = entry and entry.amount_cents == expected_cents and entry.status == "posted"
return ("done" if ok else "escalate"), msg.total_cost_usd
Read it as a checklist: caps, a gate that returns a decision for every call, an audit write on both sides of the tool, a replay-safe tool body, and three exits (approval, escalation, verified done). Hook support differs between SDKs; the hooks page lists which events Python has, so confirm that before porting.
Takeaways
- Default to a workflow in code, and put the agent only on the ambiguous step.
- Set
max_turnsandmax_budget_usdon every production run, leave headroom for overshoot, and treat limit subtypes as escalations. - Give every side-effecting tool an idempotency key derived from the logical operation, and keep your own ledger rather than trusting a 24-hour vendor window.
- Verify against the system of record after the run; never accept the model’s own summary as proof.
- Enforce policy in
PreToolUsehooks withdeferfor high-risk calls, and log both sides of every call, without relying on hooks alone for cost.
The review queue that those escalations land in is the subject of human-in-the-loop by design.
Sources
- Anthropic Engineering: Building effective agents · anthropic.com
- Claude docs: how tool use works (the agentic loop and stop reasons) · platform.claude.com
- Claude Code docs: how the Agent SDK loop works (turns, budgets, results) · code.claude.com
- Claude Code docs: Agent SDK hooks (PreToolUse, PostToolUse, decisions) · code.claude.com
- Claude docs: handle tool calls (untrusted tool results) · platform.claude.com
- Stripe API docs: idempotent requests · docs.stripe.com
Tags
- agents
- enterprise
- workflow
- idempotency
- audit logs
- Claude Agent SDK