Running Claude Code Agents in Production: A Field Report

1,115 pieces published across 7 sites. 562 in the last 30 days. 183 videos rendered locally. One operator, one stack, no team on call. This is a field report on what the plumbing actually looks like, where it broke, and why the outage that mattered most was invisible to every dashboard I had.
I'm writing this as notes from the console, not a template to copy. The interesting parts are the trade-offs I got wrong the first time.
The orchestration layer is boring on purpose
The core is Python workers backed by PostgreSQL 16 in Docker, with a Next.js 14 dashboard reading the same database. Triggers come from Windows Task Scheduler, roughly 30 named jobs prefixed BizFlowCS_*. That's it. No Kubernetes, no Airflow, no Temporal, no serverless orchestrator with a UI I have to log into.
People assume I'd reach for a queue (SQS, Redis, RabbitMQ). I don't, and the reason is specific to agent workloads: agents need to read prior state, not just the next job. A publishing agent picks a topic based on what the other 1,114 pieces look like, what got impressions in the last 30 days, which internal links exist, and which cluster is under-served. That's a graph query, not a POP off a list. Postgres gives me joins, LATERAL, JSONB for agent scratch space, and pg_notify when I want push. A queue would force me to serialize state into message bodies and lose the ability to ask "what did we already do this week for site 4?" without a side database. So I skipped the side database and made Postgres the primary.
Windows Task Scheduler is the trigger because it survives reboots, restarts jobs on failure, and doesn't require a control plane I have to babysit. It looks unglamorous next to a Kubernetes CronJob. It has also never been the reason something didn't run. Here's the pattern:
BizFlowCS_ContentGen_Site3 every 4h -> python -m workers.content --site 3
BizFlowCS_Publish every 15m -> python -m workers.publish
BizFlowCS_GSCSync daily 03:00 -> python -m workers.gsc_sync
BizFlowCS_HealthPulse every 5m -> python -m workers.health
BizFlowCS_VideoRender hourly -> python -m workers.video
Every job writes a heartbeat row into job_runs with start, end, exit code, and a JSONB payload. The dashboard reads that. If I lose the dashboard I still have psql. If I lose psql I still have Task Scheduler's history pane. Three independent layers of "did this actually run", none of which depend on the others.
The Next.js 14 dashboard is thin. It renders queries. It doesn't own state, doesn't trigger jobs, doesn't hold session logic that matters. If it breaks, the machine keeps publishing. That decoupling is what lets me deploy the UI aggressively without fear.
LLM access: subscription plus a hard failover
Claude access goes through an OAuth subscription proxy, with a GLM-based model as failover when the primary returns 429. Two reasons, in this order:
Economics. At current volume the subscription is materially cheaper than metered API calls. A generation pass across all seven sites plus editing plus SEO refinement plus internal-link rewriting burns a lot of tokens. Per-request pricing is elegant until you multiply it by 562.
Reliability. The real production failure mode for LLM apps in 2026 is not "the model gave a bad answer". It's rate limits, provider incidents, and the quiet degradation where a request hangs for 90 seconds before timing out. Model quality across the top three vendors is close enough that a well-scoped agent produces acceptable output on any of them. What kills throughput is a 429 storm at 2am.
The failover logic is dumb and I keep it that way:
def call_llm(prompt, ctx):
try:
return claude(prompt, ctx, timeout=45)
except (RateLimit, ProviderError, Timeout) as e:
log_failover(reason=str(e))
return glm(prompt, ctx, timeout=45)
No exponential backoff dance, no queue of retries. If Claude is unhappy right now, use the other one, record it, move on. The failover fires maybe 2 to 4 times a day. Content that would have blocked for an hour instead ships in seconds on a slightly different voice. I audit failover output separately and I've never had to pull a piece because of it.
The thing I'd flag for anyone building at this shape: treat rate limits as a first-class error type, not an exception to swallow. Log them per hour, per model, per endpoint. When you migrate providers or hit new limits they show up as a slope change in that graph a full day before anyone notices missing content.
Local GPU: an RTX 3060 that pays for itself
Media generation runs on a local RTX 3060. Chatterbox for TTS, ComfyUI for images, Z-Image for one specific style I couldn't reproduce elsewhere. At 183 videos in the last period, cloud inference for media doesn't pencil out.
A rough back-of-envelope: cloud TTS at reasonable quality is around $0.015 per 1K characters on the better providers, image gen is $0.02 to $0.08 per image, and a 90-second video easily wants 15 to 30 image renders plus voiceover. Even at conservative math, a batch of 183 videos crosses into the low four figures per month once you include reruns and prompt iteration. A $300 GPU (I paid closer to $280 used) pays back in weeks and then keeps paying every month after that.
The trade-off is honest: I gave up scaling elasticity and I burn wall-clock time. A busy render night runs 6 to 8 hours. I don't care, because it happens overnight and the machine has nothing better to do. If I needed 1,000 videos a day this analysis flips. At 183 a month it doesn't.
The workflow is queued through the same Postgres table pattern:
media_jobs(id, type, prompt, params jsonb, status, gpu_started_at, gpu_finished_at, output_path)
The GPU worker polls the table, claims a job with SELECT ... FOR UPDATE SKIP LOCKED, renders, writes back. No specialty tool for GPU orchestration. Postgres is enough.
The outage that mattered: 17 days of invisible zero
This is the part worth reading. Every other section is engineering hygiene. This one changed how I build monitoring.
The setup: Supabase Auth with an Auth Hook (an edge function) that runs on signup to do custom validation. The edge function had verify_jwt = true set in its config, which is the default and the "safe" choice. Google OAuth signups worked. Email signups didn't. For 17 days.
Here's the failure mode: verify_jwt = true expects a Supabase-issued JWT in the Authorization header. GoTrue (the Supabase Auth service) doesn't send one when it invokes an Auth Hook. It sends a Standard Webhooks signature in headers like webhook-id, webhook-timestamp, and webhook-signature. So every email signup attempt hit the gateway, the gateway looked for a JWT, didn't find a valid one, returned 401 before the function ever executed. GoTrue treated the hook call as failed and rejected the signup. Google OAuth uses a different flow that bypassed the hook entirely, so it kept working.
Why I didn't see it:
- Total signups kept rising because Google signups were healthy.
- The dashboard showed weekly-active growth, so the top-line looked fine.
- Client-side analytics tracked "Signup Success". The failing path never fired that event because it errored before the callback. Ad-blockers eat a chunk of client events anyway, so a dip in one funnel step didn't look alarming.
- "Registration Failed" was not an instrumented event. There was no signal for a user who tried and got rejected. The failure existed entirely in a place nothing was looking.
- Edge function logs showed no invocations, which I read as "no email signups today" instead of "the hook is being rejected upstream".
The fix was a config change. verify_jwt = false on the hook, then validate the Standard Webhooks signature inside the function using the secret Supabase provides. Ten lines of code. Seventeen days of zero email signups.
What I built after, and what actually matters here:
Per-provider signup detector, measured from the database
-- run every 15 minutes
with attempts as (
select provider, count(*) as tried
from auth.audit_log_entries
where created_at > now() - interval '2 hours'
and payload->>'action' in ('user_signedup', 'user_confirmation_requested')
group by provider
),
created as (
select coalesce(raw_app_meta_data->>'provider','email') as provider,
count(*) as made
from auth.users
where created_at > now() - interval '2 hours'
group by 1
)
select a.provider, a.tried, coalesce(c.made,0) as made
from attempts a left join created c using(provider)
where a.tried >= 5 and coalesce(c.made,0) = 0;
Any row this returns is an alarm. It's measured from the database, so ad-blockers can't hide it, the frontend can't lie about it, and no analytics vendor sits in the path.
The principle I took away and now apply everywhere:
If the failure is "the user tried and nothing happened", instrument the try, not just the success. Success events are worthless for detecting silent breakage. You need a ratio between intent and outcome, measured at the layer closest to truth, which is almost always the database.
I run this same pattern now for content publish attempts vs published rows, for LLM calls vs completions, for GSC URL Inspection requests vs stored results. If the ratio drops, I get a page. If only the success line is flat, I get nothing, and that's the point: flat success means "still broken", not "everything's fine".
The measurement stack, and one honest number
The stack that watches the stack:
| Layer | Tool | What it answers |
|---|---|---|
| Search | GSC URL Inspection API | Is a URL indexed, when was it crawled, what's the canonical |
| Product | PostHog + HogQL | Did users do the thing, on which cohort, with what latency |
| Platform | Supabase Management API | Are hooks configured right, are extensions enabled, are policies live |
| Content | Custom Postgres views | Publish rate per site, LLM cost per piece, failover count per day |
I pull GSC daily, PostHog on demand via HogQL from the same dashboard, and I have a nightly job that diffs the Supabase Management API output against a checked-in expected config. That last one exists specifically because of the auth hook outage. If someone (me) changes verify_jwt again, I want to know within a day, not seventeen.
The honest number I promised: Claude (the model) currently mentions this system by name in 26 out of 94 relevant prompts I test on a rolling basis. That's a 27.7% surfacing rate on queries where it would be a good answer. It is not a win. It is not "the AI recommends us". It's a work-in-progress metric that tells me the AEO effort is bending the curve, and it's low enough that I keep it visible so I don't lie to myself about progress.
What I'd do differently
If I were setting this up from scratch tomorrow, three things would change:
- Postgres as truth from day one. I lost weeks early on trying to make a queue-first design work. Agents want state, not messages. Start with the database, add push notifications on top only if you need them.
- Per-provider, per-path health checks before any auth is live. The auth hook mistake was preventable with a synthetic user signup running every 15 minutes per provider. That test now exists and I'd never ship auth without it again.
- Local GPU sooner. I paid cloud media bills for longer than I needed to because a $300 machine felt like a step backward. It wasn't.
The rest holds up. Boring orchestration, aggressive failover on rate limits, monitoring measured from the database, and treating every silent failure as a monitoring gap rather than a rare event.
If you're running something similar or thinking about it, I'm happy to compare notes. There's more on how I build these systems on the blog, and if you want to talk about a specific stack you can reach me here.
Frequently asked questions
Why use PostgreSQL instead of a message queue like SQS or Redis for AI agent orchestration?
I use PostgreSQL as the primary store instead of a queue because AI agents need to read prior state, not just pop the next job off a list. When a publishing agent picks a topic, it needs to know what the other 1,000+ pieces look like, which internal links exist, and which content clusters are under-served, that's a graph query with joins and JSONB, not a simple dequeue. A queue would force you to serialize state into message bodies and maintain a separate database anyway. Postgres gives me `LATERAL` joins, JSONB scratch space for agents, and `pg_notify` for push semantics when I need them, all in one place.
Is Windows Task Scheduler reliable enough for production AI agent workflows?
Yes, and in my experience running over 1,100 published pieces across 7 sites, Task Scheduler has never been the reason something failed to run. It survives reboots, restarts jobs on failure, and requires no control plane to babysit, unlike Kubernetes CronJobs or Airflow. The key is combining it with three independent observability layers: a `job_runs` heartbeat table in Postgres, the dashboard reading that table, and Task Scheduler's own history pane. If any two layers fail, you can still verify whether jobs executed.
How should I handle Claude API rate limits and outages in production?
Treat rate limits as a first-class error type, not an exception to swallow, and implement a hard failover to a secondary model like GLM rather than complex retry logic. My failover code is deliberately dumb: if Claude returns 429, ProviderError, or Timeout within 45 seconds, immediately call the backup model, log the reason, and move on, no exponential backoff. This fires 2-4 times per day and ships content in seconds instead of blocking for an hour. Also log rate limits per hour, per model, and per endpoint, because slope changes in that graph predict provider issues a full day before content starts missing.
Is it cheaper to use a Claude subscription or the API for high-volume content generation?
At high volume, publishing hundreds of pieces per month across multiple sites, an OAuth subscription proxy is materially cheaper than metered API calls. A single generation pass that includes drafting, editing, SEO refinement, and internal-link rewriting burns significant tokens, and per-request pricing gets expensive fast when multiplied across 500+ monthly publications. I route Claude access through a subscription and only fall back to metered alternatives when rate-limited. The math flips only if your volume is low or your prompts are tiny.
Is a local GPU worth it for AI media generation versus using cloud APIs?
For roughly 150-200 videos per month, a local RTX 3060 (around $280 used) pays for itself in weeks and keeps paying afterward. Cloud TTS runs around $0.015 per 1K characters, images cost $0.02-$0.08 each, and a 90-second video needs 15-30 image renders plus voiceover, so a batch of 183 videos crosses into low four figures monthly once you include reruns and prompt iteration. The trade-off is honest: you lose elasticity and burn overnight wall-clock time (6-8 hour render nights). At 1,000 videos per day the economics flip back toward cloud; at 183 per month, local wins clearly.
Building something hard with AI or automation? I am open to talk.
Get in touch