top of page

Seven Months of Vibe Coding: How I Built a Privacy-First Journal App with an AI Pair Programmer

  • kennethplacroix
  • 9 hours ago
  • 18 min read

TL;DR — For Non-Technical Readers

This post is about building a journaling app from scratch without being a professional developer, using AI as a coding partner. Here are the concepts that come up, explained plainly.

Vibe coding — Instead of writing code yourself, you describe what you want in plain language to an AI tool, and it writes the code. You guide it, ask questions, push back when something is wrong, and make decisions about what the app should do. The AI handles the technical execution. Think of it like being an architect who describes a building to a contractor — you don't swing the hammer, but the decisions are yours.

Coding on my laptop
Coding on my laptop

Local-first / privacy-first — Most apps store your data on their servers, in the cloud, where the company can technically access it. A local-first app keeps everything on your own device — your computer, your hard drive. Nobody else's server is involved. For a journal, that means your private thoughts stay private in a very literal way.

Encryption — Think of it like a lock that only your password can open. When an entry is saved, it gets scrambled into unreadable noise. Without the right password, it looks like garbage to anyone who gets hold of the file — including me, the developer. The app doesn't store the password or the key. That's the "zero-knowledge" part: the system is designed so that even I couldn't read your entries if I wanted to.

AI as a pair programmer vs. AI as a chatbot — A chatbot answers questions. A pair programmer works alongside you on an actual project, has context about what you've already built, and helps you think through problems in real time. The difference in practice is enormous. One gives you answers to questions you've already formed. The other helps you figure out what to ask.

The deluge — When you build something complex with AI assistance, information arrives fast. You encounter new technical concepts constantly — not on a schedule, but in the moment when you need them. The skill isn't absorbing everything. It's knowing which things matter right now and asking enough questions to build just enough understanding to keep moving.

Burning it down — A real part of building with AI tools that doesn't get talked about: sometimes a project, or a feature, or an approach doesn't work and you scrap it entirely and start over. The AI will enthusiastically help you build something that turns out to be wrong. The judgment about whether something should exist is still entirely human.

Open source / FOSS — The code for this app is publicly available for anyone to read, use, or modify. There is no paid version, no subscription, no hidden business model. Free and open source software (FOSS) means the app is free in both senses: free to use, and free to inspect.

If any of those concepts come up in the post and you want more detail, that's what the rest of the article is for.

The Itch

I'm not a software developer. I'm an IT person with less than a decade of experience — close enough to the code to understand how systems work, far enough from it that writing Rust was never part of the plan.

I started building MoodHaven Journal for myself. Not as a statement about the industry, not out of distrust of other apps — I genuinely don't know how most of them handle your data, and that wasn't the point. The point was that I wanted a journaling tool built around my own well-being, something I would actually use and actually trust, where the data sat on my machine and was unreadable to anyone without my password. A personal project, in the most literal sense. The kind of thing you build because it matters to you, not because there's a gap in the market.

This wasn't my first attempt. I'd started something similar before — working with ChatGPT and text files, prompt after prompt, pasting code across sessions. It got somewhere. Then I burned it down. Not because it failed dramatically, but because I'd learned enough from the first go to know it needed to be rebuilt differently. New tools, clearer thinking, a better sense of what I was actually trying to make. That cycle — start, learn, scrap, rebuild — is a real part of vibe coding that doesn't show up in the success stories. When Claude Code became available, I came back to the idea. I opened a session and described what I wanted. By the end of the day, the skeleton existed.

What I also wasn't prepared for is the deluge. Vibe coding an app this complex is not like having a calm conversation about software. It's being handed a firehose. New concepts arrive faster than you can process them — Mutex, IPC, CTAP2, mDNS, Ed25519, WAL mode — not in order, not on a schedule, but mid-feature, when you need just enough to make the next decision. The skill I had to develop wasn't understanding everything. It was knowing what was important to understand right now, and asking why, not just what.

The Prototyping Phase: October–December 2025

The first three months happened before I set up a proper git repository. Call it the prototyping phase — I was building fast, mostly to answer one question: is this actually possible?

The answer, it turned out, was yes. And then some.

By December 2025, without a formal commit history, I had built: mood tracking on a five-level scale, AES-256-GCM encrypted journaling with PBKDF2 key derivation (600,000 iterations — not arbitrary; it's the NIST-recommended floor for password-based encryption), TOTP two-factor authentication, native FIDO2 hardware key support (YubiKey, implemented in Rust using native CTAP2/HID — not browser WebAuthn, because Tauri's WebView doesn't expose it), optional WebDAV cloud sync (fully encrypted before upload), an Oura Ring health integration, journal templates, an analytics dashboard, and an AI insights layer.

All opt-in. All privacy-first, from the very first iteration.

What surprised me wasn't the pace — it was how much of the product thinking fell into place under pressure of actually building it. When Claude asks "how do you want this to work?" you need an answer. You can't hand-wave it. The process of forming a clear enough description to get the right output turned out to be exactly the process of deciding what you actually want.

The core architecture decisions made in those months held through everything that followed: Tauri as the desktop shell (Rust backend, small binary, native OS access), React and TypeScript for the frontend, SQLite for local storage, the WebCrypto API for all encryption. None of those needed revisiting.

What those months produced more than code was a clear mental model. By December, I knew what this app needed to be.

The Commitment: January 2026

On January 17, 2026, I created a clean git repository and made the initial commit. That's the line between prototyping and building.

The CHANGELOG I wrote carries the full history including the months before the repo, because the features were real even if the commit trail wasn't. The initial commit was large — months of accumulated work formalized in one snapshot. Then January 18 was a sprint: a P0 baseline, then calendar view and analytics dashboard, then the settings panel and AI configuration. All in one day.

Then the bugs began.

The first real tracked issue was what I called the journal save freeze. The app would hang on save with no error message. I described the symptom to Claude: every time I try to save an entry, the UI locks up. Claude diagnosed it in one pass: create_entry was calling get_entry internally while already holding the database mutex. SQLite's rusqlite wrapper uses a std::sync::Mutex that is not reentrant — the second lock acquisition blocked forever.

I didn't know what a Mutex was before this project. I didn't know what "reentrant" meant. I learned it because I needed to understand why the fix worked, not just that it did. That's the pair programmer dynamic at its best: you don't just get the fix, you get the explanation, and if the explanation doesn't land you ask again. "Why does it deadlock instead of returning an error?" is a real question I asked. The answer built a mental model I still use.

The thing that made Claude Code different from the ChatGPT-and-text-files approach wasn't just code quality. It was the ability to ask why in context — with the actual code visible, mid-session, when the question mattered. ChatGPT answered the questions I brought. Claude Code answered questions I didn't know to ask yet.

January 29, 2026 saw a dedicated commit for a comprehensive test suite — Vitest and Testing Library. I don't write tests. But I now understand that a test suite is what lets you move fast six months later without losing track of what you built. By v1.0.0, there were 693 of them.

Security as a Design Constraint

Before I describe any feature, the security model needs framing, because it shaped every decision.

Zero-knowledge architecture: the backend never sees plaintext. All encryption and decryption happen in the frontend, using the browser's WebCrypto API. The Rust backend stores and retrieves opaque encrypted blobs — ciphertext for journal entries, ciphertext for signals, ciphertext for exports. Mood level (1–5) and timestamps are stored unencrypted because the analytics features require them, and they don't reveal content.

The key is derived from the user's password using PBKDF2-HMAC-SHA-256 with 600,000 iterations and a per-entry random salt. The key is never stored. It lives in JavaScript memory while the app is unlocked. Lock the app, and it's gone.

Two recovery paths only: password (plus optional 2FA), or Erase and Start Fresh. No master key, no admin backdoor, no "forgot password" email. This means I can't help a user who forgets their password. I had to explain to Claude why that was the right choice and not a limitation — because the moment you add a recovery mechanism, you add a trust relationship with whoever controls that mechanism.

TOTP two-factor authentication shipped from day one. Native FIDO2/YubiKey support followed via a Rust implementation using native CTAP2/HID (January 20, 2026). Why not browser WebAuthn? Because Tauri's WebView doesn't expose the WebAuthn API — it's a desktop app running in a native shell, not a browser page. I learned what a relying party ID is because I needed to route around a platform constraint.

The hardware key was made an optional Cargo feature flag. Linux requires libudev1 at runtime for USB HID access, and not every system has it. The app needed to install cleanly without it. I learned what a Cargo feature flag is because I needed to ship on multiple Linux configurations without making hardware key support a hard dependency.

The recovery key: a 24-character code, shown once, never again. It encrypts a copy of the user's password — the only recovery path outside of remembering the password. "If you lose it, it's gone. That's the deal." Writing that UI made the stakes concrete in a way that reading about zero-knowledge encryption never had.

The Editor and the Polish: January–February 2026

After the security architecture was solid, I focused on what users would actually touch: the writing experience.

The TipTap rich text editor arrived January 19 — floating toolbar, slash commands, blockquotes, task lists, emoji picker, link dialog with keyboard shortcut. TipTap sits on top of ProseMirror, which is the same engine that powers Notion's editor. The difference between a textarea and a real editor is the difference between a notepad and a journal.

February was UI polish. Collapsible sidebar, page transitions (300ms, smooth), staggered entry animations (30ms per card, capped at the first ten), save micro-animations (scale pulse, not an opacity flash), focus mode with typewriter scrolling. The word count bar evolved: word count, reading time estimate, daily-rotating greeting seeded by day-of-year so it stays stable all day and rotates the next morning.

This is where vibe coding has a clear advantage over trying to write the code yourself. Describing a feeling and watching it become CSS is a genuinely different process. "The save confirmation should feel like relief, not like a checkbox" is a real prompt. "The entries in the timeline should cascade in, not appear all at once" is a real prompt. Claude translates those into animation timings, keyframes, and prefers-reduced-motion media queries. The vocabulary for what you want doesn't have to be technical — it has to be clear.

The post-setup tutorial wizard landed in this period too. I had never thought of "first use" as a product problem distinct from "installation" before. The realization: the handoff from installed to using is a design decision, and it matters as much as any feature.

Going Multimodal: March 2026

March was the month the app became something I hadn't imagined when I started.

Speech-to-text arrived March 3: whisper.cpp running as a Tauri sidecar — a compiled C++ binary bundled with the app, spawned as a child process when a recording needs transcribing. Audio is captured via the Web Audio API, encoded as 16kHz mono WAV, written to a temp file, handed to the whisper-cli binary, and the temp file is deleted immediately after. No cloud API call. No audio leaving the machine.

The first time I dictated a journal entry and watched it appear in the editor, transcribed locally, with no network request, that felt like something. It is a different order of feeling than a feature working correctly. It's the feeling of a constraint — offline, private — producing capability rather than preventing it.

The three-layer formatting pipeline shipped three weeks later in v0.7.1 (March 21). Raw whisper output is accurate but not journal-quality prose. Layer 1 (always on): removes filler words, collapses false starts and repetitions, adds paragraph breaks using whisper's per-segment timestamp data. Layer 2 (optional, local): routes through Ollama if installed — a local language model that improves the prose without touching any external server. Layer 3 (optional, explicit consent required): OpenAI BYOK, for users who want cloud-quality polish and have consciously chosen it. Each layer requires progressively more explicit user consent. I designed this model; Claude implemented it.

Oura Ring integration (March 3): the app pulls health context — sleep score, HRV, readiness score — and surfaces it as a badge next to journal entries. The rule: qualitative descriptors only in any AI prompt. Raw biometric values never leave the device.

Mood auto-detection (March 3–4): after five words, the app begins scoring sentiment in real-time. Sentiment scoring, emoji detection, negation handling, end-weighting (later sentences carry more weight than earlier ones). Four dots pulse while scanning, then pop into the detected mood. A lock icon replaces the indicator when you've set mood manually. "I wanted the app to know what I was feeling before I consciously admitted it." That's an unusual product requirement to write down. Claude didn't question it.

Time capsules (v0.7.5, March 26): seal any entry until a future date. Two types — Letter (a message to future you) and Vault. On the next app unlock after the reveal date, a modal surfaces the decrypted content with a mood-delta chip: your average mood since the entry was written, compared to today's most recent mood. Anniversary auto-reveal surfaces entries older than 365 days.

The Watch: March 2026

The most technically ambitious thing I attempted was a Wear OS companion app.

The use case: your watch is always with you. Your desktop is not. A passing thought at 2pm, a feeling during a walk — the watch should capture it before it disappears, and have it waiting when you sit down to write.

What the watch does: records voice memos up to 10 minutes (16kHz mono AAC-LC), lets you tap a quick mood signal, and transfers the audio to the paired Android phone over Bluetooth using the Wear OS ChannelAPI. The phone receives the file, writes it to a voice_memos_incoming/ directory, broadcasts an intent, and the desktop app picks it up. From there, whisper.cpp transcribes it, and it waits in a panel in the writing view for you to review and incorporate.

Four bytes prefix every audio transfer: a two-byte magic header and a two-byte duration. I learned what a wire protocol is because I had to describe one in enough detail to get it right.

This section of the project was where the deluge was most intense. AVDs, ADB serial numbers, GPU modes, Gradle build files, Kotlin compile errors, ChannelAPI vs MessageAPI — none of it was familiar, and all of it arrived at once. What I found was that you don't need to understand all of it. You need to understand enough to describe the problem correctly.


"The emulator detects the device serial as an empty string when the display output goes to stdout" is a different prompt than "the emulator doesn't work." The specificity comes from reading error output carefully and asking Claude to help you interpret it — question by question, building just enough mental model to move forward.

The dev.sh launch script that emerged orchestrated parallel emulator startup (watch and phone), snapshot persistence for faster boots, automatic watch app compilation and installation, dynamic ADB serial detection. By the end of it, I had opinions about emulator GPU modes. I would not have predicted that in February.

What the watch is, philosophically: a capture device. The desktop is where meaning is made. The watch catches the thought before it disappears. That division of responsibility ended up feeling exactly right.

Peer Sync: The Distributed Systems Rabbit Hole

I use multiple machines. A laptop and a desktop, at minimum. Any sync solution that routes through a cloud server contradicts the zero-knowledge model — a server that holds your encrypted data has a relationship with your encrypted data. So the only architecturally honest answer was direct device-to-device sync.

v0.6.0 (March 13) established the foundation: each device generates a permanent Ed25519 keypair on first launch. The private key stays in peer_key.bin, never leaves the device. The public key and a derived 16-character device ID go into device.json. mDNS/DNS-SD broadcasts the device's presence on the local network — moodhaven.tcp.local — so nearby devices can find each other without a discovery server.

I had to understand what asymmetric cryptography is for before I could design the trust model. What's the point of Ed25519 keys? Each device has an identity that can be verified mathematically without sharing a secret. A device claiming to be Device A that doesn't hold Device A's private key cannot produce valid signatures. It cannot be impersonated. Understanding that — well enough to reason about it, not well enough to implement it from scratch — took about four "why" questions in a row. That's the right pace.


v0.6.1 added pairing: a 6-digit PIN generated on Device A, displayed as a QR code, entered manually on Device B. The PIN is communicated out of band — you see it on screen and type it into the other device. A passive observer on the network who sees the mDNS broadcast cannot derive the PIN. I understood this better after having to explain to Claude why out-of-band PIN verification is the right answer, rather than something automated. Explaining your reasoning to an AI is surprisingly effective at exposing the gaps in it.


v0.7.0 (March 18) completed the sync engine: TCP connections, encrypted wire frames ([4-byte length][12-byte nonce][AES-256-GCM ciphertext]), transport key derived independently by both sides from both public keys without transmitting it, last-write-wins conflict resolution on concurrent edits, manifest diffing to send only what the other side is missing.

The sync port is deterministic: 44000 + (first 4 hex characters of the device ID, interpreted as a number) % 1000. Each device gets a stable port in the range 44000–44999 with no coordination required.

The moment it worked — two machines on the same WiFi, journal entries syncing between them, encrypted end-to-end, no server involved — that is the kind of feeling that makes you understand why people build things.

The Rebrand, the Audit, the Browser: March–April 2026

MoodBloom became MoodHaven Journal in v0.7.7 (March 26). The name was a carryover from the earlier version of this project — I'd just kept it when I started fresh, without thinking too hard about it. Once this version was real and settled into what it actually was, keeping the old name felt wrong. I renamed it for consistency with what the app had become: not a leftover, but a finished thing with its own identity.

The rename was comprehensive. App identifier, database filename, mDNS service type, transport key prefix, export format version strings, FIDO2 relying party ID, npm package name, Rust crate name. For the sync engine, this mattered: two devices that share a transport key derived from a prefix string must agree on the prefix. Old and new versions are no longer sync-compatible after a rename, by design.

Security hardening was not a single audit — it was a multi-month process that intensified in April. Key moments: a timing oracle in the PIN comparison fixed with constant-time XOR (v0.8.2) — the original comparison would exit early on a mismatch, potentially exposing information to a careful attacker on the same LAN; path traversal in media commands blocked via canonicalization and directory validation (v0.8.2); session lock gates added to seventeen commands so calling them before authenticating returns an error instead of quietly succeeding (v0.8.2–v0.8.3); GitHub Actions CI supply-chain hardened with SHA-pinned action references (v0.8.4); Vite upgraded from version 5 to version 8 to resolve a dev-server CORS vulnerability (v0.8.4).

The most significant security fix came in v0.9.0 (April 9), closing an item deferred since early planning. Password verification had been running in the WebView — the user's plaintext password was hashed in JavaScript and compared to the stored value. The fix: a native verify_password Rust command that receives the plaintext password, runs PBKDF2 in native code, and returns a boolean. The hash never leaves the backend. Same algorithm, same salt format, same 600,000 iterations — just moved across the IPC boundary for a stronger security guarantee.

"I shipped something real and then spent weeks hardening it properly. That's the honest sequence."

The browser port landed in v0.8.0 (April 4): the same application, running in any modern browser, using IndexedDB instead of SQLite. Same zero-knowledge model — the password never leaves the browser tab, encryption and decryption happen in WebCrypto, the server only ever sees ciphertext. PWA-installable. WebDAV sync in browser mode uses ETag-based collision protection to prevent a concurrent desktop and browser write from silently overwriting each other.

I had never thought about the difference between IndexedDB and SQLite as a product decision before. One is a browser API, one is a file. The constraints are different — IDB has different transaction semantics, no WAL mode, no pragma system. Mapping the Tauri command layer to an IndexedDB backend required understanding what each command actually did, not just what it was named.

v1.0.0 — What "Done" Means

The v0.9.x sprints ran through April 2026, all four compressed into ten days.

v0.9.2 (April 11): the STT live recording strip — a waveform indicator below the editor toolbar while dictating, with a MM:SS elapsed timer, stop and cancel controls. Timeline virtual scroll using position: absolute and ResizeObserver for measured heights — variable-height rows, no third-party library. TagCloud component. Per-device last-sync timestamps in the Devices settings tab.

v0.9.3 (April 12): a 7-day mood sparkline in the sidebar, keyboard shortcuts (1–5 to set mood, ? for a cheatsheet overlay), streak milestone toasts at 7, 30, and 100 days, an On This Day banner, a privacy transparency panel showing real-time state — what's enabled, what's leaving the device, what's running locally.

v0.9.4 (April 13): DESIGN.md — a design system document covering color tokens, typography scale, spacing system, and motion guidelines. Written as a single source of truth, not documentation after the fact. The website overhaul landed here too: seven blog posts, Open Graph cards generated per post, founder bio, Android sideload guide, JSON-LD schemas for search. Another product described and built.

v1.0.0 was tagged May 19, 2026. Final numbers: 693 tests across 47 test files, approximately 127 Tauri commands across 21 command modules, builds passing on Ubuntu, macOS (Intel and Apple Silicon), and Windows.


MIT-licensed. No Pro tier. No subscription. No analytics, no telemetry. BYOK for OpenAI if you want AI features; Ollama works completely offline. FOSS was never a business decision. It was the only honest conclusion of the privacy-first premise — a paid tier for a privacy-first application where the business would need to see usage data to know what to charge for is internally contradictory.

v1.2.0 is already planned: StillHaven, a somatic companion module. There's more to build.

"Done doesn't mean finished. It means trustworthy enough to hand to someone else."

What I Learned

There's a distinction between AI as an oracle and AI as a pair programmer. An oracle gives you answers to questions you've already formed. A pair programmer helps you figure out what to ask.

Getting to v1.0 is a milestone not just for the app but for how I build. I started this idea with ChatGPT and text files — prompt after prompt, losing context between sessions, producing prototypes that couldn't sustain their own weight. I burned that version down. Started again. Burned that down too. What Claude Code added wasn't just better code; it was continuity, and the ability to have a real back-and-forth mid-feature, with the actual state of the project visible to both of us.

But I want to be honest about something the AI hype cycle doesn't say clearly: these tools are built to be helpful and encouraging. They will validate your idea. They will help you build it. That is not the same as the idea being good, or the feature being worth shipping. I spent real time on things that got scrapped. I've started projects that got burned to the ground. An AI pair programmer will follow you enthusiastically into a bad idea just as readily as a good one — it's not a filter for whether something should exist, only for whether it can be built. That judgment is still entirely yours.

The most important thing I learned — and this took months — is that you cannot absorb all of it. The continuous flood of new information is real, and trying to understand everything as it arrives is the wrong goal. The actual skill is knowing what's important to understand right now, and asking why until you have enough of a mental model to make the next decision. Not a complete model. Enough of one.

Every technology I encountered — Rust's ownership model, Tauri's IPC layer, mDNS/DNS-SD, Ed25519 asymmetric keys, CTAP2/HID, whisper.cpp's timestamp output, IndexedDB transaction semantics, SQLite's WAL mode — I understand now at a level I can reason about. Not because I read about them. Because I had to form a position on each one in order to ask for what I wanted. You can't vibe-code a sync engine if you don't have an opinion about what sync should guarantee. You have to earn the opinion first.


Privacy-first as a design constraint was generative, not restrictive. It ruled out entire categories of complexity: no cloud login flow to build and maintain, no analytics infrastructure, no support tickets for forgotten passwords, no terms of service negotiating what happens with your data. The constraints that feel like limitations often produce the most coherent designs.

The tooling mattered in ways I didn't expect. Conventional commits aren't aesthetics — they're legible history. The CHANGELOG isn't a formality — it's how I know what this thing is. The CI pipeline tells me immediately when I broke something. These were new mental models, not just new tools.

Partway through the project I started using a set of structured workflow skills built by Gary Tan — called gstack — that plug into Claude Code and guide it through specific processes: engineering review, design review, QA testing, planning, shipping. The difference in practice is significant. Instead of asking Claude to "review this" and getting whatever it happens to return, you invoke a skill and it follows a structured process — asking the right questions in the right order, catching things a freeform prompt would miss, producing output you can actually act on. Planning sessions became more thorough. Reviews caught real problems. The final polish sprints on this app were meaningfully better for it.


I think this is where AI-assisted development is heading. The raw model is powerful, but a structured harness around it — one that constrains how the AI approaches a problem, sequences the thinking, and enforces the right questions — is what produces consistent quality at scale. Right now that looks like skill files and workflow scripts. Eventually it will probably be something more formal. But the idea is already proving itself: AI plus structure beats AI alone.

This app exists because I wanted it to exist, for my own use, built around my own well-being. That turned out to be enough reason. And the tools that made it possible are better now than when I started — which means the next thing I build will be different again.

MoodHaven Journal is free and open source, MIT-licensed, available for Linux, macOS, and Windows.

            

 
 
 

Comments


CONTACT ME

Success! Message received.

© 2017-2026 By Kenneth LaCroix.

bottom of page