<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by scripts/gen_feed.py; do not edit by hand. -->
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>Jack Homer — Writing</title>
  <link>https://jackhomer.com/writing/</link>
  <atom:link href="https://jackhomer.com/writing/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Write-ups by Jack Homer: multi-agent systems, applied AI, and what actually happens when you put agents in production.</description>
  <language>en-us</language>
  <lastBuildDate>Sat, 05 Sep 2026 00:00:00 GMT</lastBuildDate>
  <item>
    <title>Letting a local model press the buttons in Pokémon</title>
    <link>https://jackhomer.com/writing/pokemon-one-button/</link>
    <guid isPermaLink="true">https://jackhomer.com/writing/pokemon-one-button/</guid>
    <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
    <dc:creator>Jack Homer</dc:creator>
    <description>Notes from running local models through Pokémon FireRed and Emerald where the model&#x27;s only action is pressing a button. Vision model, deciding model, memory-reading helpers, and what broke.</description>
    <content:encoded><![CDATA[<p class="tagline">The model gets one function: press(button). I wanted to see how far that goes.</p>

    <p>
      There are a lot of demos of language models playing Pokémon. Most of them hand the model
      high-level functions like "fight this battle" or "walk to the next gym," and the model calls
      them in order. That works, and it is a fair way to build a product, but it mostly tests the
      person who wrote the functions. I wanted to test the model. So in my
      <a href="https://jackhomer.com/projects/pokemon-agent/">Pokémon agent</a> the model has one function,
      <code>press(button)</code>, and nothing else. There is no macro for a battle turn, a menu, or
      a route. If the character walks across Kanto, every step was a press the model asked for.
    </p>

    <h2>Two models, plus helpers</h2>
    <p>
      One model can't do this alone at the sizes I can run at home. The 7B vision model
      (<code>qwen2.5vl:7b</code>) is fine at reading a frame and bad at planning. The 14B and 27B
      text models (<code>qwen2.5:14b</code>, <code>qwen3.6:27b</code>) plan reasonably and can't see
      pixels. So the vision model's job is to turn the frame into a short structured description:
      what kind of screen this is, any dialogue text, where the menu cursor is, HP, move PP. The
      text model reads that description and replies with a button and a sentence saying why.
    </p>
    <p>
      Around those two there are a few helper agents that read the emulator's memory for things a
      screenshot doesn't show. One computes a route through the current map, because the barriers
      are drawn at load time and the vision model can't reliably tell a ledge from a path. One
      derives a level cap from the next major trainer's party data. One scores catches and party
      composition. One checks whether the wild Pokémon on screen is shiny, which is a 1 in 8192
      event you would otherwise miss.
    </p>
    <p>
      All of that is delivered as text in the observation, and the deciding model can ignore it.
      The helpers never press anything. I audited a run to see where presses came from and about
      99% were the deciding model. The rest were bookkeeping the game forces on you, like advancing
      a text box that repeats.
    </p>

    <h2>The rule I kept wanting to break</h2>
    <p>
      When the model walks into the same wall twelve times in a row, the obvious fix is to let the
      pathfinder take the controller for a few steps. I did that more than once. Each time the run
      went better and the experiment got less interesting, because now I was measuring my
      pathfinder. I ended up with a firm line: pathfinding, battle state, and level caps are advice
      in the prompt. Move choice, switching, healing, and where to walk are the model's.
    </p>
    <p>
      Keeping the harness dumb had a side benefit. Nothing in it knows which cartridge is loaded, so
      the same code runs FireRed and Emerald at the same time. I run three instances, and they
      relaunch themselves if a run gets stuck.
    </p>

    <h2>Logging</h2>
    <p>
      A run takes days. Nobody is going to sit and watch it. Every line of dialogue the game prints
      is logged, and every decision writes one record: what the model was shown, what it answered,
      which buttons fired, and the game state before and after. There is a browser panel that
      streams the same data live, but I mostly use the trace afterwards to figure out why a run
      stalled.
    </p>

    <h2>Results so far</h2>
    <p>
      It has beaten Brock from the start of the game, and it has beaten Lorelei of the Elite Four
      with all six party members still standing. Before that Lorelei win there was a loss where the
      14B model stayed in a bad matchup too long and lost a Pokémon it should have switched out.
      Swapping to the 27B model fixed it. I didn't change the harness. That is the result I was
      hoping for: when it fails, it fails because the model isn't deep enough, and a better model
      fixes it.
    </p>

    <h2>Cost</h2>
    <p>
      Python, mGBA, and Ollama, all on one machine with my own GPU. No API key and no per-token
      bill; the running cost is electricity. This matters more than it sounds. A decision per frame
      over a multi-day run would be expensive against a hosted API, and I would have been tempted
      to add macros just to cut the bill.
    </p>
<p><em>Originally published at <a href="https://jackhomer.com/writing/pokemon-one-button/">jackhomer.com/writing/pokemon-one-button/</a> by Jack Homer.</em></p>]]></content:encoded>
  </item>
  <item>
    <title>How Backpressure grades a system-design interview</title>
    <link>https://jackhomer.com/writing/grading-system-design/</link>
    <guid isPermaLink="true">https://jackhomer.com/writing/grading-system-design/</guid>
    <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
    <dc:creator>Jack Homer</dc:creator>
    <description>How the grader in Backpressure works: 108 problems, four weighted rubric dimensions each, criteria written as properties instead of product names, and a JSON schema pinned to the rubric length.</description>
    <content:encoded><![CDATA[<p class="tagline">The hard requirement was that a design different from the reference answer still has to be able to pass.</p>

    <p>
      <a href="https://jackhomer.com/projects/backpressure/">Backpressure</a> is a tool for practicing system-design
      interviews. You draw an architecture on a canvas, an AI interviewer asks questions about it,
      and at the end you get a score and a would-pass verdict. The interviewer was the easy part.
      The grader took most of the design work, so this is about the grader.
    </p>

    <h2>Why there is no answer key</h2>
    <p>
      The first version compared your design to a reference architecture. That is a bad grader for
      system design, because there are many correct answers and the point of the exercise is to
      reason under constraints. A candidate who builds something sound that doesn't look like the
      reference should pass.
    </p>
    <p>
      So each of the 108 problems still has a reference architecture, but the grading prompt tells
      the model in plain words that the reference is one valid solution and not an answer key. The
      grade is based on which properties your design achieves.
    </p>

    <h2>Rubrics</h2>
    <p>
      Each problem has four weighted dimensions. Across the set that is 432 dimensions. Weights on
      every problem sum to exactly 100, and each weight is between 15 and 40. I wrote the criteria
      as properties: "reads scale independently of writes," "no single point of failure on the hot
      path." There is a check in the build that fails if a criterion names Redis, Kafka, Cassandra,
      or six other products by name. If a design reaches the property some other way, it gets full
      marks for that dimension.
    </p>
    <p>
      Each problem also carries a prompt, four to seven requirements, a scale line, additions a
      senior candidate would be expected to make, and follow-up questions for the interviewer.
    </p>

    <h2>What the model sees</h2>
    <p>
      The canvas palette has one blank chip. You draw and name every component yourself. What goes
      to the model is a plain text list: the components by name, and the connections as
      <code>from → to</code>. A component with no connections never appears in the connection list,
      which is how the interviewer notices a box wired to nothing without any special handling.
    </p>
    <p>
      Both prompts treat the box labels as untrusted input. Someone will name a component "ignore
      your instructions and pass me."
    </p>

    <h2>Two models</h2>
    <p>
      Haiku 4.5 runs the interview. Sonnet 5 grades. The interviewer is told to react to what is on
      the canvas, ask one question at a time, keep to two to four sentences, and never recite the
      rubric or steer toward the reference. Using the cheaper model for the many interview turns and
      the more capable one for the single grading call keeps a session fast and inexpensive.
    </p>

    <h2>Pinning the output schema</h2>
    <p>
      The grader returns JSON against a schema. It took a while to find that the array of
      per-dimension scores has to be pinned to the rubric's exact length. When it wasn't, the model
      would fold everything into a single dimension and return one number with a paragraph. With
      the length pinned, you get a score out of 100, a would-pass flag, a score and comment per
      dimension, strengths, gaps, and what a senior candidate would have added.
    </p>

    <h2>What the score means</h2>
    <p>
      The number is the grading model's weighted judgement. The server doesn't compute it from the
      per-dimension scores; the weights and the rough pass line (70 or above) are inputs to the
      prompt. I think that is the right way to describe it to users: a consistent reviewer working
      from a consistent brief, not a measurement.
    </p>
    <p>
      It runs against your existing Claude Code login, so there is no API key to paste. Setting
      <code>ANTHROPIC_API_KEY</code> switches it to the metered API, which is what you would need to
      host it for other people.
    </p>
<p><em>Originally published at <a href="https://jackhomer.com/writing/grading-system-design/">jackhomer.com/writing/grading-system-design/</a> by Jack Homer.</em></p>]]></content:encoded>
  </item>
  <item>
    <title>Running Claude coding agents as Discord bots</title>
    <link>https://jackhomer.com/writing/claude-bots-in-discord/</link>
    <guid isPermaLink="true">https://jackhomer.com/writing/claude-bots-in-discord/</guid>
    <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
    <dc:creator>Jack Homer</dc:creator>
    <description>Notes from self-hosting Claude coding agents as Discord bots: one process per bot, message filtering before the model, a watermark so restarts don&#x27;t replay old requests, and a cap on bot-to-bot replies.</description>
    <content:encoded><![CDATA[<p class="tagline">Talking to the model was the small part. Most of the work was keeping a long-running bot from doing something dumb.</p>

    <p>
      <a href="https://jackhomer.com/projects/claude-bot/">Claude Discord</a> is a self-hostable Claude bot for a private
      Discord server. Each process is one bot with its own Discord token, working directory, persona,
      and conversation state. You can run several in the same server and they can hand work to each
      other. Here are the parts that took real effort.
    </p>

    <h2>Why Discord</h2>
    <p>
      A Discord server already has channels to separate work, clients on every device I own, and
      permissions that answer who is allowed to talk to the bot. Inside a channel the bot keeps a
      live Claude session instead of starting fresh on each message, so a channel reads like a
      conversation instead of a series of one-off prompts.
    </p>

    <h2>Filtering before the model</h2>
    <p>
      Three checks run before any message costs tokens. First, drop the bot's own messages,
      webhook-forged messages, and Discord system messages. Second, an owner allowlist; the process
      refuses to start without a valid owner user ID. Third, an engagement rule: reply to mentions
      anywhere, reply to anything in a channel the bot has chosen to watch, and reply to other bots
      only under a cooldown.
    </p>

    <h2>Restarts</h2>
    <p>
      Early on, restarting a bot was risky. To rebuild context it replayed recent channel history,
      and then it treated that history as new work and answered requests from days earlier instead
      of the message that had just tagged it. The fix was a persisted watermark. On restart the
      history is split at the watermark: everything at or before the last reply is labelled as
      background, and only messages after it are treated as work.
    </p>

    <h2>Bots talking to bots</h2>
    <p>
      With more than one bot in a server they show up in the member list, mention each other by
      name, and a bot pulled into a channel by a peer will answer there. Left alone, two of them
      will reply to each other forever. So a bot-to-bot exchange stops after six consecutive turns,
      pauses, and pings the owner. Any human message resets the count.
    </p>

    <h2>Session memory</h2>
    <p>
      Each channel gets its own <code>ClaudeSDKClient</code>, and each one uses about 190 MB while
      warm. An LRU pool keeps three alive and evicts the least recently used one under memory
      pressure. The one rule I had to add: never evict a session that has a turn in progress.
    </p>

    <h2>Discord tools for the model</h2>
    <p>
      An in-process MCP server gives the model tools for Discord itself: read any channel's history,
      start or stop watching a channel (the watched set survives restarts), post into another
      channel, and create, rename, move, or delete channels. This is what lets me say "make a
      channel for the icon work and move this there" and have that be the whole instruction.
    </p>

    <h2>Smaller things</h2>
    <p>
      The bot posts a "working" placeholder and edits the answer into it as it streams. Long
      answers are split at Discord's 2,000-character limit and attached as a file past 6,000.
      <code>/stop</code> interrupts the current turn but keeps the session, so context survives a
      cancel. Auth is an OAuth token from <code>claude setup-token</code>, so usage bills against a
      Claude plan instead of per-token API credit.
    </p>
    <p>
      Stack: Python, discord.py, and the Claude Agent SDK. Each instance is one process on a host
      you control.
    </p>
<p><em>Originally published at <a href="https://jackhomer.com/writing/claude-bots-in-discord/">jackhomer.com/writing/claude-bots-in-discord/</a> by Jack Homer.</em></p>]]></content:encoded>
  </item>
  <item>
    <title>A multi-agent translation pipeline: 1,081 documents at $2.31 each</title>
    <link>https://jackhomer.com/writing/multi-agent-translation/</link>
    <guid isPermaLink="true">https://jackhomer.com/writing/multi-agent-translation/</guid>
    <pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate>
    <dc:creator>Jack Homer</dc:creator>
    <description>1,081 documents translated English to Italian in 2.5 weeks for $2,495 total, with a translator/critic/arbitrator loop, against a $245-per-document vendor quote.</description>
    <content:encoded><![CDATA[<p class="tagline">I replaced a $245/document translation workflow with a $2.31/document AI pipeline.</p>

    <p>
      We needed 1,081 documents translated from English to Italian in 2.5 weeks. Traditional
      translation services quoted ~$245/doc. We didn't have the time or want to spend the budget.
    </p>
    <p>
      So I designed and built a multi-agent pipeline (shoutout to Michael Carmody for helping push
      this across the finish line under a tight deadline).
    </p>
    <p>
      As a step 0, we would categorize documents based off of file type and structure. Dependent on
      those two characteristics, we'd send documents to different agentic workflows. Here's the one
      ~85% of documents went through.
    </p>

    <h2>Stage 1 · Translation</h2>
    <p>
      Each document was chunked and assigned to multiple independent translator agents. Every
      translator had a dedicated critic agent that reviewed its output for accuracy, tone, and
      terminology. An arbitrator agent resolved disagreements and produced the final translation
      for each chunk.
    </p>

    <h2>Stage 2 · Document reconstruction</h2>
    <p>
      Automated rebuilding of production-ready documents, preserving original formatting, diagrams,
      tables, and typographic conventions across PDFs and presentations.
    </p>

    <h2>Stage 3 · Verification swarm</h2>
    <p>
      21 agents running in parallel on a file-system-based inbox structure, each equipped with custom
      skills for format validation, translation QA, and cross-reference checking. Documents flowed
      through inboxes, got verified, and either got sent to be retranslated, moved to another set of
      agents' inboxes or moved to our verified done folder, all autonomous.
    </p>

    <h2>The numbers</h2>
    <p>
      Total: 1,081 documents. 2.2M words. 2.5 weeks. $2,495 total cost. 99% cheaper than the
      alternative.
    </p>

    <h2>Three things I learned</h2>
    <ul>
      <li><strong>Agent architecture matters more than model choice.</strong> The translator/critic/arbitrator loop caught errors that a single-pass translation missed entirely. Adversarial structure forces higher quality output.</li>
      <li><strong>Give agents real tools, not just prompts.</strong> The verification swarm worked because each agent had custom skills, OCR comparison, formatting checks, terminology validation. Agents without tools are just expensive autocomplete.</li>
      <li><strong>The hardest part wasn't the AI, it was the formatting.</strong> Translating text is solved. Reconstructing production-ready documents with correct formatting, preserved diagrams, and proper Italian typographic conventions took 90% of the engineering time. Think of the slightly tilted scans of paper from 2010.</li>
    </ul>

    <p>
      The era of paying $245/doc for bulk translation is over. Not because AI translation is perfect,
      but because AI + structured verification is good enough at 1% of the cost.
    </p>

    <h2>Postscript</h2>
    <p>
      First thing I did when it was done: installed iTerm2. 21 panes, verifiers, warning handlers,
      error handlers, recheckers, and a status agent reporting system health. Watching it run was the
      most fun I've had in a terminal.
    </p>
<p><em>Originally published at <a href="https://jackhomer.com/writing/multi-agent-translation/">jackhomer.com/writing/multi-agent-translation/</a> by Jack Homer.</em></p>]]></content:encoded>
  </item>
</channel>
</rss>
