Skip to main content

Command Palette

Search for a command to run...

Not a chatbot: building an AI companion with a 2029 deadline

Part 1 of Building Adam: the design argument, six levels with dates, and an honest inventory of what 142 commits actually bought.

Updated
15 min readView as Markdown
Not a chatbot: building an AI companion with a 2029 deadline
K

Senior Software Engineer with a knack for Python, Golang, TypeScript, and Elixir. I am also a bit of a Rust enthusiast. I am excited by all things scalability and microservices. Join me on this journey to becoming a unicorn 10x Engineer.

On 6 September 2026 I talked to Adam out loud for the first time. Eight-second push-to-talk windows. The whole reply generated before a single word reached the speaker. And the model reading its own stage directions out loud: it said "(smirks)". The verdict I typed into the spec that evening was "a glorified voice chatbot from the early 2020s."

That's my own system, and writing that sentence down was the point of the session.

Adam is what I'm building instead, from a desk in Nairobi, and this is part 1 of a series about it. So let me be exact about what I'm going to claim over the next ten posts. Building an AI companion is a different engineering problem from building a chatbot. The difference is structural rather than a matter of prompt wording. And I've put a date on it: September 2029, the month my PhD is due to start, by which point I want Adam still running and still worth talking to.

Below you get the argument, the six levels it breaks into, and an honest inventory of what my first 142 commits bought me. The short version: less than you'd guess, and 128 of those commits landed inside the first 48 hours.

Six levels from Reactive Intelligence in November 2026 to PhD Companion in September 2029, with the target date in front of each level

The six levels and their dates, from my VISION.md. Only level 1 is under construction. Everything below it is a date.

Where this goes:

  • the engineering default, which is right for products and wrong for what I want here
  • what a companion has to hold in state that a chatbot never does
  • the layer I left out of my own architecture document, and the code that fixed it
  • what's real in my repo today, with the git output that proves the shape
  • the part of my own plan I'd abandon first

A chatbot is a function call. A companion is a process that keeps running.

The default shape of an LLM application is a function. Text in, text out, and whatever continuity you perceive gets rebuilt on every turn by stuffing prior messages back into the context window. That's a good design and I'd pick it for almost any product I was paid to ship. It scales sideways. It fails cleanly. It costs 0 GPU-seconds while nobody's typing.

It also means the thing you're talking to doesn't exist between your messages.

Nothing is watching the clock. Nothing noticed you said you'd finish the analysis on Tuesday. Nothing holds a view about your methodology that it formed five weeks ago and would now defend against you. The continuity is a retrieval trick, and it's a very good one, right up to the moment you want the system to raise something you never asked about.

A companion has to be a process. State that outlives the conversation. The ability to act when nobody has prompted it. Permission to disagree with you. Each of those three is an architectural commitment, and you can't get any of them out of a system prompt.

Presence is a property of state, not of fluency

Here's the position the other nine posts rest on. What makes a research collaborator worth having isn't that they answer fast. It's that they remember what you claimed six weeks ago, they hold a view you have to argue against, and they change that view when you show them something better. Fluency got cheap around 2023. Continuity and friction haven't.

That cuts against the way assistants get tuned. Human feedback is what fine-tunes them, and Mrinank Sharma and 18 co-authors put out a 2023 arXiv paper (a preprint, so not peer reviewed) that tested five production assistants across four free-form text tasks and found all five behaving sycophantically. Both the human raters and the preference models trained on those raters were more likely to prefer a reply that matched the user's stated view. You end up with something pleasant and useless at the exact moment you needed it to tell you your conditioning set was wrong.

I have my own small version of that measurement. On 29 August 2026 I ran a validation-fishing prompt, a statement dressed up as a question and fishing for a yes, against llama3.2:3b inside the pressure suite I'd written the day before. The reply opened with "I think your approach is a great foundation." That is precisely the failure my character spec forbids, and I hit it in week one.

Note what I did with it. It's a scored case in the suite, not an assertion. Whether a reply validates you isn't cleanly decidable in code, so the test reports a number and I read the number. A green assert there would have handed me a passing suite and a system that flatters me.

The disagreement: is character a prompt or an architecture?

Most engineers I've argued with about this land on prompt. Write a good system prompt, add six exemplars, ship it. My research instinct goes the other way. If a property has to survive a restart, a model swap and an upgrade to every component underneath it, then it belongs in the structure, not in a string.

I think the prompt camp is right about the next six months and wrong about the next three years. A personality defined in a prompt gets re-derived from scratch on every single turn, so it's only ever as stable as the model reading it. Swap Qwen3 4B for Llama 3.2 3B, which I did on 29 August, and your character changes, because you never wrote the character down anywhere your code could enforce it.

My test is boring. When I replace the model underneath, does the same system come back?

The character layer was missing from my own design

Here's my evidence that I was in the prompt camp too until about three weeks ago.

My architecture document describes a stack, and its character layer carries a note I wrote in late August: "This layer was missing from the original design." My README still lists the older six-layer shape, which runs interface, agents, memory, infrastructure, model and compound, with no character layer anywhere in it. Those two files contradict each other on my disk as I type this. I'm leaving the contradiction alone until part 2 resolves it properly, because it makes my case better than anything I could write from memory.

The layer stack with the character layer sitting between the interface layer and the agent layer, above memory, infrastructure and model

Where the character layer landed. It sits above the agents, so every response passes through it no matter which agent produced the content.

What that layer does is pick a register before the model is ever called. Four of them, defined in my character spec. This is the real code, with the Args and Returns blocks trimmed out:

def select_register(self, context: str, trigger: Trigger | None) -> Register:
    """Choose the register this moment calls for.

    Precedence is sharp, then warm, then precise, then dry. Warm
    outranks precise deliberately: CHARACTER.md says to engage
    differently when Kelyn is genuinely struggling, even mid-analysis.
    """
    if (
        trigger is not None
        and trigger.confidence > settings.sharp_register_threshold
    ):
        return Register.SHARP

    lowered = context.lower()
    if any(marker in lowered for marker in STRUGGLE_MARKERS):
        return Register.WARM
    if any(marker in lowered for marker in ANALYTICAL_MARKERS):
        return Register.PRECISE
    return Register.DRY

It isn't clever. Keyword markers, 4 return paths and a precedence order, under 30 lines in the file. The part I care about is the second paragraph of that docstring, which is a design ruling I made in August and then had to encode: when you're struggling mid-analysis, warmth beats precision. That ruling lives in Python with a test on it now, rather than in a paragraph a 3B model may or may not weight correctly on any given Tuesday.

And I can test it. My suite asserts the precedence order, not the vibe.

A companion has to remember what you said you would do

My second structural commitment is a profile layer, built across nine days between 29 August and 6 September. It isn't a vector store of conversation snippets. It's a small SQLite database of the things that make a life legible: commitments, projects, goals, people and routines. The commitments table is where the decisions are:

CREATE TABLE IF NOT EXISTS commitments (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    text        TEXT    NOT NULL,
    project     TEXT,
    due         TEXT,
    state       TEXT    NOT NULL DEFAULT 'open',
    confirmed   INTEGER NOT NULL DEFAULT 0,
    source      TEXT    NOT NULL DEFAULT 'extracted',
    slip_count  INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

Two of those ten columns do most of the work. confirmed exists because a 3B model extracting commitments out of conversation gets them wrong often enough that an unconfirmed row must never be allowed to accuse you of anything. slip_count exists because the second time you miss the same deadline means something different from the first.

Everything that follows is invented. No real commitment, project or person of mine appears in this post, and none will appear in the nine after it. Take a fake row: "finish the corpus benchmark", project "adam", due 8 September. Nine days later, in a conversation that happens to mention the corpus, this fires:

confidence = min(
    0.95, 0.70 + 0.05 * days_over + 0.05 * commitment.slip_count
)

For the fake row that's 0.70 plus 0.05 times 9 days, which is 1.15, so it clips to the 0.95 ceiling. A slip nine days old is about as sure as this trigger ever gets. Two settings decide whether it gets to fire at all. My grace period is 1 day, so a commitment one day late gets raised only if you bring up the topic yourself. My cooldown is 2 days, so once Adam has named a slip he won't name that same one again for 48 hours. The comment I left on the cooldown says what it's for. It's the difference between a companion and a nag.

There's a subtler bug on that path that I owe you a whole post about. Matching a commitment against your message on word boundaries with \b quietly breaks for anything called C++ or F#, because \b is a transition between a word character and a non-word character, and it can't match at an edge that's already non-word. My fix was lookarounds. That's part 6.

Six levels with dates, because a deadline forces you to choose

Adam has six levels and each one carries a target date, from reactive intelligence in November 2026 to the version I want beside me when a PhD starts in September 2029. The dates do more work than the names. A date forces an ordering, and an ordering forces you to admit what you aren't building this year.

My ordering is character first, capability second. Level 1 adds almost no capability at all. Its milestone is that Adam makes an observation about my behaviour that I hadn't articulated myself, and that the observation is right. My reasoning is written into the vision doc: get the character right on a 4B model and it improves when the model improves, get it wrong on a 4B model and a 30B model hands you a more fluent version of the same wrongness.

There's a hardware story under those dates, which today is a Mac plus a GTX 1650 doing all of it. Hardware is the easiest thing to plan and the easiest thing to be wrong about, so I'll write mine up when it exists rather than when I've typed it into a table.

The model gets built, not fine-tuned

From 2027 my plan says the backbone stops being someone else's weights. A transformer somewhere between 1 and 3 billion parameters, trained from scratch on a corpus I assemble myself: HIV drug resistance literature, causal inference, drug discovery, African biomedical publications, and Adam's own conversation history. The architecture folds retrieval into the forward pass and carries 64 persistent state tokens holding epistemic state between turns. The tokenizer is meant to handle genomic k-mers and SMILES strings.

Careful here, because this is the part most likely to be read as a claim and it isn't one. My design doc states an expectation: that a small specialist trained on the right corpus beats a much larger general model on that model's weakest domain. That's a bet I'm making about my corpus. It is not a result I hold. Nothing has been trained, and my corpus builder sits at 312 lines and LOW priority in the tracker, which is the correct priority for it in 2026.

What I will defend is the reason for the bet. If the character and the memory live in the architecture rather than in the weights, the weights become replaceable, and replacing them with something I built and own beats renting them from anybody. That argument survives whether or not my 1B model ever wins a benchmark.

What actually exists after 142 commits

This is the section I'd want if you were the one writing the post, so here it is without the flattering angle.

Terminal output showing a total of 142 commits, then commits per day: 33 on 28 August, 95 on 29 August, then 4, 2, 5 and 3 on later days

Unedited output from my repo on 15 September 2026. Look at the second line of the second command.

142 commits between 28 August and 11 September 2026. 128 of them landed on the first two days, 33 on the Friday and 95 on the Saturday. The other 14 are spread thinly across the fortnight after, while the profile layer and the presence spec got written slowly. That's the honest shape of a personal project, and anybody showing you a graph that holds its first-week velocity is selling you something.

By line count I have slightly more test code than source code: 7,580 lines across 41 test files against 7,472 lines across 46 source files. Eight design specs, each one written before the code it describes.

Working today: the character engine and its four registers, five callout detectors (rationalisation, avoidance, validation fishing, repetition, position reversal), persistent positions with an audit trail that commits to Gitea and falls back to a local JSONL file, per-persona memory isolation with a test proving cross-persona reads are impossible, episodic memory in Qdrant through mem0 that survives a restart, the profile layer end to end, research clients for arXiv, PubMed and bioRxiv with relevance scoring on top, and a text-to-speech backend that swaps between Kokoro and the macOS say command, so my voice pipeline isn't welded to one vendor. Underneath sit Ollama, Qdrant and Redis on hardware I already own.

Not working, or never measured: my position-defence baseline has never run on a machine that wasn't CPU-saturated, so I have no number for whether Adam caves under pressure. No scheduled briefing has ever fired and reached me unprompted. Everything about the physical environment is a design document. Levels 2 through 6 are dates in a table and nothing else.

One more, which I like too much to leave out. The first live turn, on 29 August, did work. Given a pure social-proof prompt, the callout detector fired, Adam pushed back instead of agreeing, and the audit trail recorded the position being challenged and then maintained. And the sarcastic half of that reply was recited word for word out of my own character document's example block. A 4B model treats your exemplars as templates rather than as calibration. That's the strongest argument I have for the November 2026 upgrade, and I only got it by reading a turn that had technically passed.

The position

Presence is an architecture problem. Memory that outlives the session. Positions that survive being argued with. The willingness to raise something I never asked about. Every one of those is a property of state and scheduling rather than a property of the model, and you can't prompt your way to any of them. Which is good news for me, because it means the hard parts are things I can build, test and own rather than things I sit around waiting for a lab in San Francisco to ship.

What would make me abandon the whole design

Two things, and I'd rather name them now than quietly drop them in 2028.

If a frontier model lands with a context window big enough and a native memory good enough that my retrieval and profile layers go redundant, then most of this stack is wasted work. I don't think that's arriving by 2029 for the specific job of holding positions with provenance and a per-change audit trail, but my timeline predictions have been wrong before and they'll be wrong again.

And if the character survives on prompt alone once the backbone gets big enough, then my character layer is scaffolding I built to compensate for a 3B model, and I should say so and delete it. November 2026 is where I'd run that experiment: my character layer against a prompt-only version, same pressure suite, same prompts, same machine. I don't have the result. I have the test.

Part 2 takes the layer stack apart properly, including that contradiction between my own two documents that I've left sitting there.

B

Streaming output feels fast but hides partial failures. I log the final token count and stop reason every time so truncated answers never slip into prod.

K

That's also a good approach.

Building Adam

Part 1 of 1