Core Fundamentals
The base layer — LLM orchestration, agentic primitives, prompts/tools/structured output, retries, idempotency. The vocabulary the rest of this guide assumes you have cold.
LLM orchestration — the mental model
An "AI agent" in production is rarely just one model call. It's an orchestrated loop:
- Receive an input — a Slack message, a survey close event, an n8n trigger, an HRIS webhook.
- Build a prompt — system prompt + retrieved context + the input.
- Call the model — Claude with tools available.
- Model proposes a tool call — "read Workday worker by id," "list Ashby candidates."
- Your orchestrator executes the tool — calls the API, returns the result.
- Result goes back to the model — model continues reasoning or proposes another tool.
- Loop until done — model returns a final structured output.
- Hand off to HITL or commit — present to a human for approval, or commit if low-risk.
Three observations that show up in interview:
- The orchestrator (n8n, your Python code, or both) controls the loop — not the model. The model proposes; the orchestrator disposes.
- Every tool call is an audit-log entry. You write the log on entry and exit of each tool.
- The final action (the Workday write, the EIB upload) is almost always a tool call you implement as a HITL request, not as a direct write.
Agentic primitives — the building blocks
| Primitive | What it is | HR example |
|---|---|---|
| Tool use | Model calls functions you define | get_worker(employee_id), list_open_reqs(), propose_eib_upload(payload) |
| Retrieval (RAG) | Fetch relevant chunks from a corpus, inject into prompt | Policy library Q&A, country-specific leave entitlements |
| Structured output | Model returns JSON conforming to a schema | An "action plan" the HITL UI can render and approve |
| Multi-step reasoning | Model iterates, possibly with sub-agents | Onboarding: hire → IT provisioning → benefits enrollment |
| Sub-agents | One agent delegates to specialized agents | Survey synthesis: a clusterer agent, a sentiment agent, a writer agent |
| Memory / state | Persisted context across runs | A multi-turn onboarding case that pauses for HITL and resumes |
| HITL gate | Human approval before commit | The Workday EIB upload approval |
You will be asked some version of "design X." Your job is to compose these primitives. There's no extra magic.
Prompts as code
Prompts are not a creative-writing exercise. They are versioned artifacts with tests. The structure that survives:
- System prompt — role, constraints, output schema. Stable, prompt-cached.
- Knowledge context — policy library chunks, Workday schema, exemplars. Prompt-cached.
- Task instruction — what to do this call. Varies.
- Input data — the worker record, the survey response, the ticket. Varies.
- Output contract — the JSON schema or the markdown shape expected back.
Anti-patterns: prompts that live inline in n8n with no version control; prompts that mix knowledge with task instructions; prompts that don't specify an output contract.
"I treat the system prompt and the knowledge context as the cacheable artifacts — they're stable, version-controlled, and reviewed. The task and input are the variable suffix. That gets me both quality control and a ~90% discount on cached tokens."
Tools / function calling
A tool is a typed function the model can call. Three rules:
- Name it like a verb on a noun.
get_worker,propose_job_change,list_open_reqs. The model picks tools by their names; clarity matters. - Document each parameter and return shape. The description goes to the model.
- Validate inputs server-side. Never trust the model to send valid values.
tools = [
{
"name": "get_worker",
"description": "Fetch a worker record from Workday by employee_id. Returns name, position, supervisory org, location, FTE, effective dates. Returns null if not found. Read-only.",
"input_schema": {
"type": "object",
"properties": {
"employee_id": {"type": "string", "description": "Workday worker ID, e.g. 'W-12345'"}
},
"required": ["employee_id"],
},
},
{
"name": "propose_job_change",
"description": (
"Propose a job change for a worker. Does NOT commit — drafts an EIB row "
"and returns a preview for human approval. Use this for any change to "
"job profile, supervisor, location, or FTE."
),
"input_schema": {
"type": "object",
"properties": {
"employee_id": {"type": "string"},
"effective_date": {"type": "string", "format": "date"},
"new_job_profile": {"type": "string"},
"new_supervisor_id": {"type": "string"},
"new_location": {"type": "string"},
"new_fte": {"type": "number"},
"reason_code": {"type": "string"},
},
"required": ["employee_id", "effective_date", "reason_code"],
},
},
]
Notice how the propose_job_change description tells the model explicitly that this is non-committing. That's the HITL boundary, encoded at the tool boundary, in language the model can act on.
Structured output
For any agent whose output is consumed by code or rendered in a HITL UI, you want JSON, not prose. Two ways with Claude:
- Tool-use for output — define a tool like
submit_final_planand require the model to call it. This gives you typed JSON. - Prefill — start the assistant turn with
{and instruct in the system prompt to return JSON only.
Always validate with Pydantic / JSON Schema before acting:
from pydantic import BaseModel, Field
from typing import Literal
class ProposedJobChange(BaseModel):
employee_id: str = Field(..., pattern=r"^W-\d+$")
effective_date: str # ISO date
new_job_profile: str | None = None
new_supervisor_id: str | None = None
new_fte: float | None = Field(None, ge=0, le=1.0)
reason_code: Literal["PROMOTION", "LATERAL", "REORG", "FTE_CHANGE"]
rationale: str
# After Claude returns:
plan = ProposedJobChange.model_validate_json(raw_json)
# If invalid, do not present to the human approver. Retry or fail cleanly.
For payroll-adjacent actions, an invalid structured output is a halt event, not a silent retry. Log it. Surface it to ops.
Retries & backoff
Two categories of failure, two policies:
- Transient (5xx, rate limit, timeout) — retry with exponential backoff + jitter. 3-5 attempts. Most SDKs include this.
- Logical (model returned wrong shape, tool validation failed) — retry once with a corrective message ("your previous output didn't match schema X; here's what was wrong"). After one retry, fail clean.
What never retries automatically: anything that touches Workday writes, payroll, or has already had partial side effects. See 08-error-handling.
import time, random
from anthropic import Anthropic, APIStatusError, APITimeoutError
client = Anthropic()
def call_with_retry(messages, system, tools, max_attempts=4):
for attempt in range(max_attempts):
try:
return client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
system=system,
messages=messages,
tools=tools,
)
except (APITimeoutError, APIStatusError) as e:
if attempt == max_attempts - 1:
raise
sleep = (2 ** attempt) + random.random()
time.sleep(sleep)
Idempotency — the most underrated discipline in HR automation
Every action your agent could ever commit must be safely retriable. In HR this is non-negotiable because:
- An idempotency failure on a hire can create a duplicate worker record
- An idempotency failure on a comp change can double-apply
- An idempotency failure on a leave entry can mis-balance PTO
The pattern: every commit-shaped action carries an idempotency key that uniquely identifies the intended outcome.
import hashlib, json
def idempotency_key(action_type: str, payload: dict) -> str:
"""Stable hash of the intended action. Same inputs → same key."""
canonical = json.dumps(payload, sort_keys=True)
return f"{action_type}:{hashlib.sha256(canonical.encode()).hexdigest()[:16]}"
# Before committing, store the key. If we see it again, no-op.
def commit_with_idempotency(action_type, payload, store):
key = idempotency_key(action_type, payload)
if store.exists(key):
return store.get(key)["result"] # already done
result = actually_commit(payload)
store.put(key, {"result": result, "ts": time.time()})
return result
Idempotency keys for HR actions should encode the business intent, not just the request. A "hire Jane Doe effective 2026-06-01" is the same intent whether retried 5 minutes or 5 hours later. A "process unpaid leave for Jane Doe for week of X" is the same intent across retries. See 11-coding-problems for a worked rehire-scenario idempotency design.
Prompt caching — the cost lever you can't ignore
Anthropic's prompt caching gives ~90% discount on cached input tokens. The cache lasts ~5 minutes by default with longer-lived options. In HR, the cacheable surfaces are huge:
- The policy library (often 100K+ tokens)
- The Workday schema and field dictionary
- System prompts for each agent
- Country-specific leave entitlement reference data
- Role rubrics for promotion review
Architecture rule: stable content goes first in the message array; per-call variable content goes last. The cache breakpoint sits at the join.
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
system=[
{
"type": "text",
"text": POLICY_LIBRARY_FULL, # 80K tokens, stable
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": TASK_SPECIFIC_INSTRUCTION},
],
messages=[
{"role": "user", "content": user_question} # variable
],
)
Model selection — when each is right
| Model | Use when | HR examples |
|---|---|---|
| Haiku | High-volume, simple classification | Ticket triage, simple intent classification, PII redaction passes |
| Sonnet | The default workhorse — orchestration, tool use, drafting | Onboarding agent, policy Q&A, survey synthesis |
| Opus | Hardest reasoning, multi-jurisdiction synthesis, ambiguous policy questions | Promotion-cycle calibration, cross-jurisdiction comp analysis, complex DSAR scoping |
In interview: "I default to Sonnet, drop to Haiku for high-volume triage, and reach for Opus when the task spans multiple jurisdictions or has genuinely ambiguous reasoning."
Streaming & latency
For chat-shaped UX (e.g. an HRKX agent over Slack) you'll want streaming. For batch workflows (e.g. nightly survey sweep) you don't. Two notes:
- Streaming complicates structured-output validation — buffer the full response, then validate.
- For HITL workflows, time-to-first-token matters less than time-to-correct-output. Don't over-optimize streaming.
Cost shape — what drives it
Two levers dominate cost in agent systems:
- Input tokens — usually 10-100x more than output. Cache the stable parts aggressively.
- Loop length — multi-turn tool use compounds. Cap turns. Audit logs should show turn counts per run; a sudden growth is a regression.
For a typical HR agent run: 50K cached input tokens + 5K variable input + 2K output × ~3 turns = your unit cost. Know roughly what one run costs and what a month at production volume costs. Engineering leaders ask.
Fluency check — answer these out loud
- What's the difference between tool use and structured output? When would you use one but not the other?
- Why does prompt caching matter for an HR Q&A agent specifically?
- Give an idempotency key design for "process a leaver checklist for worker W-1234 effective 2026-06-30."
- What's a retry policy for a "propose EIB upload" tool call vs. a "send Slack welcome message" tool call? Why different?
- Which Claude model would you reach for to synthesize 200 free-text survey responses with sentiment + theme clustering? Why?
If those are crisp, move to 04-deep-dive-primary.