Python 3.12 · FastAPI · Pydantic v2 · Anthropic · Cloud Run

An agent is a config file. The harness is the product.

Harness is the 100x agent framework. A client agent is one agent.yaml and a markdown knowledge pack. No new code in the framework per client — and a test fails the build if a client's name appears in it.

The gap

A demo is a prompt and a loop. Production is everything around them.

The interesting engineering in a customer-facing agent is not getting a good reply. It is what happens on the bad path — and there are more bad paths than good ones.

What a demo skipsWhat it costs in production
Nothing checks the outbound messageThe model offers a discount nobody authorised, in writing, to a customer
The channel retries a deliveryThe same person is answered twice
The model declines, or the API times outSilence. Nobody is told. The conversation dies
No record of what was sent or whyNothing to audit, nothing to replay, nothing to improve against
Volatile data at the front of the promptThe cache never hits and every turn pays full input price
Per-client forks of the loopClient ten costs what client one cost

Architecture

Five layers, adjacent-only, typed at every boundary

A layer talks only to the layers next to it, and only through a Pydantic model. The contract chain for one turn is fixed:

harness/contracts.pythe type spine
InboundEventMemorySnapshotAssembledContextModelResultToolOutcome* → Verdict* → OutboundMessageTurnRecord
harness/context/

Context

Builds the model input: system template, the client's versioned knowledge pack, policies, and memory — a rolling window plus durable per-contact facts. Deterministic: identical inputs produce identical bytes, and a test asserts it.

harness/tools/

Tools

Capability adapters behind one interface — send, calendar, CRM, hand off to a human, speak. A client's config lists the tools it gets by name; an unregistered name fails at load. Tools never hold a channel sender.

harness/loop/

Control loop

Drives the turn end to end. Bounded retries with backoff, per-stage timeouts, a total turn budget, and an idempotency claim on the inbound message id. Owns the only path to a customer.

harness/guardrails/

Validation

Structure, policy, PII, tone. Deterministic checks first and free; the model-backed tone check runs only if they pass, and only if the client turned it on.

harness/obs/

Observability

One structured record per turn, hash-chained per conversation. Cost from real usage, drift metrics, and a replay-eval harness built to rerun recorded conversations against a changed prompt or model.

Runtime

One turn, in order

The order of operations is the product. Read run_turn top to bottom and this is what you get.

The path of one turn A message arrives and is claimed, memory is loaded, the prompt is assembled, the model reasons and calls tools, and the reply meets the validation gate. Only a passing reply is sent. Every failure — a duplicate, a refusal, a rejected reply, an error — leaves the path to a human. Both the sent path and the escalated path end in a hash-chained record. a message arrives 1claim the message id a retry loses the race 2load memory window + durable facts 3assemble the prompt cached prefix first, volatile after 4reason, call tools loops until it stops asking 5the gate structure · policy · PII · tone passes 6sent to the customer a human takes over notified, with the reason duplicate refusal error rejected twice nothing is sent 7 · one hash-chained record, either way input · prompt hash · model · tools · every verdict · output · latency · cost
Every exit from the path is deliberate, and every one of them still writes a record.
  1. Claim. Atomically claim the inbound message id. A duplicate delivery loses the race and returns immediately — channels retry, and a retry must not produce a second reply.
  2. Remember. Load the conversation window and long-term facts for this contact.
  3. Assemble. Build the prompt: stable content first with the cache breakpoint, volatile content strictly after it.
  4. Reason and act. Call the model. Route tool_use to adapters, return every result in a single user message, loop until it stops asking — bounded by a per-client iteration cap, past which the turn escalates rather than spinning. Handle pause_turn by resuming; treat refusal as a hand-off, never as text to rewrite.
  5. Validate. Run the guardrail pipeline over the reply. On a failure, regenerate once with the violation fed back verbatim.
  6. Send — or escalate. A passing pass is the only way to build an OutboundMessage. A second failure notifies a human and sends nothing.
  7. Record. Write the turn: input, prompt hash, model, tool calls, every verdict, output, per-stage latency, tokens and cost.

The gate

One way out, and it is typed shut

The constraint is structural rather than procedural: OutboundMessage raises in its own validator if it is handed no verdicts, or any failing verdict. There is nothing to remember to call.

harness/contracts.pythe constructor is the gate
def model_post_init(self, _, /) -> None:
    if not self.verdicts:
        raise ValueError("OutboundMessage requires guardrail verdicts")
    failed = [v.validator for v in self.verdicts if not v.passed]
    if failed:
        raise ValueError(f"OutboundMessage blocked by failing validators: {failed}")

A tool that wants to put its own words in front of a person — an extra message, a spoken line — does not get a sender. It asks the loop's gate, which runs the same pipeline and raises a tool error the model can see and correct. Adding a second route to the customer means changing the loop, and an architecture test fails if clear_for_delivery is called from any module but the loop.

One honest caveat about the strength of that guarantee. Written replies are enforced by the type: they cannot exist without passing verdicts. A spoken line is enforced by the pipeline: the voice tool carries its own transport, so it calls the gate to be screened and then hands the text to the voice backend — the same checks, on the same code path, but no OutboundMessage is constructed. Two mechanisms, not one, and only one of them is a compiler-level fact.

CheckCostCatches
StructurefreeEmpty, over-length, leaked internal markup, a tool call written as prose
PolicyfreeBanned topics from a shared library plus client-specific phrases — discounts, guarantees, legal and medical claims, competitor mentions
PIIfreePhone numbers other than the customer's own, any email address, IBANs, Luhn-valid card numbers. Order references, invoice numbers and opening hours are deliberately not read as phone numbers
Tone1 model callBrand voice. Off by default, and skipped entirely when a cheaper check already failed

Rules first is not a cost decision, it is a determinism decision. A regex returns the same verdict every time, which is what makes a logged turn replayable and an eval meaningful. Tone is the one judgement rules genuinely cannot make, so it is the one thing a model is asked.

Prompt assembly

Cache safety is a constraint, not an optimisation

The API caches on an exact prefix match and renders tools → system → messages. So everything stable renders first and carries the breakpoint; everything volatile renders strictly after it. One cache entry then serves every conversation that client has.

harness/context/assembler.pyordering rule
# cached prefix — identical for every turn, every contact
tools      the client's tool schemas, sorted by name
system[0]  system template + persona + policies
system[1]  knowledge pack             ← cache_control breakpoint

# after the breakpoint — changes every turn, invalidates nothing above
system[2]  today's date · first contact? · long-term facts
messages   conversation window
           + the inbound message      ← second breakpoint (see limits)

The failure mode is silent: put a timestamp in the prefix and nothing breaks, the bill just goes up. So a test renders two turns on different days, for different contacts, with different memory, and asserts the prefix bytes are identical — and separately asserts no date, contact id or fact ever appears in it.

What the test cannot assert is that the entry is still there. The breakpoint is a five-minute ephemeral cache, so "one entry serves every conversation" holds for a client with steady traffic and quietly stops holding for a client whose conversations are twenty minutes apart — each cold turn then pays a write at 1.25× input instead of a read at 0.1×. Whether caching is a saving or a small tax is a function of a client's arrival rate. It is read off cache_read_input_tokens in production, not asserted in a test.

Observability

Every turn is a record, and the records are chained

Each record carries the previous record's hash, so editing one after the fact breaks every link from that point on. It is the EU AI Act Article 12 answer and the eval corpus at the same time — a record holds everything needed to reconstruct the turn.

harness/contracts.pyTurnRecord
turn_id · client_id · conversation_id · contact_id · message_id
started_at · finished_at · inbound_text · outbound_text
prompt_sha256 · knowledge_sha256 · config_sha256 · model
tool_calls[] · verdicts[] · regenerated · outcome
escalation_reason · error · stages[] · usage · cost_usd
retention_days · prev_hash · record_hash

Because the verdicts are deterministic, the same records drive replay evals: rerun real conversations against a changed prompt or a new model and diff the outcomes before a customer sees them. Drift alerts on four numbers per client — guardrail-failure rate, escalation rate, p95 latency, and cache hit rate. A climbing escalation rate almost always means a hole in the knowledge pack, which is a markdown edit rather than a code change — though today it still travels as a container rebuild, because packs are baked into the image and read once at startup.

The whole point

One file defines an agent

clients/<client_id>/agent.yamlabridged
client_id: acme          # must match the directory name
agent_name: Sam
channel: whatsapp
model: claude-opus-5   # unknown model = refuses to load
language: [en, ar]

persona:
  voice: "warm, concise, professional"
  disclosure: true        # false = refuses to load

tools:
  - whatsapp_send
  - crm_upsert
  - handoff_to_human      # missing = refuses to load

guardrails:
  banned_topics: [pricing_discounts, guarantees]
  max_message_length: 600

escalation:
  human_contact: "+9715…"      # blank = refuses to load
  confidence_floor: 0.7

observability:
  retention_days: 365       # no default, on purpose

Compliance is enforced by the loader, not by a checklist. A config that would produce an agent with no human fallback, or one that hides being an AI, does not start — it raises at load with the reason. The same is true of an unknown tool or an unknown policy key: you cannot silently misspell a guardrail into non-existence.

$ harness new-client acme --channel whatsappscaffold
created clients/acme
  agent:    Sam on whatsapp
  tools:    whatsapp_send, crm_upsert, handoff_to_human
  escalates to: +971500000000

Next:
  1. Replace the knowledge pack in clients/acme/knowledge with the real thing.
  2. Set the real escalation contact in clients/acme/agent.yaml.
  3. Tune persona.voice and guardrails.banned_topics.
  4. harness validate clients/acme

# the scaffolder loads what it generated and deletes it if invalid,
# so a broken client never reaches the repo. Note step 2: that default
# number loads happily.

The next five questions

What a reviewer asks after the diagram

Architecture diagrams answer the easy half. These are the questions that decide whether a thing survives contact with a customer.

QuestionHow it is handled
A tool half-succeeds — the booking lands, the call times out The timeout comes back to the model as a failed tool result, so it reacts rather than crashes. Idempotency for the side effect itself belongs in the tool contract, not the loop — a booking tool takes an idempotency key so a retry resolves to the same booking. The loop guarantees the turn is not repeated; the tool guarantees its own effect is not
How is one client's state kept out of another's By key prefix. Every document is client_id:conversation_id, every claim client_id:message_id, and the state store is reached only through a Protocol that takes the client id on every call — so there is no path that reads another client's conversation by accident. Where a deployment needs a hard boundary rather than a key, the same Protocol takes a per-client database or per-client credentials without touching the loop
What happens when the knowledge pack outgrows the context window The knowledge pack is the cached prefix, so its size is a cost question before it is a capacity one: it is written once and read at a tenth of the input price by every conversation that client has. The model client exposes count_tokens, and the place to spend it is at config load — a pack over budget should fail the way any other bad config fails, loudly, before the agent serves anyone
How do you roll back a prompt change By reverting the commit and redeploying — the same as any other change, because the system template is code and the knowledge packs ship in the image. Every turn record carries prompt_sha256, knowledge_sha256 and config_sha256, so you can always tell exactly which version answered a given conversation. Moving between versions without a deploy would mean lifting the pack out of the image and into the store the records already point at
What is the concurrency model Async on two worker processes, scaling to zero. Turns for different conversations run concurrently; the atomic claim is what stops a duplicate delivery being answered twice. There is no per-client concurrency cap and no queue, so a burst is absorbed by the model API's rate limits and the loop's bounded retries rather than by anything the framework does

Stack

Boring on purpose

ConcernChoiceWhy
RuntimePython 3.12, FastAPI, Pydantic v2, asyncTyped at every boundary; mypy strict across the framework
ReasoningClaude, adaptive thinking, effort per clientOne adapter file knows the API; swapping the model is a config line
Tool loopHand-writtenThe stock runner exposes no per-stage timeout, no idempotency claim and no gate between model output and send. One beta feature is used deliberately: the server-side refusal fallback, so a false-positive safety decline on ordinary business text is retried rather than escalated to a person
StateProtocol → in-memory or FirestoreThe whole suite runs offline; production swaps by environment
DeployCloud Run, scale to zeroStateless service; a bad config crashes the worker at startup rather than serving half a fleet
SecretsSecret Manager onlyThe config schema has nowhere to put a credential, deliberately

If you build these too, I would rather compare notes than pitch you.

And if you are choosing someone to build one for you: the questions worth asking are what happens when the model says the wrong thing, who finds out, and what you can prove about it a year later. This page is my answer. Happy to go deeper on any layer.

Tell us about your company

Let's find the job worth giving an agent.

Tell us what the work looks like today and where it goes wrong. We'll come back with what we'd build first, and what it would have to survive.

We use this to reply to your enquiry — nothing else. How we handle your data.