Nathan Stehr

Notes on building things, mostly software.

An Agent With Taste and No Authority

A reading list that reads back

Intro

As a goal for the last half of the year, I wanted to be more intentional about reading more. Inspired by some voracious readers in the household I am setting out to read as much as I can and being open to all mediums including comic books and audiobooks.

To help with this I did what all curious software developers do in the year 2026: build an AI-powered reading and recommendation list.

What I ended up with is a system where an agent can argue for a book but can't put it on the list.

I. It started as one HTML file

The very first pass was a single HTML page with supporting CSS and JavaScript. It contained what is currently on my shelf and what I want to read next. The read next list was a manually curated list of books and comics that I built based on authors I like, art styles I enjoy and scouring different subreddits for recommendations. This was all hard coded into the app.

The next obvious step was to move this to being backed by a database. I added both an admin interface for adding and editing and a backing SQLite database.

Finally, to complete the full lifecycle of a side project, I needed somewhere to host it. Since it was all HTML/CSS/JS with an SQLite DB, Cloudflare Durable Objects was a perfect fit. For those who don't know Durable Objects are a serverless platform that has the really cool upgrade of maintaining a self-contained state using SQLite. Other than state, the other appealing thing for side projects is that they are available under the Workers free tier :).

II. Writing taste down

Now that I had the basic tech parts out of the way, I wanted to codify my 'taste'. What I like to read, what styles of art in comics I like, does the story finish (important for comics, weighing ongoing series vs. trade paperbacks and omnibuses), etc. This was then synthesized into 6 axes of taste that I could use to score each book or comic:

  1. Creator-owned / singular vision.
  2. Morally-grey / damned protagonist.
  3. Decaying / cursed / apocalyptic world.
  4. Sub-genre lean.
  5. Tone: dread over comedy.
  6. Ending & structural payoff.

Each one is a paragraph rather than a tag, because the edge cases are where the value is. Axis 1 is the one that gave me the most trouble later:

1. **Creator-owned / singular vision.** I strongly prefer work that is one creator's
   statement, or a tight creator team's, over editorially-driven or committee product.
   This is why even my superhero picks are the "handed to one bold creator" versions
   (Immortal Hulk, the Absolute line, Hickman's X-Men) rather than standard continuity.

There is a seventh axis that doesn't carry between mediums, the craft axis. For comics that's painterly art and a strong colorist. For prose it's voice and structure. I also keep a passed-on.md file with everything I've already declined and the reason why.

III. Then I pointed a robot at it

The axes that I mentioned earlier were written down in markdown. What do we immediately think of when we see markdown these days? Agents and LLMs. My first usage was opening up Claude, pointing it at the markdown on my machine and asking it to propose books and comics that fit my taste. This worked well enough, but I wanted to take it a step further.

This next step was providing a remote MCP interface. This allowed me to add it to Claude.ai and ChatGPT so I could ask recommendations anywhere I could open up a web browser.

The MCP provided the following tools:

claude.ai calling the MCP tools: search, two passed-on checks, then asking permission before it can stage anything

The answer: Kingdom Come staged as candidate #25, argued against the axes it hits and the ones it misses

The tools that do writes are gated by OAuth, with an additional check that validates that only a token representing me can make the changes. propose never touches the list. It writes a row to a pending table, and the schema makes it say which axes a pick misses:

server.registerTool(
  "propose",
  {
    title: "Propose a candidate",
    description:
      "Stage a recommendation for the reader to accept or reject. This NEVER writes to " +
      "the lists directly — it creates a pending candidate. State the fit reasoning " +
      "against the axes, and be honest about which axes it MISSES; a proposal that " +
      "claims only hits is less useful than one that names its weaknesses.",
    inputSchema: z.object({
      medium: MEDIUM_ENUM,
      title: z.string().min(1),
      fit_reasoning: z.string().min(1),
      axes_hit: z.array(z.string()).default([]),
      axes_missed: z.array(z.string()).default([]),
    }),
  },
  async (args) => { /* insert into candidates, never into entries */ },
);

The MCP was written in JavaScript, using Zod to model the tool schema and modelcontextprotocol and agents packages to implement the MCP itself.

You also might have noticed the core thesis to my approach. I am happy to rely on the agent to make recommendations, but I want to be the one to make the final decision. The agent is a tool to help me make better decisions, not a replacement for my own judgment.

IV. More Than Meets the Prompt

The MCP tool was working great. I was able to immediately add some recommendations to my list. But this all was predicated on me prompting the LLM at a point in time. After I've been browsing Reddit, popping into a bookstore or listening to a podcast. I thought it would be really great to have this automated. This is where Scout comes in.

Scout is my custom, automated agent that ties everything together. At its core it models a pipeline that harvests data sources for interesting titles, judges them against the taste profile, and makes recommendations to me to accept or reject. To make this all work I took the following high-level approach:

The weekly digest arriving in Telegram

Asking about a single title, standing in a bookshop, gets back a verdict and the argument behind it, including the axes it thinks the book misses:

A one-off verdict on Lazarus: propose at 0.90, with the argument and the axes it misses

V. Lessons Learned

I have had this running for a full scheduled pass as well as multiple ad hoc requests through the Telegram interface. This has been a really fun project and there have been a few things I have learned along the way, mostly by getting them wrong first.

Seed the facts because asking nicely doesn't work. propose cannot write to the lists. It stages a candidate with its reasoning and the axes it thinks it hits and misses, and I accept or reject. What is interesting is how much of this has to stay deterministic in code rather than letting the model rip. The cap on how many proposals reach me in a week is not a sentence in a prompt asking nicely for restraint; it is written in code:

if len(accepted) > in.MaxProposalsPerMedium {
    out.Dropped[m] = len(accepted) - in.MaxProposalsPerMedium
    logger.Info("over the proposal cap", "medium", m, "dropped", out.Dropped[m])
    accepted = accepted[:in.MaxProposalsPerMedium]
}

I gave the judge a search_web tool and it declined to use it, happily proposing at 0.90 confidence on a volume count, a colorist and "no known adaptation" pulled entirely from memory. It kept declining after I told it, in the transcript, to go and verify. The fix was to stop asking and run the search first, unconditionally, and hand it the results before it says anything. There is a field on every verdict called completeness_basis which indicates one of verified, my_own_knowledge, or not_established for the same reason as above. Once a fact is written into prose, you cannot tell a looked-up one from a remembered one.

Fairness only exists at the point where you truncate. Extraction costs a model call per post, so the harvest has a budget. I pooled every source, sorted by date, and took the top N. This quietly turned the budget into a contest about posting frequency. Adding two subreddits, which post hourly, took all five slots from newsletters that post weekly. Worse, it had been happening before I noticed: one comics site had been dropping out of every single pass simply because its posts were older. The fix was to bring in a round robin approach. A turn each, newest first within a source. Then I needed a second fix, when I realised the budget was rationing the wrong thing entirely. Fetching is a web request and extraction is what costs money. Cap the expensive step, after you know what is on offer.

Silence is ambiguous, so make the thing report itself. A weekly job that stops running and a quiet week with nothing worth proposing look identical from the outside. And I'm a busy guy so I probably won't be reading a droplet's logs to find out which. So every pass reports, including when it proposed nothing and when it failed. The same approach applies to cost: I did not know what a pass cost until I measured it. BAML provides a nice interface for capturing input and output tokens so I brought that into the code. A full scheduled run costs about $1.80, and judging turned out to consume three times the input tokens of extraction on half the calls, which is the number that tells you which knob to turn.

A feedback loop only closes if the "no" is as cheap as the "yes". I had thirteen acceptances and zero rejections, and it was not because everything proposed was wanted. Accepting was two clicks; declining was two clicks plus writing a sentence in a browser I was not sitting in front of. So the taste model only ever heard yes. I moved declining into Telegram to reduce that friction.

Declining a proposal: the queue, a button per candidate, and the reason reconciliation flags until I update passed-on.md

The spec was the fragile part, not the model. Scout proposed Batman: Year One and claimed it hit axis 1, creator-owned, on the grounds that one person wrote it and another drew it. I said that was obviously wrong. It is a work-for-hire DC book. Then I went and read my own axis properly, and found this sitting in it: "even my superhero picks are the 'handed to one bold creator' versions." By that reading Year One does hit, and I was the one about to write the wrong thing into the file that is supposed to be the source of truth. The fix was not code, it was a sentence in the taste file. The agent was not hallucinating here. It resolved an ambiguity with the data that it was given.

VI. Conclusion

I am still poking at this one and it's almost as fun working on the code as it is reading the books. The list is live here if you want to see what it actually produced. Ninety-four entries across comics and books, tiered by fit, with the judgment layer that shaped those picks kept in Markdown.

From a taste and preference perspective things are still evolving. As I read more of the lists and update the reader notes, the reasoning will change to then guide the agent to propose different books and sharper recommendations. But the core will always remain the same. The agent proposes but the part that says what I actually like is mine.