DEV Community

bigkijimon
bigkijimon

Posted on • Originally published at zenn.dev

I Don't Write a Single Line of Code. I Built an Unattended Blog Factory with Claude Code — and the Real Culprit Wasn't Even the AI

📝 This is an English write-up of a piece originally published in Japanese on Zenn (canonical). All figures are real measurements from my own machine.

I don't write code. I build systems with prompts alone

Let me set my position straight first. I don't write code myself. I turn design and requirements into prompts and have Claude Code implement them. You can still build systems that way. But there is exactly one thing you cannot skip — observation. This is the story of learning that the painful way.

What I built: an unattended pipeline that generates a blog article every day, and — with a single tap of an approval button that arrives in Telegram — auto-publishes it to multiple platforms. Generation runs on a local LLM (Qwen), the goal being to run it for nothing beyond the electricity bill. The environment is macOS (Apple Silicon), so OS-dependent things like launchd, a Swift menu-bar app, and macOS's ps show up — read it with that in mind.

The design came together nicely. I ran it. And the moment I ran it, entirely unrelated tasks — video, image, and music generation — all stopped. Stuck in the queue, going nowhere. Only GPU utilization sat pinned at 50–65%.

"The AI is running non-stop. Maybe the VRAM is broken." That was my first assumption, and it was completely wrong. The real culprit wasn't even the AI. Let me go in order.

What I built: the architecture of an unattended blog factory

First, what I built.

Automated blog factory architecture

The flow:

  1. launchd (scheduled) — macOS's resident job mechanism pulls the trigger at set times.
  2. spool (job drop) — the trigger merely drops a job file ("write this today") into a spool directory. It does no processing itself.
  3. resident dispatcher — watches the spool, and when a job arrives, picks it up and runs it.
  4. headless claude -p — the dispatcher launches claude -p (non-interactive prompt mode) and has the local LLM (Qwen) write the article. This headless session actually runs as a self-driving process under caffeinate (sleep suppression).
  5. Telegram approval — the finished draft flies into Telegram. I just tap approve.
  6. Publish — once approved, it's poured into multiple platforms automatically.

The point is "the only thing a human does is tap approve." Generation, saving drafts, publishing — all unattended. Even I, who writes no code, can write this diagram down as a prompt and have Claude Code implement it into something that runs. As for languages: the dispatcher and the "watchdog" (below) are Shell/Bash, the status-display menu-bar app is Swift, article generation is headless claude -p.

— should have been something that runs.

The incident: the GPU pins, and video generation queues forever

The problem wasn't the blog factory itself. The blog got written. What broke was "the room next door."

In my setup, GPU-using generation tasks (video, image, music) are serialized so they don't fight over the single GPU. But that arbitration queue wasn't advancing at all. Video generation could never escape "waiting its turn."

Looking at the GPU with an Activity-Monitor-equivalent tool, utilization sat pinned at 50–65% constantly. Something looked like it was churning a heavy load. The symptom started once I began running the blog factory.

I concluded: "The blog's local LLM is running non-stop, eating the GPU. Maybe the VRAM broke and stopped being released." A plausible hypothesis. Plausible, and wrong.

The climax: pinning the GPU culprit by measurement, with SIGSTOP bisection

Start treating a problem on assumptions and you usually widen the wound. So I (by instructing Claude Code) decided to identify the culprit by measurement. The tool: "SIGSTOP bisection."

The idea is simple. Send kill -STOP <pid> and the process pauses without dying (kill -CONT resumes it). So pause each suspect process one at a time and observe how GPU utilization moves each time. When it drops, that's your culprit. Narrow the suspects binary-search style.

Pinning the true culprit with SIGSTOP bisection

How the measurement went:

  • Baseline: about 43% GPU utilization, touching nothing.
  • Stop the browser → unchanged.
  • Stop the terminal → unchanged.
  • Stop the stats-display app → unchanged. So far just wandering 43–52%; all innocent.
  • The instant I stopped a GUI Electron app (a desktop app like Claude.app) → about 2%, a sharp drop.
  • Confirmed with kill -9 for full termination → 0%.

The culprit was neither the blog's AI nor video generation. An Electron GUI app was redrawing the screen at a high frame rate — that was the main cause of "GPU 40–65% while idle."

There was proof, too. FPS reached 121–174. Behind a screen that looks static to the human eye, the GPU was firing over 100 redraws per second. And the breakdown of that load was Render/Tiler — i.e. screen drawing.

Retraction: GPU "utilization" and VRAM are different things

Here my assumption gets overturned at the root.

GPU utilization (drawing) and VRAM (compute) are different resources

I believed "high GPU utilization = the AI is computing = can't generate." The reality was:

  • The number I saw as GPU "utilization" was mostly Render/Tiler = screen drawing load, inflated by the GUI app's high-FPS redraw.
  • What image/video generation actually uses is VRAM (generation memory). Look there, and 40–52 GB was always free.
  • After Qwen's inference finishes, ollama ps shows the model empty. Ollama auto-unloads models after a period of idle (the timeout is set by the env var OLLAMA_KEEP_ALIVE, default 5m), so once inference ends, GPU compute is zero, VRAM ~50 GB free. Yet "utilization" alone looked pinned at 50–65%.

So "the GPU is busy = can't generate" was false. The GPU was busy drawing; the compute memory was wide open. I'd misread one number on a dashboard as the exhaustion of an entirely different resource.

Lesson: GPU utilization (Render/Tiler = drawing) and VRAM (generation memory) are different resources. Don't judge both by one meter. If generation looks jammed, look not at utilization but at "is VRAM free?" and "is the model actually loaded (ollama ps)?"

Secondary disaster: 129 hung ollama ps processes choking memory

Learning the culprit was drawing didn't fully clear things up. There was another real reason my gut misread this as "VRAM failure."

I had a self-made resident app (Swift) in the menu bar, which, to display GPU status, shell-executed ollama ps on every status update. Fine in normal times.

But when the local LLM is temporarily frozen (kill -STOP) for GPU arbitration, running ollama ps during that goes wrong. The query to the frozen process never returns, and ollama ps hangs indefinitely.

The fatal part: the shell-execution helper had no timeout. It waited endlessly for child-process termination, so it kept waiting on the hung ollama ps. Since status updates run periodically, the hung ollama ps and its wrappers piled up. Measured: 129 of them stagnant, choking memory. That was the true identity of the "system is heavy, broken" feeling.

Two fixes:

  • Give the shell-execution helper a 12-second timeout, and on expiry kill the whole child process.
  • Simply skip calling ollama ps entirely while frozen.

After rebuilding, reinstalling the fixed version, stopping the old processes and restarting, I observed for 60 seconds with the local LLM frozen. Hung ollama ps stagnation went 129 → 0. The pile-up stopped completely.

Lesson: if you shell-execute an external command, always give it a timeout, and on expiry kill down to the child process. A "just wait for it to finish" implementation is a time bomb that piles processes up infinitely the moment the other side hangs.

Another pitfall: the scheduled job had no single-run guard

The blog factory itself had a flaw too: the dispatcher had no single-run guard.

The scheduled trigger dispatched the next run when the time came, without checking whether the previous run had finished. Blog generation takes time. Result: three blog runs alive simultaneously, each pinned for 5–11 hours.

The nasty part is the positive feedback this creates. There's only one model server. Hang three runs on it at once and each run's throughput drops to 1/N. Slower means it doesn't finish. Not finishing means the previous run is still alive when the next arrives. The more they pile up, the slower it gets — "doesn't finish" self-propagates.

The fix was simple: before dispatch, check whether an existing run is present.

# If even one blog run (a claude session under caffeinate) is alive, don't dispatch a new one
if pgrep -f 'caffeinate -i claude' > /dev/null; then
  echo "A run is already active. Skipping this time."
  exit 0
fi
Enter fullscreen mode Exit fullscreen mode

If pgrep -f 'caffeinate -i claude' hits even once, skip the new dispatch. This serializes the runs so they stop fighting over the single model server. (For the three already tangled and pinned, I manually invoked the "watchdog" below to terminate all three cleanly, and confirmed by measurement that GPU and model were released immediately.)

Lesson: scheduled jobs (cron/launchd) don't care whether the previous one is still running. If processing can outlast the launch interval, always add a single-run guard (flock or a pgrep check) to serialize.

The watchdog mistook a "busy-spinning" run for healthy

As insurance, I had a resident script — a "watchdog" — to detect and kill hung runs. It had a hole too.

The watchdog only looked at "has the transcript (conversation log) gone 15 minutes without update?" The idea: no response for a while = presumed dead = kill. Reasonable enough.

But the "three runs pinned at once" from the previous section weren't unresponsive. They advanced slightly every few minutes — dribbling out tokens while actually barely moving forward. To the watchdog they read as "updated within 15 minutes = healthy." As a result, the watchdog overlooked these busy-spinning runs for about 10 hours.

The fix was to add a second axis of judgment. Set an upper bound (e.g. 50 minutes) on the process's actual age, and kill any run past it regardless of progress. Runs that slip through the idle check can't escape actual age.

One implementation trap: macOS's ps doesn't support etimes, which would give elapsed seconds directly. So I fetch etime (a dd-hh:mm:ss string) and convert to minutes myself.

Lesson: build hang-detection on "unresponsive time" alone and you miss "busy-spinning" tasks. Watch both unresponsive time and actual age. And platform dependencies (macOS ps doesn't support etimes) should be confirmed by actually running them before you implement.

The biggest trap in running a local LLM via headless claude

This, I think, is what hits local-LLM operators hardest.

I wanted to run blog generation on local Qwen. claude normally hits the cloud API. I want to point it at local Ollama. The obvious thought is "specify local by model name."

But — claude --model ollama/..., the model setting in the config file, config overrides — all of them silently fell back to the cloud. No error. The response comes back normally. So at a glance it looks like it's running locally. That's the trap.

What worked was not model specification but swapping the endpoint itself via an environment variable:

ANTHROPIC_BASE_URL=http://127.0.0.1:11434 \
ANTHROPIC_AUTH_TOKEN=ollama-local \
claude --model qwen32b-long
Enter fullscreen mode Exit fullscreen mode

Point ANTHROPIC_BASE_URL at Ollama's local endpoint. Only then did /v1/messages return a correct response (thinking block + body) with HTTP 200 in about 8.5 seconds.

And above all, the verification method matters. "A response comes back" is not proof it's running locally — a cloud fallback returns a response too. To confirm, always check that POST /v1/messages is increasing in Ollama's own server log. Prove that the request reaches the local server, at the receiving end's log. That's the only trustworthy evidence.

Lesson: switch a local LLM by "endpoint URL (ANTHROPIC_BASE_URL)," not by "model name." And don't be satisfied with "a response came back" — back it up with the POST increase in the server-side log.

Appendix: two landmines while building the local AI environment

Appendix 1: isolated-vm won't compile on Node.js 26

To make this self-driving terminal-less, I tried to install the workflow platform n8n (v2). But on Node.js 26 the native module isolated-vm won't build — it dies compiling serializer_nortti.o with Error 1. Conclusion: prepare Node.js 22 (a Homebrew keg-only package) and run n8n there. A too-new Node is a landmine for tools with native dependencies — confirm a tool's supported version before installing.

Appendix 2: an empty-match ls returns the current directory

When I had it build a "mv the matching files to another directory" loop, it enumerated targets with ls <glob>. But when the glob matches nothing, ls is treated as if called with no arguments and returns the current directory listing. It started moving files directly under the directory one by one, unintended (fortunately fully recoverable, no data loss). The fix: don't rely on ls — collect only files that actually exist into an array and pass them through an existence guard first. Remember that the shell's glob won't return "empty for empty."

Takeaway: you don't have to write code. But you can't skip observation

You can build an unattended automation platform with prompts alone, no code. This run proved it. But it also drove home, painfully: being able to build a system and being able to observe one are different skills. And you can't skip the latter.

What I learned this time, at the cost of my own money (well, tens of GPU-hours):

  1. GPU utilization and VRAM are different. High utilization (drawing) can coexist with free generation memory. Suspect a jam? Look at VRAM and model-load state, not utilization.
  2. SIGSTOP bisection. When the culprit is unknown, pause suspects one at a time with kill -STOP and observe the metric change — you pin it by measurement. Faster than treating it on assumptions.
  3. Always timeout shell execution. On expiry, kill down to the child. "Just wait for it to finish" piles processes up infinitely against a hang.
  4. Scheduled jobs need a single-run guard. Check that the previous run isn't alive before running, and serialize. Long processing × fixed interval self-propagates "doesn't finish."
  5. Verify local switching by log. Swap the endpoint with ANTHROPIC_BASE_URL, and back it up with the server-side POST increase, not "a response came back."

The ending — that the true culprit wasn't even the AI, just screen drawing — may sound a little dumb. But I think it was a fitting punishment for a human who swallowed a dashboard number whole. Next time someone's yelling "the GPU is pinned!", the first thing I'll ask is: "Utilization, or VRAM?"

Top comments (0)