Harness Engineering
Evolve your POI agent into a deployed, self-running product.
Where you left off: in Weekend 1 you built a local Gemma pipeline that turns compliant data into confidence-scored POIs, and you shipped a web app to a VPS. This weekend you wrap that pipeline in a real harness — the scaffolding that makes a model a reliable agent — then put it online, running itself.
Before the weekend
~90 min. Mostly verifying Weekend 1 still works, plus a few new free accounts.
0.1 Carried over from Weekend 1 — verify
For the non-tech learner
You're building on what you already have. Just confirm each still works — don't rebuild.
0.2 New for Weekend 2 — install / sign up
Set your spend cap first — do this now
In the Claude Console, set a workspace spend limit of $5–10 below your tier cap. Most work runs locally on Gemma (free); only hard POIs escalate to Claude. The cap means escalation physically cannot overspend. This is the most important box on the page.
Alternatives (for later)
- Database: self-hosted Postgres or SQLite on your VPS instead of Supabase.
- Monitoring: self-hosted Uptime Kuma instead of Healthchecks.io.
- Scheduler: cron instead of launchd (note: cron won't run if the Mac was asleep at the scheduled time).
The picture: from pipeline to product
For the non-tech learner
Left is what you have. Right is where you're going. Saturday adds the loop and tools in the middle; Sunday adds the database, API and UI on the right.
flowchart LR
I[Ingest data] --> X[Gemma extract]
X --> C[Confidence score]
C --> O[(CSV / JSON)]
O --> E[Accuracy eval]
Weekend 1: a straight line — a model with almost no harness.
Build the harness
Goal: an agent that re-checks its own work, recovers from crashes, passes an eval gate, and respects hard limits.
1.1 The agent loop
For the non-tech learner
A harness is all the code around the model that makes it a reliable agent — the loop, tools, memory, retries, guardrails, checks. (Claude Code is a harness.) The agent loop is plan → act → observe → repeat, where the model picks the next action and decides when it's done. Anthropic's rule: use the simplest thing that works, so we only loop the one step that benefits — re-checking low-confidence POIs — and leave the bulk as a fixed pipeline.
flowchart TD
P[Plan: pick next action] --> A[Act: call a tool]
A --> O[Observe: read the result]
O --> D{Done, or limit hit?}
D -->|No| P
D -->|Yes| END[Stop and report]
# agent_loop.py — the smallest real loop
MAX_STEPS = 3 # a guardrail (see 1.6)
def recheck_poi(poi):
for step in range(MAX_STEPS):
if open("STOP", "r").read().strip() == "1" if __import__("os").path.exists("STOP") else False:
break # kill switch
action = decide_next_action(poi) # model plans
if action["type"] == "done":
return action["poi"]
result = run_tool(action) # you act
poi = observe(poi, result) # feed result back
return poi # ran out of steps — keep best effortAlternatives (for later)
- Agent frameworks (LangGraph, etc.) give you loops + state for free. Worth it once your hand-written loop gets complex — not before.
1.2 Tool use, done properly
For the non-tech learner
Tool use / function calling: you describe tools (a name, a description, and parameters as a schema); when the model needs one, it outputs a structured request; your code runs it and returns the result. The model never runs code itself — you stay in control. Give the POI agent: a geocoder lookup, a database query, and a re-check.
sequenceDiagram
participant M as Model
participant Y as Your code
participant T as Tool / API
M->>Y: request geocode(address)
Y->>T: run the tool
T-->>Y: result or error
Y-->>M: here is the result
M->>M: continue reasoning
from pydantic import BaseModel
class GeocodeArgs(BaseModel):
address: str # the address to look up
# Tool definition the model reads (name + description + schema)
GEOCODE_TOOL = {
"type": "function",
"function": {
"name": "geocode_address",
"description": "Look up coordinates for an address via OpenStreetMap Nominatim.",
"parameters": GeocodeArgs.model_json_schema(),
},
}
# Give tools to Ollama: ollama.chat(model=..., messages=..., tools=[GEOCODE_TOOL])Compliance: Nominatim has hard limits
Max 1 request per second, set a real custom User-Agent (stock ones are blocked), and cache results. For scheduled/bulk use it's stricter still. Never scrape Facebook — stay on OSM / Overture / Foursquare-OS as in Weekend 1.
1.3 Handle tool failures
For the non-tech learner
Tools fail — timeouts, rate limits, bad data. Two rules: (1) never let a tool failure crash the run — return a readable error the model can react to; (2) retry with backoff (wait longer each try) but always cap the retries.
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import requests
@retry(stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(requests.RequestException))
def geocode_address(address: str) -> dict:
r = requests.get("https://nominatim.openstreetmap.org/search",
params={"q": address, "format": "json", "limit": 1},
headers={"User-Agent": "weekend2-poi-agent/1.0 (you@example.com)"},
timeout=10)
r.raise_for_status()
return r.json()[0] if r.json() else {}1.4 Memory & state — survive a crash
For the non-tech learner
State = what's done and what's left. A checkpoint saves it so a crashed job can resume instead of restarting. Idempotency means running a step twice is the same as once — so resuming never duplicates work (key on the record's id).
flowchart TD
S[Start or re-run] --> L[Load checkpoint]
L --> F{Pending records left?}
F -->|Yes| P[Process one record]
P --> SV[Mark done in checkpoint]
SV --> F
F -->|No| DONE[Batch complete]
import json, os
CKPT = "checkpoint.json"
def load(): return json.load(open(CKPT)) if os.path.exists(CKPT) else {}
def save(state): json.dump(state, open(CKPT, "w"))
def run(records):
state = load() # resume-aware
for rec in records:
if state.get(rec["id"]) == "done":
continue # idempotent skip
process(rec) # your work
state[rec["id"]] = "done"
save(state) # checkpoint after each unitTry it
Run it, kill it halfway (Ctrl-C), run again — it should skip what's done and finish. That's resumability.
1.5 Observability — see WHY
For the non-tech learner
An agent is a black box until you make it talk. Structured logging = machine-readable log lines (JSON) you can search. A trace / transcript = the full step-by-step record of one run. This is the most underrated skill: when the agent does something weird, the trace is how you find out why. Start with plain file logs — no new service needed.
import logging, json
logging.basicConfig(filename="agent.log", level=logging.INFO,
format="%(message)s")
def log_decision(poi_id, step, decision, confidence, tokens=0):
logging.info(json.dumps({
"poi_id": poi_id, "step": step, "decision": decision,
"confidence": confidence, "tokens": tokens,
}))
# Later: grep poi_id agent.log to replay one POI's whole journeyAlternatives (for later)
- Graduate to Langfuse (open-source, free tier), LangSmith, Phoenix, or Weave when log volume justifies a real trace viewer. Instrument once with OpenTelemetry-style traces, switch backends later.
1.6 Evals-in-the-loop — make it a gate
For the non-tech learner
Weekend 1's one-off accuracy check becomes a gate: output is only published if the eval passes a threshold (e.g. F1 ≥ 0.85). Two kinds: capability evals ("what can it do?", start low) and regression evals ("does it still do what it used to?", aim near 100%). Build the threshold from real past failures, and read the transcripts before blaming the agent.
flowchart LR
B[Batch output] --> EV[Run regression eval]
EV --> Q{F1 above threshold?}
Q -->|Yes| PUB[Publish to database]
Q -->|No| QU[Quarantine and alert]
def eval_gate(pred_csv, golden_csv, threshold=0.85):
precision, recall, f1 = evaluate(pred_csv, golden_csv) # from Weekend 1
passed = f1 >= threshold
print(f"F1={f1:.2f} gate={'PASS' if passed else 'FAIL'}")
return passed # if False, quarantine the batch instead of publishing1.7 Guardrails — hard limits for an autonomous loop
For the non-tech learner
An agent that loops and spends money needs hard limits so a bug can't run forever or run up a bill. Layer them: max iterations, a per-run cost budget that aborts, max_tokens caps, rate limits, a kill switch, and the platform spend cap you set in Prep.
import os
class Guardrails:
def __init__(self, max_steps=3, cost_budget=1.00):
self.max_steps, self.cost_budget = max_steps, cost_budget
self.steps, self.cost = 0, 0.0
def check(self):
if os.path.exists("STOP"): raise SystemExit("kill switch")
if self.steps >= self.max_steps: raise SystemExit("max steps")
if self.cost >= self.cost_budget: raise SystemExit("over budget")
def spend(self, dollars): self.cost += dollars
def tick(self): self.steps += 1; self.check()Saturday capstone
What "done" looks like
Run the full agent on a real batch and confirm all five: a forced crash resumes cleanly; low-confidence POIs trigger tool-backed re-checks; the eval gate blocks bad output; a guardrail demonstrably trips; and you can open the trace and narrate one POI's journey end-to-end.
Make it a product
Goal: the agent's reviewed results live on your subdomain, refreshing on a schedule. Building this is the lesson — you're using the very ship-a-product skills the course teaches.
2.1 A real database
For the non-tech learner
A database is a typed spreadsheet that programs read and write. A table = a sheet, a row = a record, a schema = the column definitions. Supabase gives you a managed Postgres database on a free tier (and auto-generates an API). Point your agent's output here instead of a CSV.
create table pois ( id text primary key, name text, category text, lat double precision, lon double precision, confidence real, status text default 'pending', -- pending | done | needs_review source text, updated_at timestamptz default now() );
Alternatives (for later)
- Self-hosted Postgres or a single-file SQLite on your VPS if you want full control and no third party.
2.2 A small API
For the non-tech learner
An API is a URL your code exposes; ask it and get structured data (JSON) back. FastAPI is a friendly Python way to build one, and it auto-creates a test page at /docs so you can click instead of code.
from fastapi import FastAPI
app = FastAPI()
@app.get("/pois")
def list_pois(min_confidence: float = 0.0):
rows = db_query("select * from pois where confidence >= %s", (min_confidence,))
return rows # FastAPI returns it as JSON
@app.post("/pois/{poi_id}/correct")
def correct(poi_id: str, name: str, category: str):
db_exec("update pois set name=%s, category=%s, status='done' where id=%s",
(name, category, poi_id))
return {"ok": True}
# run it: uvicorn main:app --reload then open http://localhost:8000/docs2.3 A review UI (human-in-the-loop)
For the non-tech learner
Reuse your Weekend 1 web skills: a page that lists POIs, highlights the low-confidence ones, and lets a human fix them. Corrections write back through the API — and feed your golden set, so the evals from Saturday keep improving. Let Claude Code scaffold this for you.
<!-- review.html (sketch) -->
<script>
async function load() {
const pois = await (await fetch("/pois?min_confidence=0")).json();
// render rows; flag confidence < 0.7 in coral
}
async function correct(id, name, category) {
await fetch(`/pois/${id}/correct?name=${name}&category=${category}`, {method:"POST"});
}
load();
</script>2.4 Containerize with Docker
For the non-tech learner
Docker packages your app + everything it needs into a portable box (a container) that runs the same on your Mac and on the VPS — killing "works on my machine" bugs. The recipe is a Dockerfile.
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Gotcha
Your Mac is ARM; many VPSs are x86. Build for the server: docker build --platform linux/amd64 -t poi-api .
2.5 Auto-deploy with GitHub Actions
For the non-tech learner
CI/CD = every git push automatically builds and deploys — no manual SSH. GitHub Actions builds the image, pushes it to a registry, then SSHes into your VPS to restart the container. The VPS is a runtime server, not a build server. Store all secrets in GitHub Secrets, never in the file.
flowchart LR
G[git push] --> GA[GitHub Actions]
GA --> BLD[Build Docker image]
BLD --> REG[Push to registry]
REG --> SSH[SSH to VPS]
SSH --> UP[docker compose up -d]
UP --> HC[Health check]
name: deploy
on: { push: { branches: [main] } }
jobs:
ship:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/poi-api
git pull && docker compose up -d --build
curl -fsS https://your.subdomain/pois >/dev/null # health check2.6 Schedule it + monitor + cost alarm
For the non-tech learner
Make the agent run itself. On a Mac, launchd is the modern scheduler (it even runs on wake if a scheduled time was missed while asleep — cron won't). A dead-man's-switch alerts you when the job fails to ping on success — the best way to catch silent failures. Plus the spend cap from Prep is your cost alarm.
<!-- ~/Library/LaunchAgents/com.you.poiagent.plist -->
<plist version="1.0"><dict>
<key>Label</key><string>com.you.poiagent</string>
<key>ProgramArguments</key>
<array><string>/path/.venv/bin/python</string><string>/path/run_batch.py</string></array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>3</integer></dict>
</dict></plist>
# load it: launchctl load ~/Library/LaunchAgents/com.you.poiagent.plist
# at the end of run_batch.py, on success:
# requests.get("https://hc-ping.com/YOUR-UUID") # dead-man's-switchThe finished product
flowchart TD
SCH[Scheduled agent<br/>launchd] --> DB[(Database<br/>Supabase Postgres)]
DB --> API[FastAPI service]
API --> NG[nginx + HTTPS<br/>your subdomain]
NG --> UI[Review UI]
UI -->|corrections| DB
DB -.feeds.-> GS[Golden set / evals]
Sunday capstone — what "done" looks like
Your subdomain serves reviewed POIs over HTTPS; a git push auto-deploys; the scheduled run pings the dead-man's-switch; the cost alarm is armed. That's a real self-running product.
Reference cards
Keep these open while you work.
Command cheat-sheet
PYTHON / API python3 -m venv .venv && source .venv/bin/activate uvicorn main:app --reload run the API + /docs DOCKER docker build --platform linux/amd64 -t poi-api . docker run -p 8000:8000 poi-api docker compose up -d --build DEPLOY / OPS git push triggers GitHub Actions deploy launchctl load ~/Library/LaunchAgents/com.you.poiagent.plist launchctl list | grep poiagent is it scheduled? echo 1 > STOP kill switch on the agent loop
Troubleshooting
| Symptom | First thing to try |
|---|---|
| Model returns tool calls as plain text | temperature 0 + Pydantic structured output, or a tool-tuned model |
| Agent loops forever / costs spike | check max-steps + cost budget; create the STOP file |
| Resume re-does finished work | checkpoint isn't saving, or you're not keying on id |
| Nominatim 403 / blocked | set a real custom User-Agent; stay ≤1 req/sec; cache |
| Eval gate keeps failing | read the transcripts before changing prompts |
| Docker image won't run on VPS | build for linux/amd64 |
| Scheduled job never runs | launchd plist syntax/permissions; test a trivial job first |
| Supabase project paused | free tier auto-pauses after 7 idle days; the daily run keeps it warm |
Glossary
- Harness / scaffold
- all the code around the model that makes it a reliable agent
- Agent loop
- plan → act → observe → repeat, with the model deciding the next step
- Workflow vs agent
- you hardcode the steps / the model chooses the steps
- Tool use / function calling
- giving the model functions it can request; your code runs them
- Tool schema
- the structured description (name, params) the model reads to call a tool
- State / checkpoint
- a saved record of progress so a crash can resume
- Idempotency
- doing a step twice equals doing it once — no duplicate work
- Observability
- being able to see why the agent did something, via logs + traces
- Trace / transcript
- the full step-by-step record of one agent run
- Eval gate
- a rule that blocks output unless the eval passes a threshold
- Capability vs regression eval
- "what can it do?" (starts low) vs "does it still?" (near 100%)
- Guardrail
- a hard limit (max steps, cost cap, kill switch) that stops a runaway agent
- Retry / backoff
- re-trying a failed call, waiting longer each time, capped
- Database / table / row / schema
- structured storage; a typed sheet; a record; the columns
- API / endpoint / JSON
- a URL returning structured data; one such URL; the data format
- FastAPI / uvicorn
- a Python API framework; the server that runs it
- Docker / container / Dockerfile
- packaging tech; a running instance; the build recipe
- CI/CD / GitHub Actions
- auto build+deploy on push; GitHub's tool for it
- launchd / cron
- macOS's modern scheduler; the older Unix one
- Dead-man's-switch
- a monitor that alerts when an expected "I'm alive" ping fails to arrive
Go deeper (official first)
A note on accuracy
Prices, free tiers, and model tool-calling quality change month to month. Confirm Claude pricing, Supabase/GitHub free-tier limits, and your model's tool support on the official pages before you rely on them.