All posts
Fullstack

An assistant that answers as me

· 8 min read

The chat panel on this site answers visitors in my voice, so most of the work went into what it's allowed to say for me and how its replies get to the screen.

In the corner of this site there's a button that says "Ask me anything". Behind it, a language model answers visitors in the first person, as me: "I built", not "Amine built".

That puts my name on words I didn't write, so most of the work went into what the model may state as fact and what it must never agree to on my behalf.

Where the answers come from#

The model knows nothing about me on its own. Everything it says comes from notes sent with the system prompt, and I don't write those by hand.

The same data the pages render#

buildContext() in lib/chat-context.ts assembles the notes from the same modules the site renders: projects, experience, certifications, stack and contact details. There's no second copy, so the assistant and the pages can't disagree about what I've built.

Here's how each project becomes an entry:

const work = projects
  .map((project) => {
    const links = [
      project.demoLink ? `live: ${project.demoLink}` : null,
      project.githubLink ? `source: ${project.githubLink}` : null,
    ]
      .filter(Boolean)
      .join(" · ");

    return [
      `### ${project.title} (${project.category})`,
      project.shortDescription,
      `Stack: ${project.tags.join(", ")}`,
      project.features.length ? `Does: ${project.features.join("; ")}` : null,
      links || null,
      // The helper the project pages are routed by, so a link the model
      // repeats back always resolves
      `Page: /projects/${slugify(project.title, project.id)}`,
    ]
      .filter(Boolean)
      .join("\n");
  })
  .join("\n\n");

The last line is the project's page, built by slugify, the same helper that generates the project routes. The model gets an exact path to quote instead of guessing one, and it can't drift from the route it points at.

Why summaries and not full write-ups#

Groq has no prompt caching, so the notes go out in full with every message and their length is paid for on every question. The comment above buildContext() puts it at roughly 2.9k tokens with summaries, against 5.9k with full descriptions. The feature lists carry the same facts in far fewer words.

The page they're reading#

The widget also sends the path of the page the visitor has open, so "explain this" has something to point at. The server only uses that path as a key. pageContext() matches it against the site's own posts and projects, and any other path adds nothing, so a request can't put its own text into the prompt.

On a post, the model gets the excerpt, every heading and the first paragraph under each. The closing "What I'd change" section goes in whole, because its first paragraph usually describes what exists and a later one says what I'd do instead. Cut it short and the opinion comes out backwards.

It's an outline rather than the whole post because of the token budget. My Groq account allows 8,000 tokens a minute and the notes already take about 2,900 per message, so a full post would push a quick follow-up question over the limit. The notes also list my published posts by title and path, so the model can point to one from anywhere on the site.

The rules it answers by#

The system prompt tells the model who it is (me, answering recruiters, potential clients and other developers on my own site) and sets the rules, most of which exist because it speaks in my name.

Only what the notes say#

The prompt calls the notes "everything you can state as fact". For anything that isn't, it should "just say you do not have that detail to hand". It must never invent a project, employer, date, metric or technology, or inflate what I did on something.

The request is kept cold too, at a temperature of 0.2, because open models wander when asked to stick to source notes.

None of this makes a wrong answer impossible. A prompt is an instruction, and it guarantees nothing. What the rules do is narrow what the model reaches for, and give it an acceptable way out when the answer isn't there.

Two things it never does#

It never commits on my behalf: no rate, start date, deadline, meeting time or scope. For those, it says that part is worth settling directly and gives the email or the Calendly link. A bot answering as me shouldn't be able to quote a client a price.

It also doesn't pretend to be human. Asked whether it's really me, a bot or an AI, it says plainly that it's an AI assistant answering on my behalf from my own notes, then carries on helping. It only says so when asked, because the panel already does. Visitors will assume a person unless told otherwise, so the line under my name in the header reads "AI, answering from my own notes".

When it gives out my email#

The first version of the prompt handed out my email in three places: when a fact was missing, on hiring questions, and when refusing to commit. Each rule made sense alone, but together they read as "close every reply with the address".

Now a single rule decides. Contact details appear when the visitor asks how to reach me, or when the answer really needs a person (a rate, a start date, a scope, a contract). Sign-offs like "feel free to email me" are banned outright. The prompt says they read as a brush-off to someone who only asked a simple question.

One endpoint, with limits#

Everything goes through one route handler, app/api/chat/route.ts, so the system prompt and the API key never leave the server. The settings are at the top:

/**
 * Groq retires and renames model ids, so this is an env var with a default.
 * Check what the account can actually reach with
 *   GET https://api.groq.com/openai/v1/models
 * rather than the docs — access varies per account.
 */
const MODEL = process.env.GROQ_MODEL ?? "openai/gpt-oss-120b";

const MAX_TURNS = 12;
const MAX_CHARS = 1500;

/**
 * Per-IP throttle. In-memory, so it resets whenever the instance is recycled
 * and is not shared between instances — enough to stop a bored visitor
 * burning the quota, not a real quota guard. Move to Redis for real traffic.
 */
const WINDOW_MS = 10 * 60 * 1000;
const MAX_REQUESTS = 20;
const hits = new Map<string, number[]>();

function rateLimited(ip: string): boolean {
  const now = Date.now();
  const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
  recent.push(now);
  hits.set(ip, recent);

  if (hits.size > 500) {
    for (const [key, times] of hits) {
      if (times.every((t) => now - t >= WINDOW_MS)) hits.delete(key);
    }
  }

  return recent.length > MAX_REQUESTS;
}

The default is an open-weight model on Groq. Its id is an environment variable because Groq retires and renames ids, and access varies by account: the one I first wrote this against doesn't exist on my account at all. Hence the comment: check the API, not the docs.

The handler checks three things first. A missing GROQ_API_KEY gets a 503 with "The assistant is not configured yet.", which the panel shows as a reply instead of breaking. More than twenty requests from one IP in ten minutes gets a 429. Then the body: it keeps at most the last twelve turns, drops any reply left at the front of that window so the conversation still opens on a question, and cuts each turn to 1,500 characters, the same limit as the input box. Anything malformed gets a 400.

The rate limiter is deliberately modest, and its comment says so: enough to stop a bored visitor burning the quota, and no more. It sweeps out idle IPs once the map passes 500 entries, so it can't grow without end.

Streaming, and failing halfway through#

The reply streams in as it's written:

const body = new ReadableStream<Uint8Array>({
  async start(controller) {
    try {
      const completion = await groq.chat.completions.create({
        model: MODEL,
        stream: true,
        max_completion_tokens: 700,
        // Open models wander when asked to stick to source notes; keep it cold
        temperature: 0.2,
        messages: [
          { role: "system", content: SYSTEM_PROMPT },
          ...(onPage ? [{ role: "system" as const, content: onPage }] : []),
          ...turns,
        ],
      });

      for await (const chunk of completion) {
        const text = chunk.choices[0]?.delta?.content;
        if (text) controller.enqueue(encoder.encode(text));
      }
    } catch (error) {
      console.error("[chat] stream failed", error);
      const status =
        typeof error === "object" && error !== null && "status" in error
          ? (error as { status?: number }).status
          : undefined;

      controller.enqueue(
        encoder.encode(
          status === 429
            ? "\n\nThe assistant is over its rate limit for the moment. Try again shortly."
            : "\n\nSomething broke on my side. Try again in a moment.",
        ),
      );
    } finally {
      controller.close();
    }
  },
});

The handler returns the Response straight away with this stream as its body, and the model call runs inside start. So the status is already 200 before the model answers, and an error can't become a status code. It goes into the stream as text instead, after a blank line, so it reads as its own paragraph if part of a reply has already arrived.

There are two 429s in this file. My limiter's is a JSON error sent before streaming starts. The one caught here comes from Groq when the account itself is over its limit, and gets its own message.

On the client, the widget appends each chunk to an empty assistant turn it added before sending. TextDecoder runs with { stream: true }, so a character split across two chunks is held back until it's whole instead of rendering as a broken glyph.

Rendering what the model writes#

My first version fought markdown. The prompt said "Plain prose only", and the widget stripped **, __ and headings from every reply anyway, because open models emit markdown emphasis even when told not to.

Now the prompt allows bold for a name worth emphasising and "- " bullets for lists. No headings, tables, numbered lists or emoji. The panel renders exactly that.

Elements, not HTML#

/**
 * Renders an assistant reply as React elements — never as HTML.
 *
 * The text comes from a language model, so `dangerouslySetInnerHTML` is off
 * the table: anything it emits would be live markup. Building elements means
 * a stray `<script>` in a reply is just characters on screen.
 *
 * Deliberately a small subset — paragraphs, bullets, bold, inline code and
 * links — because that is all the model is asked to produce. A full markdown
 * parser would be a dependency earning its keep on four features.
 */

const URL_PATTERN = /(https?:\/\/[^\s<]+|\/(?:projects|blog)\/[a-z0-9-]+)/gi;
const BOLD_PATTERN = /\*\*([^*]+)\*\*|__([^_]+)__/g;
const CODE_PATTERN = /`([^`]+)`/g;

Through dangerouslySetInnerHTML, anything the model emitted would be live markup, so that was never an option. As React elements, a stray <script> is just characters on screen. A full markdown parser would have been a whole dependency for four features.

Internal paths become Next.js Links, and external URLs open in a new tab. Trailing punctuation is split off first, so a sentence's closing full stop doesn't end up in the href.

URL_PATTERN is also why the prompt is strict about paths. After /projects/ or /blog/ it matches only ASCII letters, digits and hyphens, which is all a slug contains. So the prompt tells the model to copy each path exactly as written in the notes, "with plain ASCII hyphens — a typographic hyphen in a URL makes it dead". Here the pattern would stop at that character, and the link would point at a path cut short.

Unclosed markers while streaming#

Streaming adds a problem a finished reply doesn't have:

/**
 * Mid-stream, a bold run arrives as "**Sourc" before its closing pair — which
 * would render its asterisks until the rest lands, flickering on every bold
 * term. Drop the odd marker out while the reply is still arriving.
 *
 * Only ** and ` are balanced: __ appears inside identifiers like __dirname,
 * and stripping those would mangle real text.
 */
function hideUnclosed(text: string): string {
  let out = text;
  for (const marker of ["**", "`"]) {
    const count = out.split(marker).length - 1;
    if (count % 2 === 1) {
      const at = out.lastIndexOf(marker);
      out = out.slice(0, at) + out.slice(at + marker.length);
    }
  }
  return out;
}

Mid-stream, a bold run arrives as "**Sourc" before its closing pair. Rendered as it stands, every bold term would flash its asterisks until the rest landed, so while a reply is still streaming, an unmatched marker is dropped. Once it's complete, the text renders untouched.

Only ** and backticks are balanced like this. __ is left alone because double underscores turn up inside real identifiers, and stripping one would mangle the text.

Keeping the conversation in the browser#

A visitor who reloads or comes back another day finds the conversation where they left it. It's kept in their own localStorage, capped at the last twenty turns, and never sent anywhere but the chat endpoint.

// Read after mount, never during render: the server has no localStorage and
// a differing first paint is a hydration mismatch.
useEffect(() => {
  setTurns(readTurns());
  try {
    setHasOpened(window.localStorage.getItem(SEEN_KEY) === "1");
  } catch {
    setHasOpened(false);
  }
}, []);

// Persist completed exchanges only. Writing on every streamed token would
// hit localStorage dozens of times per reply for no benefit.
useEffect(() => {
  if (isBusy) return;
  try {
    if (turns.length === 0) window.localStorage.removeItem(TURNS_KEY);
    else
      window.localStorage.setItem(
        TURNS_KEY,
        JSON.stringify(turns.slice(-KEEP_TURNS)),
      );
  } catch {
    // Nothing to do — the panel still works for this visit
  }
}, [turns, isBusy]);

The read waits for mount because the server has no localStorage: reading it during render would make the first client paint differ from the server's, which is a hydration mismatch. The write waits for a finished reply instead of hitting storage on every streamed token. And every access sits in a try, because private windows and blocked site data both throw, and the panel should still work for that visit.

A Clear button appears once there's something to clear, and removes the stored conversation too.

The launcher uses the same storage for one more thing. Until the panel has been opened once, a ring pulses out of my avatar. After that it never comes back: a launcher that pulses forever is noise to a returning visitor. hasOpened starts as true, so neither the server render nor the first paint shows the ring, and the effect turns it on unless storage says the panel has been opened. Under prefers-reduced-motion it doesn't animate.

The site scrolls with Lenis, which takes over the wheel for the whole page, so the message log carries data-lenis-prevent. Scrolling inside the panel moves the conversation, not the page behind it.

What I'd change#

The rate limiter lives in a Map, in the memory of whichever instance handles the request. It resets when that instance is recycled, and separate instances keep separate counts, so the real limit is looser than the numbers in the file suggest. The comment already names the fix, a shared store such as Redis, which will be worth it once traffic is real rather than occasional.

The other trade-off was deliberate. Conversations live only in each visitor's browser, and the endpoint doesn't keep them. That's right for privacy, but I never see which questions the notes fail to answer. To make the assistant better, the first thing I'd add is a way, with the visitor's say-so, to learn where it had to say it didn't have the detail to hand.

NextAn order board that never refreshes