Section B · Technical core

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:

  1. Receive an input — a Slack message, a survey close event, an n8n trigger, an HRIS webhook.
  2. Build a prompt — system prompt + retrieved context + the input.
  3. Call the model — Claude with tools available.
  4. Model proposes a tool call — "read Workday worker by id," "list Ashby candidates."
  5. Your orchestrator executes the tool — calls the API, returns the result.
  6. Result goes back to the model — model continues reasoning or proposes another tool.
  7. Loop until done — model returns a final structured output.
  8. 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

PrimitiveWhat it isHR example
Tool useModel calls functions you defineget_worker(employee_id), list_open_reqs(), propose_eib_upload(payload)
Retrieval (RAG)Fetch relevant chunks from a corpus, inject into promptPolicy library Q&A, country-specific leave entitlements
Structured outputModel returns JSON conforming to a schemaAn "action plan" the HITL UI can render and approve
Multi-step reasoningModel iterates, possibly with sub-agentsOnboarding: hire → IT provisioning → benefits enrollment
Sub-agentsOne agent delegates to specialized agentsSurvey synthesis: a clusterer agent, a sentiment agent, a writer agent
Memory / statePersisted context across runsA multi-turn onboarding case that pauses for HITL and resumes
HITL gateHuman approval before commitThe 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.

Interview-ready phrase

"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:

  1. 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.
  2. Document each parameter and return shape. The description goes to the model.
  3. 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_plan and 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.
Validation is not optional

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
The HR-specific wrinkle

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

ModelUse whenHR examples
HaikuHigh-volume, simple classificationTicket triage, simple intent classification, PII redaction passes
SonnetThe default workhorse — orchestration, tool use, draftingOnboarding agent, policy Q&A, survey synthesis
OpusHardest reasoning, multi-jurisdiction synthesis, ambiguous policy questionsPromotion-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

  1. What's the difference between tool use and structured output? When would you use one but not the other?
  2. Why does prompt caching matter for an HR Q&A agent specifically?
  3. Give an idempotency key design for "process a leaver checklist for worker W-1234 effective 2026-06-30."
  4. What's a retry policy for a "propose EIB upload" tool call vs. a "send Slack welcome message" tool call? Why different?
  5. 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.