Holdfast
Solo, product / build / deploy · 2026 · Status: live, pre-launch, one user · useholdfast.co
Built with TypeScript · Telegram · gpt-4o-mini · database
Talk for twenty seconds after you see someone, get reminded of the one thing worth saying, on the day it matters.
01 / Problem: nobody decides to lose a friend
There's no fight and no last conversation. There's a gap that quietly gets too wide to text across, and by the time you notice it, reaching out feels like an event rather than a message. The failure isn't that people stop caring, it's that the details fall out of your head between one meeting and the next.
02 / Approach: nothing to maintain, nothing to open
Every product built at this problem so far has been a contact manager with softer language. They fail for the same reason: keeping a list of your friends up to date is work, and nobody does unpaid data entry about the people they love. So the design constraint came first: there must be nothing to maintain, and nothing to open.
03 / Mechanism: twenty seconds in, one message out
No app. Capture is a voice note to a Telegram bot, roughly twenty seconds, said the way you'd tell a friend what happened. Transcribe, then delete the audio, text is stored, voice never is. Extract who you saw, what's happening in their life, and what you said you'd do (that last distinction is the product). Dates are resolved by a pure function, not the model. Commitments must quote the transcript verbatim or they're dropped. Persist person, facts, and commitments as linked records in one atomic transaction.
Rank once a day: overdue commitments first, then facts dated yesterday, then people drifting past their usual cadence. Send at most one, hard cap of three messages per person per rolling seven days, over-nudging is the failure mode that kills this category. Listen for the answer: "done" closes it, "not yet" brings it back in three days, "no" drops it without guilt.
Measured over a dozen captures, that pipeline runs in about 2.6 s at the median and 4.2 s at worst from voice note to scheduled reminder (the final database write not counted): transcription ~1.1 s, extraction ~1.5 s, and the in-code date resolver ~1 µs, which is the entire reason the arithmetic lives in TypeScript and not the prompt. The transcription figure is a synthesized clip rather than a real voice note, so it reflects upload and model time on clean audio, not a phone recording in a noisy room. Each capture is roughly 1,200 tokens through gpt-4o-mini, about 1,120 of them the fixed system prompt, so extraction costs on the order of $0.0002 and the whole capture stays under a tenth of a cent at list price.
04 / Trace: one capture, all the way through
A real record from the production database.
"Coffee with Marco this afternoon. His mom's out of the hospital and doing okay. He's got a final interview on the 28th and he's nervous about it. I told him I'd send him the recruiter's number this week."
Person: Marco · fact: mom out of hospital, recovering (no date) · fact: final interview, nervous, 2026-08-28 · commitment: send over the recruiter's number, due 2026-08-24 · evidence: "I told him I'd send him the recruiter's number."
"Marco: you said you'd send over the recruiter's number. Still worth doing?"
Note what's not in the record: Marco said nothing about what he'd do, so nothing was stored as a commitment for him. The interview date resolved without the model doing arithmetic. The reminder names Marco because the message template knows the person even though the stored text doesn't.
05 / Hard part: making a model trustworthy enough to hold someone's promises
A wrong reminder is worse than no reminder. The extraction prompt was the highest-stakes string in the codebase, so the fix started with measurement: twelve real transcripts with exact expected output, run three times each against every prompt version, fixed reference date so results were comparable. Three failures survived every rewrite, each one the model being asked to do something it's bad at.
| Failure | Tried | Actually fixed by |
|---|---|---|
| "Thursday" resolved to a Monday | Stating today's date + a lookup table in the prompt | A pure date resolver in TypeScript |
| Commitments nobody made | An explicit rule, then a stronger rule with examples | Checking a verbatim quote against the transcript |
| Person's name dropped from the reminder | A rule requiring substitution | The message template, which already knew it |
The date lookup table is worth dwelling on: giving the model a table of all seven weekdays worked because seven is a closed set, every possible answer was there to copy. Extending the same idea to ordinals failed badly. With two worked examples and no entry for "the 14th," the model copied the nearest example date wholesale and returned the 28th. Demonstration is not lookup. Moving the arithmetic into code fixed it permanently and had a second effect: date behavior became testable in milliseconds with no API call, freeing the twelve slow, non-deterministic cases to test only what the model is genuinely good at, reading what a person said. The resolver has 8 rules (weekday, weekday-plus-time, ordinal, "this weekend", "next week", today/tomorrow, an explicit date, and refuse) and the 23 tests cover them across 14 groups, including the boundaries that bite: an ordinal that lands on the capture day, a weekday that is today rolling a full week out, an ordinal crossing the December year line, and four vague phrasings that must return null rather than guess.
The bug that hid for eleven captures
The database showed eleven captures. One had a linked person and facts, the other ten had stored nothing, commitments table completely empty. The bot had replied convincingly to every one. One line was the cause: profile creation was gated on whether the person was new.
if (existingPersonId === null) {
chunks.push(
db.tx.profiles[profileId].create({ ... }), // already exists
db.tx.people[personId].create({ ... }),
);
}
The first time a returning user mentioned a new person, that re-created a profile that already existed. The chat id column is unique, the database rejected the step, and because it was one atomic transaction, the person, facts, and commitments went down with it. Two changes came out of it: the reply now reports what was saved rather than what was extracted, and a rule went into the project notes, a function named findOrCreate must actually create, this one only found.
06 / Result: it works, and I stopped using it after two days
That last row is the finding, worth more than the passing tests. Working alone, in a normal week there are perhaps two moments seeing someone worth recording, Holdfast needs someone who has five or six. The reminders that did arrive felt hollow, several were about example transcripts written during development rather than real promises to real people. Neither of those is a reason to stop, both are reasons to stop testing it on myself.
What happens next: five testers chosen for one property only, they see a lot of people, two weeks. The measure is their capture counts in the database, not their opinions in a chat. Nothing new gets built until those numbers exist.
I built a product I am not the user of, and then used myself to test it.