Writing

Recommending books by mood: a small LLM product in a day

In short: you type how you feel, and the app gives you three books, three meals and three things to do. It is one prompt, one API route and one screen, and it took a day because I wrote down what it…

published
read time
5 min
words
948
lang
en
filed under
Product

In short: you type how you feel, and the app gives you three books, three meals and three things to do. It is one prompt, one API route and one screen, and it took a day because I wrote down what it would not do before I wrote any code.

Most of what I build takes months and has a lot of moving parts. Once in a while I like to build something that fits in a day, start to finish, just to remember how little a useful product can be. This one is public as What_to_Read_Today on my GitHub.

Scope first

The question behind it is one I ask myself most evenings: what should I read right now? Not "what is the best book", but what fits the mood I'm in. A tired evening and a restless Sunday morning want different books.

Books came first. Meals and activities came later the same day, because once the model understands how you feel, three more lists cost one more line in the prompt and one more section on the screen. That was the only scope that grew.

What it does:

  • Takes a sentence or two about how you feel.
  • Returns three books, three meals and three activities, each with a short reason.

What it does not do, on purpose:

  • No accounts, no history, no saved favourites.
  • No ratings, no feedback loop, no "people like you also read".
  • No links to buy anything.

Each item on the second list is a good idea. Each one is also another day, another table, or another privacy question. For a one-day build, the "not" list is the plan.

Three screens

how do you feel? match
books
meals activities
Input, books, then meals and activities. Nine suggestions from one request, and nothing to sign up for.

It is a Next.js 14 app with the App Router, TypeScript and Tailwind CSS. I gave it a neon, glassy look because a one-day app is a good place to try a style I wouldn't put on a serious product. The layout is a single column, so it works on a phone without any extra effort.

The two states people notice most got the most care. While the model thinks, the button says what is happening instead of spinning silently. When something fails, the message is one plain sentence and the text you typed is still there, so trying again costs one tap. Neither took long. Both are the difference between a demo and something a friend will open twice.

The prompt is the product

The whole backend is one route. It checks the input, asks the model for a fixed JSON shape, checks the output, and returns it. Everything interesting is in the system prompt.

import OpenAI from "openai";
import { NextResponse } from "next/server";

const openai = new OpenAI();

type Item = { title: string; why: string };
type Recs = { books: Item[]; meals: Item[]; activities: Item[] };

const SYSTEM = `You suggest things that fit how someone feels right now.
Reply with JSON only: {"books": [], "meals": [], "activities": []}.
Exactly 3 items per list. Each item has "title" and "why" (one sentence).
For books, put the author in the title. Only suggest books that exist.`;

function isRecs(x: any): x is Recs {
  return ["books", "meals", "activities"].every(
    (k) =>
      Array.isArray(x?.[k]) &&
      x[k].length === 3 &&
      x[k].every((i: any) => typeof i?.title === "string" && typeof i?.why === "string")
  );
}

export async function POST(req: Request) {
  const { mood } = await req.json();
  if (typeof mood !== "string" || !mood.trim() || mood.length > 500) {
    return NextResponse.json({ error: "Tell me how you feel in a sentence or two." }, { status: 400 });
  }
  const res = await openai.chat.completions.create({
    model: "gpt-4",
    temperature: 0.8,
    messages: [
      { role: "system", content: SYSTEM },
      { role: "user", content: mood },
    ],
  });
  try {
    const data = JSON.parse(res.choices[0].message.content ?? "");
    if (isRecs(data)) return NextResponse.json(data);
  } catch {}
  return NextResponse.json({ error: "That didn't work. Try again?" }, { status: 502 });
}

Three choices in that prompt did most of the work:

  • Exactly three. Ten suggestions is a list you scroll past. Three is a choice you can make.
  • A one-sentence "why". Without it the books felt random. With it, you can see the model understood the mood, and you can disagree with it.
  • Author in the title. Models sometimes invent books. An author name gives you something to search, and makes an invented book much easier to spot.
TipCheck the model's output against the exact shape your UI expects before you return it. A plain type guard is enough. Your screen then has only two states to handle, a full result or a friendly error, and never half a list.

What I'd add, and what I'd still leave out

If I spend a second day on it, the first thing is a check that each book exists, against a public book catalogue, before it reaches the screen. The second is a small "not for me" button, so I can see which moods the prompt handles badly.

I'd still leave out accounts. The moment there is a login, it stops being a thing you open on a whim, and opening it on a whim is the whole point.

Build your own version

Clone the repo, run npm install, put your OPENAI_API_KEY in .env, and start it with npm run dev. Then change one thing: replace books with films, podcasts or walks near you, edit the system prompt, and keep the "exactly three" and "why" rules. Before you start, write your own "does not do" list. If it's longer than the "does" list, you're probably building the right size of thing for a day.

related

Keep reading