<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Moji Tajik</title>
    <link>https://moj.dev/blog/</link>
    <description>Incidents, root causes, and the rules I extracted from them. Engineering, security, and the occasional Dutch bureaucracy maze.</description>
    <language>en</language>
    <atom:link href="https://moj.dev/feed.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Sat, 25 Jul 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>The Bug Was Never in the Diff</title>
      <link>https://moj.dev/blog/never-in-the-diff/</link>
      <guid isPermaLink="true">https://moj.dev/blog/never-in-the-diff/</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <category>Engineering</category>
      <description>Three minutes after I opened the pull request, a bot told me the endpoint I was rewriting could be knocked over by a small JSON body. I had written the bot.</description>
      <content:encoded><![CDATA[<p>Three minutes after I opened the pull request, a bot told me the endpoint I was rewriting could be knocked over by a small JSON body. I had written the bot.</p>
<p>The code I was changing takes text containing <code>{{placeholders}}</code> and replaces each one with a real value. Then it does the whole thing again, because a value it just substituted can itself contain another placeholder, and it keeps going until a pass replaces nothing. So give it a placeholder whose value contains that same placeholder twice. Every pass replaces one and produces two, the string doubles each time, and the loop never reaches a pass that changes nothing. Nothing bounded that, and the endpoint takes anonymous requests, so any stranger on the internet could send a small body and pin the worker. Billion laughs, no XML required.</p>
<p>The loop was not something I wrote, and the bot said so itself: <em>&quot;The core loop DoS predates this PR... but this PR rewrites exactly this loop and adds a termination-safety comment, so it&#39;s the natural place to close the gap.&quot;</em> It was reporting somebody else&#39;s old bug and it was honest about that, which is the part that made me read the rest of the comment. A human reviewing my diff would not have looked at that loop at all, because the loop was not the point of the change. Thirty seconds later the code reviewer found the same loop from its own angle, self-referential data hanging the worker until the host times out. I spent the next 24 minutes on a pass cap, a per-field length budget, and a test for each shape of the problem.</p>
<p>That was 07:24 on a Friday. We run a monolith and thirty-something microservices, review stopped being something we could keep up with by hand, and these agents are what I built to close the gap, so the interesting part is not that a bot found a bug, it is where it was allowed to look. It started with a commit rule and a script on my own laptop.</p>
<h2 id="why-review-got-hard">Why review got hard</h2>
<p>A single change usually means touching several of those services at once, the volume going out every day has gone up a lot because generating a PR is cheap now, and reviewing all of it landed on the same people as before. Three costs, stacked.</p>
<p>The diffs got big. AI makes it easy to produce a large, coherent, fully tested change, so a PR now arrives as around a thousand lines across multiple files, and reading that carefully is genuinely hard work. You cannot skim it, and you cannot honestly approve it without holding most of it in your head at once.</p>
<p>Then the context switching. The PRs come from different people working on different things, a new feature here, a maintenance change there, a bug fix somewhere else, each in its own repository, so you load the mental model of that service, what it owns, what calls it, what its config looks like, read the whole diff carefully, and then the next PR is a different person in a different repository and you drop all of that and load a different one. My reviews got worse the more I switched, and I could feel it happening.</p>
<p>And the thing that actually breaks production is often not in the diff at all. A perfectly correct change in one repository can break three services that consume it, and nothing in the pull request shows you that. You only find it if you happen to remember which services call this thing, on a day when you happen to have the energy to go and look.</p>
<p>None of this is an AI problem. AI made the volume and the size worse, but a human-written PR breaks the consumer in exactly the same way.</p>
<h2 id="the-cheapest-fix-was-not-a-reviewer-at-all">The cheapest fix was not a reviewer at all</h2>
<p>Before automating any reviewing, I changed how the code arrives.</p>
<p>We had an AI software-engineer agent by then, and the rule I gave it was simple: do not commit everything at once, commit in related chunks. So a change arrives as several logical commits, each one doing one thing, instead of one thousand-line blob with a single message on top. Whoever reads it can walk it commit by commit, and the change explains itself in the order it was built.</p>
<p>One line in an agent file, and every review after it got easier, before a single review agent existed. Nothing else in this post had that ratio.</p>
<h2 id="a-tool-that-only-helps-me-does-not-move-the-bottleneck">A tool that only helps me does not move the bottleneck</h2>
<p>The review side started as small as it gets. I wrote a code-reviewer agent and a security-reviewer agent, kept them on my own machine, and added a hook so that whenever somebody assigned me a PR I handed it to Claude Code and the agents ran locally against it. No CI, no rollout plan. The reviews were good enough that I stopped opening big diffs cold: findings first, then the code, and the second read was a different kind of reading, because I knew what I was looking for.</p>
<p>It was also miserable to run. A terminal per PR, opened by hand. It went through my own Claude account, so a big review would hit a context limit or start crawling against a rate limit. The CLI crashed partway through often enough that I learned to check. And more than once I came back to a review that had died because the laptop went to sleep. None of that is an argument about architecture. It is just what it costs to run infrastructure on a machine that is also a laptop.</p>
<p>Which fixed my afternoon and nothing else. The same cost was landing on everyone, PRs were sitting waiting for whoever had enough context to read them properly, and review was what set how fast any of us could ship, so a fix that ran on one laptop left that cap exactly where it was. So I moved the agents into a repo the whole team could pull from. They used them, and they liked them, and I want to be careful with that sentence: it is a feeling the people around me reported, not a number I can show you.</p>
<p>Sharing them is also what forced the quality bar up, because a reviewer that only annoys me is my problem, and a reviewer that annoys the whole team gets muted and then quietly wastes everyone&#39;s CI minutes forever. So the code reviewer got rebuilt around Rapid Software Testing principles, question the assumptions, look for what is <em>missing</em>, prioritize by risk, and every finding got a mandatory <strong>Failure Scenario</strong> field: concrete inputs, and the wrong outcome they produce. If the agent cannot say what actually goes wrong, the finding gets downgraded or dropped. That one rule killed almost all the noise, and it survives verbatim into what we run today.</p>
<h2 id="good-reviewers-still-blind-to-the-blast-radius">Good reviewers, still blind to the blast radius</h2>
<p>By then the agents had solved the first two costs, and solved them well. A thousand-line diff got read carefully every single time, at the same quality on the last PR of the day as on the first, and nobody had to hold an entire service in their head to say something useful about it.</p>
<p>The third cost they could not touch at all. They reviewed the change and nothing the change touches, so the question that actually takes production down, does this break the services that consume it, was still nobody&#39;s job. They also only ran when somebody remembered to run them. An agent sitting on a laptop had no idea the other repositories existed, and it could tell you the code was correct without telling you anything about what it would do to the rest of the system.</p>
<p>So I tried to give it the other repositories. I cloned every one I needed into a single parent directory and ran the agents from inside it, and it worked, the cross-checks were suddenly possible. Then the clones went stale. I would have needed a rule making the agent pull the default branch of every repo before every review just to trust what it was reading, and I still had to keep that directory honest by hand, and missing one new repository means the review is confidently wrong. A directory on my disk was pretending to be the organization.</p>
<h2 id="the-access-was-the-whole-feature">The access was the whole feature</h2>
<p>Moving the agents into GitHub Actions was supposed to solve the &quot;when somebody remembers&quot; half. It solved the stale-mirror half too, because in Actions the checkout is fresh by definition and the organization is the source of truth instead of my disk, so there is nothing to pull and no new repository to remember to clone. The cross-repository review I had wanted turned out not to be a feature I had to build, it was a side effect of putting the agent where the code already lives.</p>
<p>What I did have to build was the map. One central file in the shared repo lists which projects relate to which, as symmetric groups. The reviewer resolves the group for the repo under review and gets that list handed to it, read-only, and the repo under review cannot grant itself extra reach.</p>
<p>There is no index and no clever retrieval. The reviewer decides for itself what to go looking for, and it does it the way you would: it takes the thing it just saw change — an endpoint route, a property name, a DTO, a method — and runs <code>gh search code</code> for that term across the sibling repositories, then pulls the specific files the hits point at and reads them. That is the whole mechanism, and its weakness is exactly the one you would expect. It finds the consumer when the consumer names the thing recognisably. A caller that reaches the endpoint through a generated client, or a field that gets mapped through two layers of renaming on the way in, is invisible to a text search, and the reviewer will tell you the change looks safe because as far as it could see, it was.</p>
<p>Nothing is cloned and nothing is uploaded wholesale. The reviewer queries the API for exactly the files and lines it needs, when it needs them, so a sibling repository gets read a few hits at a time rather than shipped as a body of code. That is a smaller surface, not a different one — the code still goes to a model, and a CI job holding an org-scoped token reading other teams&#39; repositories on every push is not the same thing as an engineer choosing to point an assistant at their own. It was an easier conversation here because everyone already works this way, but &quot;easier&quot; is not &quot;asked.&quot;</p>
<p>So now: change something in the backend, and the reviewer goes and looks at every repository connected to it, the consuming services, the gateway layer, and tells you whether the change is backward compatible or whether it is about to break somebody. That is the whole point of the thing, and it runs today on the monolith and on the 30-plus microservices around it.</p>
<p>The obvious objection is that this is what contract tests are for, and it is. We have some. We add more when we touch something, and coverage grows slowly, and it is nowhere near all of a monolith that size — so on any given morning most of the surface between our services is asserted by nothing at all. A reviewer that reads the consumer&#39;s code does not fix that. It covers the part that is not covered yet, and it is worse than a contract test everywhere a contract test exists: slower, non-deterministic, and it can be argued with. The version of this I actually want is the one where the reviewer&#39;s findings turn into tests, so the same breakage gets caught next time by something cheaper and more certain than a model.</p>
<p>The number I do have is turnaround. Opening a PR and getting usable review feedback on it used to take 30 to 60 minutes depending on the size of the thing, sometimes a lot more when everybody was heads-down. Now it is under 5 minutes, of which about two are the reviewers themselves running in parallel and the rest is queueing and checkout. That is wall clock from push to feedback, not engineer hours saved.</p>
<p>The plumbing is deliberately boring: a 25-line workflow in each repo calls a reusable workflow in the shared repo, which loads the agent file, resolves the related repositories and runs the model. I have put a sanitized version of it up at <a href="https://github.com/MojtabaTajik/PR-Review-Agents" target="_blank" rel="noopener">github.com/MojtabaTajik/PR-Review-Agents</a>, about 520 lines: the composite action, the reusable workflow, both agent files, the relationship-map schema and an example caller workflow. The plumbing is not the part worth having. The agent files and the rules inside them are.</p>
<ul>
<li><strong>Single source of truth.</strong> No repo carries a copy of an agent. Changing a reviewer across the whole fleet is one PR to one file, and adding the next repository costs a 25-line workflow file.</li>
<li><strong>The two reviewers run in parallel.</strong> They are separate jobs on the same push, so the wait is about two minutes for both of them, not two minutes each. That number is the difference between a tool and a tax, and if it ever doubles people will start merging before the review lands.</li>
<li><strong>It is not a required status check.</strong> Nothing in the pipeline physically stops a merge. The rule is a team agreement instead: critical and high findings block the merge, everything below that is the author&#39;s judgment. Enforced socially, not mechanically, which works right up until the day it doesn&#39;t.</li>
<li><strong>One review per push, with the reviewed commit linked in the title.</strong> Push again and you get another review, pinned to the head commit it actually saw, so on a PR you push to five or six times you can tell which state of the branch each verdict applies to, and watch findings escalate and clear instead of arguing about a moving target.</li>
<li><strong>Terse when clean.</strong> A clean verdict is two lines: <em>&quot;Looks good — nothing to flag.&quot;</em> No &quot;here is what I checked&quot; essays. Silence about non-issues is the signal.</li>
<li><strong>Everything the reviewer reads is untrusted input.</strong> It reads diffs and other people&#39;s repositories, and both can talk back. A repo can append its own context file, but that file is handed to the agent as untrusted data that can add emphasis and cannot relax the severity rules. The cross-repo token is fine-grained, contents-read only, and the repo under review cannot widen its own reach. I built a security reviewer, so it gets treated like one.</li>
<li><strong>The reviewer must name the repositories it consulted.</strong> Every cross-repo claim ends with that list. Otherwise you cannot tell an informed finding from a confident guess.</li>
</ul>
<h2 id="the-proof-one-feature-three-repos-nineteen-reviews">The proof: one feature, three repos, nineteen reviews</h2>
<p>Back to that Friday. One ticket, one behavioral change, three coordinated PRs: the engine itself, a gateway policy, and the MCP tools that document the new authoring rules.</p>
<p>After I pushed the DoS fix, the code reviewer read the new commit and came back with the finding I could never have gotten locally: the live consumer app calls this endpoint without setting the mock-data flag, so my new strict default would break document creation in production. Footer of the comment, the consumer repo, named. It had read the consumer&#39;s actual code instead of guessing.</p>
<p>I went to production telemetry to settle it, 90 days, around 61k invocations, zero mock-fallback traces. Then I stopped trusting my own zero, because our ingestion filters out Information-level traces entirely, and that is exactly the level those fallbacks were logged at. The evidence that would have answered the question was below the ingestion floor. So the PR now logs fallbacks at Warning, and the rollout became measurable instead of hopeful.</p>
<p>An hour later the code reviewer, cross-checking three sibling repos, found the sharpest thing of the day: the admin app&#39;s document <em>preview</em> flow proxies its body straight through to the same endpoint, and preview is exactly the case where real data does not exist yet, so strict validation would have broken every preview. That finding created the gateway PR, injecting the flag at the gateway for the two preview operations only, instead of touching the admin app at all.</p>
<p>Then the best moment of the exchange. The reviewers reviewed the gateway PR in its own repo, verified the policy, confirmed by cross-repo read that the current engine tolerates the extra property, and then flagged the one thing they could not verify from that side: if the engine reads that flag anywhere other than a top-level body field with exactly this name, the injection silently does nothing and previews start returning 400 with no error visible at the gateway. I verified the contract by hand, then added two tests to the engine PR that deserialize the exact JSON the gateway policy produces. A future rename now fails a test instead of quietly turning every preview into a 400.</p>
<p>The third PR was documentation only, and all four of its reviews were the one-liner. A bot that always finds something teaches you to ignore it, a bot that can say <em>nothing to flag</em> makes its warnings worth reading.</p>
<p>At 09:35 both reviewers cleared the merge commit, and after all of that the entire verdict was: <em>&quot;Safe · Ready for QA — Looks good — nothing to flag.&quot;</em></p>
<p>Final count for the three PRs: 19 automated review rounds, 7 substantive replies from me, 9 of the 11 commits driven by review findings, 13 findings I acted on or pushed back on, three of which were wrong, one MEDIUM security finding, tests 199 to 208 green, about two and a quarter hours end to end. One feature, one morning: this is an anecdote, not a benchmark.</p>
<p>No other engineer looked at any of it before it reached our QA team, and that part deserves saying out loud rather than counting as a win: the only human at the merge button was the person who had written both the code and the reviewers. For a behavior-neutral change sitting behind a setting that is a call I am comfortable making. As a standing policy it would not be.</p>
<h2 id="the-round-i-won">The round I won</h2>
<p>At 09:02 the code reviewer flagged that my new length cap would fire even on the supposedly behavior-neutral deploy, and suggested gating it to log-only. The finding was right. The fix was wrong, and I rejected it in the thread: log-and-continue returns half-expanded content, and a silently corrupt document is worse than a clear 400, and removing the bound reopens exactly the DoS the security reviewer flagged at 07:24. Instead I raised the default budget 10x, to 1M characters, far beyond any legitimate value while still being a bound.</p>
<h2 id="what-it-still-gets-wrong">What it still gets wrong</h2>
<ul>
<li><strong>There are no evals.</strong> A one-line edit to an agent file ships to every repository at once, and the only symptom of a bad edit is fewer findings, which looks exactly like good news. I tested the product code carefully and did not test the thing making the judgments, which is a bit embarrassing to write down. I am building them now, with other models judging the reviewers&#39; output.</li>
<li><strong>Three of the thirteen findings were wrong, and the loop made them itself.</strong> The agent flags something, I fix it and leave a comment in the code explaining the fix, the next round of fixes makes that comment stale, and a later review flags the stale comment. Each of those findings was correct about what it saw. The work was manufactured by the review loop, and the more rounds a PR takes the more of it there is. No fix yet, and I do not think more rules is the answer.</li>
<li><strong>I do not know what it misses.</strong> Everything in this post is a finding the reviewers caught. I cannot show you the ones they cleared that broke later, because the engine has not been running long enough for me to have that list, and a review system with no known false negatives is not the same thing as one with none. Same reason I would not trust that zero in the telemetry: nobody is measuring at the level where a miss would show up.</li>
<li><strong>The map is maintained by hand.</strong> That central relationship file is a dependency graph a human writes. A new service that nobody adds to it is invisible to every reviewer in the fleet, and the review that misses it still reads exactly as confident as the ones that got it right. Which is the same failure I escaped when I stopped cloning repositories onto my laptop, moved up one layer. I do not have a fix for it.</li>
<li><strong>It costs real money, and I have not added it up.</strong> Around €1 to €2 per PR on average, depending on how much cross-repo checking it does and how big the PR is. I have not totalled that across the fleet, so I cannot tell you the monthly bill. Guards exist, max turns, concurrency cancel, docs and bot PRs skipped, per-repo model override, and Sonnet as the fleet-wide lever if spend gets out of hand.</li>
<li><strong>Cross-repo reads are stale.</strong> One round cross-checked the gateway repo, hit the default branch instead of the sibling PR I had opened minutes earlier, and re-raised a finding I had already addressed. Open PRs in other repositories are invisible to it. I am working on that now: permission to list open pull requests in the related repositories, and rules telling the code reviewer to check them, so it surfaces the sibling PR that already fixes the breakage instead of reporting it to me twice.</li>
<li><strong>The reviewers review code, not intent.</strong> There is a gate that blocks PRs without a linked requirement, but a change that faithfully implements the wrong requirement passes every check we have.</li>
<li><strong>It repeats itself.</strong> The security reviewer re-affirmed the same hardening in several clean verdicts. Harmless, slightly annoying.</li>
</ul>
<p>The division of labor is the part I would keep, and the 09:02 round is what it looks like. <strong>The agents set the bar. They do not own the merge button.</strong></p>
<p>The reviewer itself was never the goal. Review was the thing capping how fast we could ship, so review is what I went after, and when the constraint moves the fix will look like something else entirely.</p>
<p>None of which is the thing I actually remember from that Friday. A cross-repo contract, pinned by a test, because a bot asked a question it could not answer itself.</p>
]]></content:encoded>
    </item>
    <item>
      <title>I Gave My Vault a Memory</title>
      <link>https://moj.dev/blog/vault-with-memory/</link>
      <guid isPermaLink="true">https://moj.dev/blog/vault-with-memory/</guid>
      <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
      <category>Engineering</category>
      <description>The vault was full of notes I'd completely forgotten having. I added one character to the bot and made it answer back.</description>
      <content:encoded><![CDATA[<p>In <a href="https://moj.dev/blog/vault-walkie-talkie/" target="_blank" rel="noopener">A Walkie-Talkie to My Vault</a>, I solved capture. Text a thought, tap Today, done. The vault writes itself.</p>
<p>That worked. After months of daily logging, the vault had grown dense. Thousands of notes: goals, lessons, VulWall decisions, Roamler retrospectives, weekly reviews, ideas I&#39;d completely forgotten having.</p>
<p>Then I wanted to actually use it.</p>
<p>Not search it. Not scroll it. Talk to it. Send a message, get an answer that pulls from everything I&#39;ve written: the lesson from three months ago, the decision I made last week, the goal I set in January. Without remembering where I filed it.</p>
<p>I added one character to the bot. Now <code>?</code> at the start of any message means: ask the vault.</p>
<hr>
<h2 id="the-setup">The Setup</h2>
<p>Same Telegram bot. Same Go service. I added a FastAPI sidecar that handles embeddings and retrieval, and wired it in.</p>
<p>The architecture is simple:</p>
<pre data-lang="code"><code>Obsidian vault (markdown files)
        ↓
    FastAPI sidecar
    LanceDB (local, on disk)
    Ollama (nomic-embed-text, 768-dim)
        ↓
    Telegram bot
    &quot;?&quot; prefix triggers retrieval
        ↓
    Groq LLM answers from retrieved context</code></pre><p>No OpenAI embeddings. No managed vector DB. Everything retrieval-related runs on the home lab next to the vault. The retrieval stack is local; Groq still handles final answer generation.</p>
<hr>
<h2 id="embeddings-without-a-cloud-dependency">Embeddings Without a Cloud Dependency</h2>
<p>The first decision: which embedding model.</p>
<p>I&#39;m already running Ollama for other things. <code>nomic-embed-text</code> is 137M parameters, pulls in seconds, runs comfortably on CPU, and produces 768-dimensional vectors that punch well above their weight in retrieval benchmarks. It&#39;s become the default for local RAG setups for good reason.</p>
<pre data-lang="bash"><code class="language-bash">ollama pull nomic-embed-text</code></pre><p>That&#39;s the entire setup. The sidecar talks to Ollama over HTTP at <code>localhost:11434</code>. No API key. No rate limit. No bill.</p>
<hr>
<h2 id="why-hybrid-search">Why Hybrid Search</h2>
<p>The obvious implementation (embed the query, find nearest vectors, return top-k) works fine for semantic questions. &quot;What are my thoughts on delegation?&quot; retrieves notes that <em>mean</em> delegation even if they don&#39;t say the word.</p>
<p>But it falls apart on specifics. If I ask &quot;what was the outcome of the Roamler retro in April?&quot;, a pure vector search returns notes that are <em>about</em> retrospectives, not the one from April or the specific decisions that came out of it. Dense embeddings compress meaning; they wash out rare tokens, proper nouns, and dates.</p>
<p>The fix is hybrid search: BM25 for exact token matching, vector for semantic matching, and reciprocal rank fusion (RRF) to merge the two result lists into a single ranking.</p>
<pre data-lang="code"><code>query → embed → vector search   ─┐
query → tokenize → BM25 search  ─┼→ RRF → top-k chunks → LLM</code></pre><p>LanceDB supports this natively with one call:</p>
<pre data-lang="python"><code class="language-python">results = (
    table.search(query_type=&quot;hybrid&quot;, vector_column_name=&quot;vector&quot;)
    .vector(query_vector)
    .text(query_text)
    .rerank(reranker=RRFReranker())
    .limit(6)
    .to_list()
)</code></pre><p>Proper nouns, dates, and project names now land correctly because BM25 catches what the dense model misses.</p>
<hr>
<h2 id="chunking-and-indexing">Chunking and Indexing</h2>
<p>Notes are chunked into paragraph-shaped blocks, capped around 800 characters, with 120 characters of overlap between chunks. The overlap is the part most tutorials skip: without it, a sentence that straddles a chunk boundary gets split in half and retrieval misses it.</p>
<p>Each chunk is stored with:</p>
<ul>
<li>The text</li>
<li>Its embedding vector</li>
<li>The note&#39;s vault-relative path as a key (<code>Daily notes/2026/04/2026-04-12.md</code>)</li>
<li>A SHA-256 hash of the full note content</li>
</ul>
<p>The hash is the idempotency key. When the reindex job runs, it fetches the current hash map from the sidecar in one call, then walks the vault. If a file&#39;s hash matches what&#39;s already indexed, it&#39;s skipped entirely. A vault with 2,000 notes where 10 changed since yesterday means 10 embed calls, not 2,000.</p>
<p>Bulk reindex runs on demand via <code>/reindex</code> in the bot. New notes get indexed automatically when I add them. Fast enough that I don&#39;t notice it.</p>
<hr>
<h2 id="parallel-embeddings">Parallel Embeddings</h2>
<p>One thing that bit me early: the first version of <code>embed_batch</code> was sequential.</p>
<pre data-lang="python"><code class="language-python"># wrong
for t in texts:
    r = await client.post(ollama_url, ...)
    out.append(r.json()[&quot;embedding&quot;])</code></pre><p>For a note with 8 chunks, that&#39;s 8 serial HTTP round-trips to Ollama. Multiply by 2,000 notes on first index and you&#39;re looking at minutes of wall time for what should be seconds.</p>
<p>The fix is <code>asyncio.gather</code>:</p>
<pre data-lang="python"><code class="language-python">async def _one(t: str) -&gt; list[float]:
    r = await client.post(ollama_url, json={&quot;model&quot;: MODEL, &quot;prompt&quot;: t}, timeout=60.0)
    r.raise_for_status()
    return r.json()[&quot;embedding&quot;]

return list(await asyncio.gather(*(_one(t) for t in texts)))</code></pre><p>All chunks for a note go out concurrently. In my setup, Ollama still ends up doing the actual embedding work effectively serially on CPU, but the HTTP overhead stops stacking.</p>
<hr>
<h2 id="the-fts-index-trap">The FTS Index Trap</h2>
<p>Another non-obvious issue: LanceDB&#39;s BM25 index needs an explicit build step.</p>
<p>The naive implementation calls <code>create_fts_index</code> after every <code>/index</code> call. That&#39;s an O(n) rebuild over the entire table, once per document during a vault reindex. For 2,000 notes, that&#39;s 2,000 full rebuilds of a growing index.</p>
<p>The fix: accept a <code>rebuild_fts</code> flag on the <code>/index</code> endpoint. The bulk reindex caller sets it to <code>false</code> for every document, then calls <code>/rebuild-fts</code> once at the very end. One rebuild, not 2,000.</p>
<p>Small detail. Massive difference in reindex time.</p>
<hr>
<h2 id="asking-the-vault">Asking the Vault</h2>
<p>From the user side, nothing changed. Same bot, same Telegram thread. The only difference is the prefix.</p>
<p>Without <code>?</code>: the message goes into today&#39;s daily note.
With <code>?</code>: the message goes to the vault as a question.</p>
<pre data-lang="code"><code>me:  ? what were my main takeaways from the Roamler retro in April?

bot: Based on your notes from April 14 and the weekly review from W16:
     The main theme was async communication overhead...</code></pre><p>The sidecar retrieves 6 chunks, the bot assembles them into a context block, and Groq answers against that context. The source note keys are passed into the prompt for grounding, even though the bot&#39;s final reply stays clean and citation-free.</p>
<p>Temporal queries work naturally too. If the question contains &quot;last week&quot;, &quot;yesterday&quot;, &quot;last Tuesday&quot;, or an explicit date like &quot;2026-04&quot;, the bot parses it and passes a date range to the sidecar. LanceDB filters by the <code>date</code> column before running the hybrid search, so &quot;what happened last week?&quot; only searches notes from that window — not the entire vault. No special syntax needed.</p>
<p>One thing I didn&#39;t expect: the detail hierarchy doing real work. My vault has daily logs (raw, specific), weekly reviews (themes), and quarterly reviews (narrative arcs). The chunking doesn&#39;t distinguish between them. A query about the Roamler retro retrieves from all three levels simultaneously: a daily note with the raw action items, a weekly review that grouped them into themes, a quarterly review that named it a turning point. The answer has natural depth because the source material was written at multiple levels of abstraction.</p>
<p>That wasn&#39;t designed. It emerged from the structure the walkie-talkie system wrote into the vault.</p>
<p>I&#39;ve been using it to surface things I&#39;d genuinely forgotten: decisions I made and didn&#39;t act on, lessons I wrote down and then ignored, patterns across weeks of daily logs that I couldn&#39;t see one day at a time. Generic RAG demos use docs or Wikipedia. When the source material is your own writing, the answers feel unnervingly on-point. It&#39;s not retrieving similar documents. It&#39;s retrieving <em>me</em>.</p>
<hr>
<h2 id="steal-this">Steal This</h2>
<p>The pieces are all open source and local.</p>
<ul>
<li><strong>Ollama + nomic-embed-text.</strong> Free, fast, no API key. <code>ollama pull nomic-embed-text</code> and you have a production-quality embedding model.</li>
<li><strong>LanceDB.</strong> Embedded vector DB, runs in-process or as a sidecar, no infrastructure. Hybrid search built in.</li>
<li><strong>Content-hash idempotency.</strong> SHA-256 the file, store the hash, skip unchanged files on reindex. Your reindex job goes from slow to instant after the first run.</li>
<li><strong>Hybrid BM25 + vector + RRF.</strong> Pure vector search misses proper nouns, dates, and rare terms. Hybrid catches them. The extra setup is ten lines.</li>
<li><strong>One rebuild at the end.</strong> Don&#39;t rebuild the FTS index after every document during bulk reindex. Rebuild it once when the loop finishes.</li>
<li><strong>Temporal filtering.</strong> Store the note date as a column and parse natural language (&quot;last week&quot;, &quot;yesterday&quot;) into a date range filter before the vector search. Cuts noise significantly for time-specific questions.</li>
<li><strong>Use your existing interface.</strong> I already had a Telegram bot. Adding <code>?</code> as a retrieval prefix cost me an if-statement and a new handler. No new app. No new habit.</li>
</ul>
<p>The vault was already healthy. Now it answers back.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A Walkie-Talkie to My Vault</title>
      <link>https://moj.dev/blog/vault-walkie-talkie/</link>
      <guid isPermaLink="true">https://moj.dev/blog/vault-walkie-talkie/</guid>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <category>Engineering</category>
      <description>I haven't opened Obsidian to journal in weeks. I put a Telegram bot in front of my vault, let AI do the filing, and now the daily log writes itself.</description>
      <content:encoded><![CDATA[<p>I haven&#39;t opened Obsidian to journal in weeks. I still open it every day for everything else: blog drafts, Permanent notes, VulWall, Roamler, refining my thinking. What stopped happening there is capture. And my daily log has never been in better shape.</p>
<p>The journaling problem isn&#39;t discipline. It&#39;s friction. Open the app. Find today&#39;s note. Remember the template. Fight the formatting. By the time the tool is ready, the thought is gone, and you&#39;re three days behind on a ritual you swore you&#39;d keep.</p>
<p>I solved it the dumb way. I put a Telegram bot in front of my vault and let AI do the filing. Now I journal by texting. The vault writes itself.</p>
<p>Here&#39;s the whole setup.</p>
<hr>
<h2 id="the-core-loop">The Core Loop</h2>
<p>I text any thought (a line, a paragraph, a dump) to a private Telegram bot. Or I hold the record button and speak. The bot replies with two buttons: <strong>Today</strong> or <strong>Yesterday</strong>. I tap one. Under five seconds later it reacts 👌 to my original message, and my daily note in the Obsidian vault has the new content, cleanly refined, filed into the right section.</p>
<p>Voice is the half that makes the mobile moments actually work. Typing on a phone while watering flowers isn&#39;t possible. Holding a button and speaking is. Telegram voice notes land as Opus-in-Ogg audio, get piped to <code>whisper-large-v3</code> on Groq, and come back as text in about a second. The bot shows me the transcript next to the Today/Yesterday buttons so I can catch any word Whisper got wrong before I commit. I almost never need to.</p>
<p>That&#39;s the entire user-facing experience. No app. No template. No friction. The hardest decision I make is <em>today or yesterday.</em></p>
<p>Under the hood it&#39;s a Go service called <strong>DailyLog</strong>, running in Docker on my home lab, bind-mounted directly into my Obsidian vault. When I tap a day, it:</p>
<ol>
<li>Loads the existing daily note at <code>vault/Daily notes/YYYY/MM/YYYY-MM-DD.md</code></li>
<li>Appends my raw blurb to the end</li>
<li>Sends the whole merged document to <strong>GPT-OSS-120B on Groq</strong> with a very strict system prompt</li>
<li>Writes the refined output back to disk</li>
</ol>
<p>The model choice matters here. Note-taking isn&#39;t a reasoning task. It&#39;s routing, grammar cleanup, and bullet grouping. A 120B open-weights model running on Groq&#39;s inference is more than enough for that, fast enough to round-trip under five seconds, and cheap enough that I can fire it a dozen times a day without thinking about the bill. Frontier models would be overkill. A smaller model would lose the grouping nuance. 120B-class is the sweet spot for this specific job.</p>
<p>The actual magic isn&#39;t the API though. It&#39;s the prompt.</p>
<hr>
<h2 id="the-prompt-is-the-product">The Prompt Is the Product</h2>
<p>Every daily note in my vault follows a fixed shape:</p>
<pre data-lang="markdown"><code class="language-markdown">## Personal
- …

## VulWall
- …

## Roamler
- …</code></pre><p>Three sections. Personal for life, VulWall for my startup, Roamler for day-job work. That&#39;s it. The system prompt I hand Groq has eight rules, but three of them carry the weight.</p>
<p><strong>1. Never drop information.</strong> Every piece of input must appear in the output. Not &quot;summarized away,&quot; not &quot;skipped because it sounds unimportant.&quot; If I mention a family member&#39;s package pickup tomorrow, that bullet exists in the final note. Even if it&#39;s about a future day. Even if it&#39;s someone else&#39;s plan. If in doubt, include it.</p>
<p><strong>2. Route to the right section.</strong> Personal is home, family, errands, appointments, non-work messages. VulWall and Roamler are the two work buckets. Items go there only if they&#39;re clearly about those projects. Everything else defaults to Personal. No new sections ever. No <code>## Miscellaneous</code>, no <code>## Notes</code>.</p>
<p><strong>3. Group related content.</strong> If I dump five sentences about fixing a kitchen wall (the research, the store run, the tools, the install, the leftover TODOs), the output is <strong>one</strong> rich bullet telling that story, not nine fragmented ones. Only split when topics are genuinely unrelated.</p>
<p>The group-don&#39;t-fragment rule is what turned this from &quot;AI notes toy&quot; into something I actually trust. Without it, a paragraph of connected thought becomes a scatter of disconnected bullets and the note reads like a grocery list. With it, the note reads like someone wrote it on purpose.</p>
<p>One more rule works in the background without making a speech about it: <strong>fix what&#39;s broken.</strong> Typos, wrong tense, run-on sentences, missing punctuation. The capture pass is also the polish pass. There&#39;s no editor downstream, because there shouldn&#39;t need to be.</p>
<hr>
<h2 id="merge-don39t-replace">Merge, Don&#39;t Replace</h2>
<p>The other design choice that made this work: the bot never overwrites. It <strong>merges</strong>.</p>
<p>When I send my sixth note of the day, the bot doesn&#39;t regenerate the whole note from scratch. It loads the existing refined markdown, appends my new raw blurb after the last section, and hands the combined document to the model. The prompt tells Groq explicitly: <em>preserve every existing bullet exactly; only integrate the new paragraph at the end.</em></p>
<p>This matters for two reasons.</p>
<ul>
<li><strong>Stability.</strong> A bullet I wrote this morning doesn&#39;t get subtly rephrased every time I add a new thought in the afternoon. What was refined stays refined.</li>
<li><strong>Trust.</strong> I can look at a note mid-day and know that nothing I&#39;ve logged has quietly mutated. The model only touches new content.</li>
</ul>
<p>It&#39;s a small architectural decision that most &quot;AI-powered notes&quot; tools get wrong. They regenerate. Things drift. You stop trusting the output. You stop using the tool.</p>
<hr>
<h2 id="the-second-half-runs-on-cron">The Second Half Runs on Cron</h2>
<p>The bot captures. Cron curates. Every script writes directly into the vault and pings me on Telegram when it&#39;s done.</p>
<table>
<thead>
<tr>
<th>When</th>
<th>Script</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><strong>03:30 daily</strong></td>
<td><code>daily-plan.sh</code></td>
<td>Reads goals and recent notes, generates a time-blocked plan for the day, delivers it via Telegram. This is my morning briefing.</td>
</tr>
<tr>
<td><strong>03:45 Sunday</strong></td>
<td><code>weekly-review.sh</code></td>
<td>Synthesizes the week&#39;s daily notes into themes, wins, and follow-ups. Writes to <code>Self Reflection/Weekly/YYYY-W##.md</code>.</td>
</tr>
<tr>
<td><strong>03:00 Q1/Q2/Q3/Q4</strong></td>
<td><code>quarterly-review.sh</code></td>
<td>Distills thirteen weekly reviews into narrative arcs. No PR numbers, no item lists. Just what actually happened at a quarterly zoom level.</td>
</tr>
<tr>
<td><strong>03:00 Sunday</strong></td>
<td><code>clippings-process.sh</code></td>
<td>Scans the clippings inbox, drafts article summaries for keepers, drops the junk.</td>
</tr>
<tr>
<td><strong>02:00 daily</strong></td>
<td><code>backup.sh</code></td>
<td>Tars the whole home lab (vault, docker configs, automations) to SSD. Sundays additionally encrypt and ship off-site to Cloudflare R2.</td>
</tr>
</tbody></table>
<p>Each of these is a small shell script. The script itself is mostly plumbing: source an env file, export <code>ANTHROPIC_API_KEY</code>, call the <code>claude</code> CLI with a prompt and an agent flag, pipe the result to <code>notify.sh</code>. Under 50 lines apiece. The actual work happens inside Claude.</p>
<p>The <code>--agent</code> flag is the hinge. It tells Claude Code which specialist to open the session as. Each agent is a markdown file under <code>~/.claude/agents/</code> with a system prompt, tool permissions, and a tighter scope. The mapping:</p>
<table>
<thead>
<tr>
<th>Script</th>
<th>Agent</th>
</tr>
</thead>
<tbody><tr>
<td><code>daily-plan.sh</code></td>
<td><code>daily-planner</code></td>
</tr>
<tr>
<td><code>clippings-process.sh</code></td>
<td><code>clippings-curator</code></td>
</tr>
<tr>
<td><code>weekly-review.sh</code></td>
<td><code>life-coach</code></td>
</tr>
<tr>
<td><code>quarterly-review.sh</code></td>
<td><code>life-coach</code></td>
</tr>
</tbody></table>
<p>Each script leans on one specialist. Planning a day against recent notes and goals is an opinionated routing job, so <code>daily-planner</code> does it. Triaging a stack of clippings into keep-or-delete plus a draft note is a classification job, so <code>clippings-curator</code> does it. Synthesizing a week (or a quarter) of notes into themes and narrative arcs is a reflection job, so <code>life-coach</code> does it. Different agents, different system prompts, different jobs.</p>
<p>I ran the weekly and quarterly pipelines as two stages for a while: one agent to draft, a separate grammar-and-frontmatter agent to polish. Then I noticed I was paying to reload the whole life system twice just to run a checklist at the end. I moved the checklist into the drafting prompt and dropped the second stage. The output is the same. The bill is lower. The log is simpler. When a pipeline needs an editor, the right editor is usually the writer, one paragraph later in the same prompt.</p>
<p>But none of this would be useful if the agents didn&#39;t know my life. That&#39;s what <code>CLAUDE.md</code> is for.</p>
<p>The <code>CLAUDE.md</code> at the root of my vault documents everything an agent needs to act well: folder structure, templates, my life system (<code>Life OS.md</code>, <code>Goals.md</code>, <code>My rules in life.md</code>, <code>Lessons.md</code>, <code>Wins.md</code>), the detail-hierarchy rule, and when to invoke specialized advisory agents like <code>startup-coach</code> or <code>startup-legal-advisor</code> when the topic calls for them.</p>
<p>When the weekly-review cron runs at 03:45 on Sunday, it isn&#39;t firing a fixed prompt at an LLM. It&#39;s opening a Claude Code session scoped to my vault, with full filesystem access, with <code>CLAUDE.md</code> loaded, with my 2026 focus visible in <code>Goals.md</code>, with last quarter&#39;s narrative sitting in <code>Self Reflection/Quarterly/</code>. It knows the shape of a weekly review because it has read the template. It knows what not to include because it has read the detail-hierarchy rule. Then it writes the file.</p>
<p>The payoff is iteration cost. If a weekly review feels off, I don&#39;t deploy code. I edit the <code>life-coach</code> agent&#39;s markdown, or tweak a line in my vault&#39;s <code>CLAUDE.md</code>, or adjust the template. The next Sunday&#39;s cron run uses the new version automatically. The system has no release cycle. Every night is a hot reload.</p>
<p>The detail hierarchy is the organizing idea:</p>
<pre data-lang="code"><code>Daily     -&gt; raw log, specific items, one-off errands
Weekly    -&gt; grouped themes, 3-5 highlights, pattern-spotting
Quarterly -&gt; narrative arcs, zero line items, direction and drift</code></pre><p>Each level summarizes the one below. Detail never flows upward. A PR number belongs in the daily log and nowhere else. A quarterly review reads like a letter from your future self about the shape of the last three months.</p>
<hr>
<h2 id="reliability-is-not-optional">Reliability Is Not Optional</h2>
<p>This is all worthless the day I lose the vault.</p>
<p>Local SSD backups every night at 02:00, 14-day retention. Every Sunday, the same tarball gets GPG-encrypted with AES-256 and shipped to Cloudflare R2. The R2 bucket has a lifecycle rule: transition to Infrequent Access after 7 days, expire after 60.</p>
<p>The encryption passphrase lives in two places: the <code>.env</code> on the host, and a 1Password entry. If both disappear, the R2 backups are permanently unreadable. That&#39;s the design. Off-site backups that require no trust in Cloudflare.</p>
<p>Every backup run posts to Telegram:</p>
<ul>
<li>✅ <code>Local backup complete</code> with size and host</li>
<li>✅ <code>Local + R2 backup complete</code> with R2 object key</li>
<li>❌ <code>Host backup FAILED</code> with the specific reason</li>
</ul>
<p>No dashboard. No log aggregator. Just Telegram. If my phone doesn&#39;t buzz at 02:05 with a green checkmark, something broke.</p>
<hr>
<h2 id="why-this-works-when-app-based-journaling-didn39t">Why This Works When App-Based Journaling Didn&#39;t</h2>
<p>Every journaling tool I&#39;ve tried failed in week three. Obsidian with a beautiful template. Notion with elaborate databases. Apple Notes. Day One. Roam. All of them died the same way: the tool became more effort than the thought was worth.</p>
<p>Telegram isn&#39;t optional. It&#39;s open on my phone and laptop all day. The bot is one thread in a stack of conversations I&#39;m already scrolling. There&#39;s no <em>decision to journal</em>. There&#39;s a thought, there&#39;s a chat window already right there, and the friction between them is zero.</p>
<p>The vault is still the system of record. Real writing, linking notes, building out Permanent notes, running DataView queries. All of that happens in Obsidian when I&#39;m at my desk.</p>
<p>Telegram is for the daily log. The thought at a train station. The reminder while I&#39;m watering flowers in the yard. The errand that popped into my head walking back from the store. My day doesn&#39;t happen behind a laptop, and those are exactly the moments where a note would have died on the walk home.</p>
<p>The bot catches them because capture is one gesture: press the button, talk. It&#39;s a walkie-talkie with an AI on the other end. No app switch, no typing, no decision about where the thought belongs. Press, talk, tap Today, back to whatever I was doing.</p>
<hr>
<h2 id="steal-this">Steal This</h2>
<p>The pieces are individually boring. The combination works.</p>
<ul>
<li><strong>Put a Telegram bot in front of your vault.</strong> Any stack works: Go, Python, Node. The hard part isn&#39;t the bot. It&#39;s deciding you deserve zero-friction capture.</li>
<li><strong>Write a strict system prompt.</strong> Fixed section schema. Never-drop rule. Group-related rule. Spend more time on the prompt than on the code.</li>
<li><strong>Merge, don&#39;t regenerate.</strong> Load existing output. Append raw input. Only transform the new part. Stability is a feature.</li>
<li><strong>Let cron do the second draft.</strong> Weekly synthesis, quarterly narrative, morning briefing. You don&#39;t need to review. You just need the pipeline.</li>
<li><strong>Let Telegram be the whole interface.</strong> Capture goes in, text or voice. Briefings, reviews, and alerts come out. One chat, both directions. The capture channel and the feedback channel being the same channel is what keeps the whole system in one context.</li>
</ul>
<p>None of this is clever. It&#39;s a couple hundred lines of shell, a small Go service, and a prompt I&#39;ve iterated on more than the code. The bar for &quot;it works&quot; is low. The bar for &quot;I still use it in month six&quot; is high. This one cleared it.</p>
<p>I still open Obsidian every day. Just not to journal. The vault is the healthiest it&#39;s been.</p>
]]></content:encoded>
    </item>
    <item>
      <title>AI;DR — When Pasting Raw AI Output Becomes a Team Problem</title>
      <link>https://moj.dev/blog/ai-dr/</link>
      <guid isPermaLink="true">https://moj.dev/blog/ai-dr/</guid>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <category>Engineering</category>
      <description>A colleague replied to an AI-polished message with two words: &quot;AI;DR.&quot; If slang forms organically around a behavior, the behavior is already a problem.</description>
      <content:encoded><![CDATA[<p>A colleague recently replied to a long, LLM-polished technical message with two words: <strong>&quot;AI;DR.&quot;</strong></p>
<p>It&#39;s a riff on TL;DR, and it&#39;s the most honest feedback I&#39;ve seen on a growing team habit: pasting raw AI output into cross-team chat. The joke landed because everyone recognized the shape of the thing. If a new piece of slang forms organically around a behavior, the behavior is already a problem.</p>
<p>This isn&#39;t an anti-AI post. I use AI every day. This is about a specific failure mode: <strong>AI that makes the writer&#39;s job easier by making the reader&#39;s job harder.</strong></p>
<h2 id="two-shapes-of-the-problem">Two shapes of the problem</h2>
<h3 id="1-ai-replacing-quoti39m-stuckquot">1. AI replacing &quot;I&#39;m stuck&quot;</h3>
<p>A developer hits a 401 on an internal reporting API. Instead of pinging the owner with <em>&quot;I&#39;m getting 401 on every call, here&#39;s the error - any idea?&quot;</em>, they paste an 800-word AI-generated root cause analysis into the channel:</p>
<blockquote>
<p>Every call to the <code>reporting-internal</code> API returns HTTP 401 at the gateway, even though the OAuth handshake succeeds.</p>
<p><strong>Layer analysis:</strong></p>
<ol>
<li><strong>OAuth / token issuance</strong> - OK. The token is minted; login completes cleanly.</li>
<li><strong>API gateway routing</strong> - OK. We get a structured JSON error envelope, not a transport-level error.</li>
<li><strong>Scope enforcement</strong> - FAILS. The gateway evaluates the authenticated principal against a scope literally named <code>reporting</code>. The principal does not carry that scope.</li>
</ol>
<p><strong>What this tells us about the backend design:</strong> There is a dedicated scope gating all reporting calls, implying scope enforcement at a gateway middleware layer rather than per-endpoint. Identity resolves correctly, so the bearer token is accepted and mapped to a user - the mapping is just missing the scope grant.</p>
<p><strong>Suggested follow-up:</strong> (1) Grant the scope. (2) Document token refresh semantics. (3) Improve the error envelope with a <code>code</code> field. (4) Add a pre-flight <code>/capabilities</code> endpoint. (5) Audit scope granularity - <code>reporting</code> is coarse; <code>reporting:read</code> vs <code>reporting:write</code> would be healthier.</p>
</blockquote>
<p>The actual information the owner needed was one line:</p>
<blockquote>
<p>Getting 401 on every <code>reporting-internal</code> call - error says &quot;token does not carry the reporting scope.&quot; Can you grant it? Username <code>alex</code>.</p>
</blockquote>
<p>The AI output wasn&#39;t wrong. It was 790 words of scaffolding around a 10-word problem, and the owner had to read all of it to confirm the 10 words were in there somewhere.</p>
<h3 id="2-ai-replacing-quotcan-you-helpquot">2. AI replacing &quot;can you help?&quot;</h3>
<p>Worse variant: someone from outside the technical domain pastes a step-by-step &quot;fix&quot; for a problem they don&#39;t own.</p>
<blockquote>
<p><strong>ISSUE:</strong> The billing export uses &quot;delegated tokens&quot; - these are tokens obtained per user via OAuth. My token can only export as me, not as the finance team. The finance service account has never completed the OAuth flow, so there is no token for it.</p>
<p><strong>STRUCTURAL FIX (already deployed):</strong></p>
<ol>
<li>Export route -&gt; now uses <code>getServiceAccountId()</code> (impersonation-aware)</li>
<li><strong>New: Client Credentials Flow added</strong> - an app-level token that allows the platform to export on behalf of ANY user in the tenant, without each user completing OAuth individually.</li>
<li>Fallback chain: per-user token -&gt; legacy token -&gt; client credentials token.</li>
</ol>
<p><strong>HOWEVER:</strong> this only works if the platform app has the correct permissions. In the IdP admin console: App registrations -&gt; select the Billing app -&gt; API permissions -&gt; Add a permission -&gt; Finance Graph -&gt; Application permissions -&gt; search <code>Export.ReadWrite</code> -&gt; Add -&gt; &quot;Grant admin consent&quot; (important!).</p>
<p>This is a one-time setup. Once done, the platform can export on behalf of any user.</p>
</blockquote>
<p>The reply, from an engineer:</p>
<blockquote>
<p><strong>AI;DR;</strong></p>
</blockquote>
<p>Now the engineer has to reverse-engineer two things: what the actual issue is, and which parts of the AI&#39;s confident prescription are real vs. hallucinated. The claim <em>&quot;already deployed&quot;</em> is doing huge trust-work: was it? By whom? Tested how? The sender didn&#39;t know. The AI didn&#39;t know either. But the output <em>reads</em> like it does, and that confidence is the problem.</p>
<p>What the message should have been:</p>
<blockquote>
<p>Billing export is failing for the finance team - they can&#39;t pull the April file. Can someone take a look? Happy to share my screen.</p>
</blockquote>
<h2 id="why-this-specifically-hurts-team-communication">Why this specifically hurts team communication</h2>
<p>The case against pasting AI output into team chat isn&#39;t that AI is bad. It&#39;s that:</p>
<ul>
<li><strong>Length is no longer a signal of effort.</strong> A 1000-word message used to mean someone thought hard. Now it might mean they pressed a button. Readers can&#39;t tell, so they treat all long messages as suspect - which penalizes people who did think hard.</li>
<li><strong>Confidence is no longer a signal of expertise.</strong> AI writes with the voice of authority by default. A non-technical person pasting AI output sounds like an architect. The reader has to do the expertise check the sender skipped.</li>
<li><strong>Assumptions get laundered into facts.</strong> The AI fills gaps with plausible guesses. Those guesses arrive in your chat looking identical to the parts that are true. Separating them is the reader&#39;s job now.</li>
<li><strong>The bias is toward more output, not better output.</strong> AI lowers the cost of writing, not the cost of reading. Team chat runs on a shared attention budget, and unfiltered AI output is a tragedy of the commons.</li>
</ul>
<h2 id="a-simple-rule">A simple rule</h2>
<p>Before pasting AI output into team chat, ask: <em>would I have written this much if I had to type it?</em></p>
<p>If no, cut it to what you would have typed. The AI can help you <em>think</em>; it shouldn&#39;t be the voice you send on your behalf.</p>
<p>For technical problems specifically: the useful message is almost always <strong>a clear description of the problem, not a proposed solution.</strong> The investigation belongs to the person who owns the system. Your job is to describe what you observed, not to diagnose it for them.</p>
<h2 id="what-to-do-instead">What to do instead</h2>
<ul>
<li><strong>Use AI as a personal thinking tool.</strong> Draft, rubber-duck, summarize for yourself. Then write the message in your own voice.</li>
<li><strong>Send the problem, not the hypothesis.</strong> <em>&quot;401 on every call&quot;</em> beats <em>&quot;here&#39;s my analysis of the backend&#39;s scope enforcement layer.&quot;</em></li>
<li><strong>If the AI output is genuinely the message, say so.</strong> <em>&quot;AI helped me draft this, flagging the parts I&#39;m unsure about: [...]&quot;</em> is honest. Pasting it unmarked is not.</li>
<li><strong>Cut 80%.</strong> The useful signal is almost always in the first two sentences.</li>
</ul>
<h2 id="closing">Closing</h2>
<p>The pushback is already cultural. &quot;AI;DR&quot; works as a reply because everyone has spent the last few months skimming AI-polished paragraphs for the 10% that&#39;s actually relevant. That&#39;s the signal.</p>
<p>AI is a power tool. Point it at your own thinking and it makes you sharper. Point it at your teammates&#39; inboxes and it makes you the reason someone replies &quot;AI;DR.&quot;</p>
<p>Keep it human, keep it short, keep it yours.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Bridging the Gap: How to Align Engineering and Product Teams</title>
      <link>https://moj.dev/blog/bridging-the-gap/</link>
      <guid isPermaLink="true">https://moj.dev/blog/bridging-the-gap/</guid>
      <pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate>
      <category>Leadership</category>
      <description>Engineering and product teams think differently, and that friction is structural, not personal. Here's how to close the gap from someone who's been on both sides.</description>
      <content:encoded><![CDATA[<p>If you&#39;ve spent time on an engineering team, you&#39;ve heard it: <em>&quot;What is this meeting? Why was I invited? I want to skip it and focus on task X.&quot;</em> Honestly, I&#39;ve thought the same. Sometimes engineers don&#39;t see the value in business or product discussions. Other times it&#39;s just the engineering mindset, heads down, focused on the work.</p>
<p>And sometimes we&#39;re right. We already know how the meeting will go before it starts.</p>
<p>But as senior engineers or leads, we have to hold both perspectives at once. It&#39;s not about who understands more. Business and engineering teams think differently, and both ways of thinking are necessary. The gap between them isn&#39;t a personality problem. It&#39;s a structural one, and it can be closed.</p>
<p>From my own experience leading teams and running a company, I&#39;ve learned that the best outcomes come from engineering teams that understand the product and product teams that understand the tech. The closer those two worlds get, the faster and better everything moves.</p>
<p><img src="https://moj.dev/blog/attachments/team-meeting.jpg" alt="A cross-functional team working together" loading="lazy"></p>
<h2 id="1-understand-that-the-gap-is-real">1. Understand That the Gap Is Real</h2>
<p>Engineers focus on correctness, scalability, and technical craft. Product and business teams focus on customer needs, market fit, and shipping. Neither is wrong; they&#39;re optimizing for different things.</p>
<p>The friction shows up in predictable places: an engineer prioritizes paying down technical debt while a PM wants the next feature out the door. Acknowledging this tension honestly is the first step. You can&#39;t close a gap you pretend doesn&#39;t exist.</p>
<h2 id="2-open-communication-before-decisions-are-made">2. Open Communication Before Decisions Are Made</h2>
<p>The most common mistake: engineers get looped in after product decisions are already locked. By then, their input feels like obstacle-raising rather than collaboration.</p>
<p>Bring engineers into conversations early, when discussing customer pain points, scoping new features, or setting timelines. They&#39;ll surface technical constraints before they become blockers, and they&#39;ll feel like owners rather than implementers.</p>
<p>On the other side: engineers who stay in their technical bubble miss context that would change how they build. Regular check-ins, shared channels, joint planning sessions. These aren&#39;t bureaucracy. They&#39;re the communication infrastructure that makes good decisions possible.</p>
<h2 id="3-align-on-the-actual-goal">3. Align on the Actual Goal</h2>
<p>Both teams need to understand what the company is trying to achieve, not just their slice of it. Is this quarter about retention? Scale? Entering a new market? When everyone knows the goal, they can make better local decisions without escalating everything.</p>
<p>Engineers involved early in product development can flag feasibility issues before they&#39;re expensive. Product teams who understand technical constraints can set more realistic expectations. Neither side needs to become experts in the other&#39;s domain; they just need enough shared context to collaborate without friction.</p>
<h2 id="4-build-feedback-loops">4. Build Feedback Loops</h2>
<p>Engineers who see how their work is used in production develop instincts that product teams can&#39;t replicate. Get them into customer feedback sessions, user testing, or even occasional direct customer contact. It changes how they think about what they&#39;re building.</p>
<p>Product teams should also share what they&#39;re hearing from users before it&#39;s been formalized into a spec. The informal signal, <em>&quot;customers keep asking about X&quot;</em>, is valuable input, not just the polished requirements document.</p>
<h2 id="5-work-as-one-team-not-two">5. Work as One Team, Not Two</h2>
<p>Cross-functional teams where engineers and PMs share the same goals and cadence consistently outperform siloed ones. Not because everyone does everyone else&#39;s job, but because shared ownership changes how people show up. They ask different questions in sprint planning. They catch problems earlier. They trust each other more.</p>
<p>This doesn&#39;t require a reorg. It requires shared workspaces, shared tools, and shared objectives.</p>
<h2 id="6-keep-iterating-on-the-collaboration-itself">6. Keep Iterating on the Collaboration Itself</h2>
<p>The gap doesn&#39;t close once and stay closed. Teams change, priorities shift, new people join. The collaboration process needs the same continuous improvement you&#39;d apply to the product.</p>
<p>Regular knowledge-sharing sessions help: engineers presenting technical changes and constraints, product sharing market trends and user feedback. The goal isn&#39;t information transfer. It&#39;s building mutual empathy so both sides understand the pressures the other is working under.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The engineering-product gap is a structural problem with structural solutions. Open communication, early involvement, shared goals, feedback loops, and genuine cross-functional ownership. These aren&#39;t soft skills. They&#39;re the infrastructure that lets good teams do their best work.</p>
<p>When both sides are aligned, you move faster, build better, and waste far less time on friction that didn&#39;t need to exist.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Why Every Professional Needs a Brag Document</title>
      <link>https://moj.dev/blog/brag-document/</link>
      <guid isPermaLink="true">https://moj.dev/blog/brag-document/</guid>
      <pubDate>Fri, 12 Sep 2025 00:00:00 GMT</pubDate>
      <category>Career</category>
      <description>In the fast-paced world of modern industries, it's easy for valuable work and significant contributions to slip through the cracks. Your manager is human — here's how to fix that.</description>
      <content:encoded><![CDATA[<p>In the fast-paced world of modern industries, it&#39;s easy for valuable work and significant contributions to slip through the cracks. While we might hope that our efforts will be noticed, the reality is that managers, peers, and even ourselves sometimes fail to shine a light on key accomplishments.</p>
<p>Let&#39;s face it — your manager likely has their hands full with an endless to-do list, juggling meetings, decision-making, and a myriad of other responsibilities. Be realistic: you&#39;re not the only cog in the wheel that deserves their attention. They&#39;re human, with finite bandwidth that needs to be used wisely. In the grand scheme of a large organization, you&#39;re just one piece of a much bigger puzzle.</p>
<p>It&#39;s not solely your manager&#39;s responsibility to keep tabs on everything you&#39;re doing. It&#39;s also up to you to make it easy for them to stay informed. Having a clear and accessible record of your efforts helps refresh their memory and ensures they can easily recognize the value you bring to the table.</p>
<h2 id="what-is-a-brag-document">What is a Brag Document?</h2>
<p>A brag document is a living record where you track all of your work-related achievements and milestones. It includes major projects, key contributions, metrics, and feedback from colleagues or managers. This tool ensures that when performance reviews, promotions, or job interviews come around, you have a comprehensive list of your accomplishments — without relying on your memory or, more realistically, on your overworked manager&#39;s memory!</p>
<p>In today&#39;s ever-evolving professional landscape, a brag document can be more than just a list of wins. It&#39;s a chance to showcase your ongoing contributions to company culture, mentorship, innovation, and strategic improvements — areas that are increasingly valued in modern tech and corporate environments.</p>
<h2 id="why-you-should-keep-a-brag-document">Why You Should Keep a Brag Document</h2>
<h3 id="demonstrates-your-impact">Demonstrates Your Impact</h3>
<p>A brag document highlights specific contributions, like product launches or efficiency gains, making it easier to showcase how you&#39;ve solved problems and stood out in a fast-paced industry.</p>
<h3 id="supports-career-growth-and-promotions">Supports Career Growth and Promotions</h3>
<p>Tracking achievements in a brag document shows your growth and progress, providing solid evidence for promotions and raises, while highlighting leadership and cross-team contributions.</p>
<h3 id="makes-performance-reviews-easier">Makes Performance Reviews Easier</h3>
<p>A brag document simplifies performance reviews by summarizing your work and helping managers evaluate your contributions more effectively, especially in fast-moving industries.</p>
<h3 id="facilitates-transparency-and-peer-recognition">Facilitates Transparency and Peer Recognition</h3>
<p>Sharing your brag document with peers allows for valuable feedback, increasing visibility and fostering transparency in collaborative work environments.</p>
<h3 id="builds-confidence-and-provides-clarity">Builds Confidence and Provides Clarity</h3>
<p>Regularly updating a brag document helps you reflect on your accomplishments, boosting confidence and offering clarity on areas to focus on, especially during career transitions.</p>
<h2 id="how-to-create-your-brag-document">How to Create Your Brag Document</h2>
<h3 id="keep-it-up-to-date">Keep It Up-to-Date</h3>
<p>Regularly update your brag document to capture recent achievements. Set aside time monthly or quarterly to reflect on your work, ensuring you don&#39;t miss important details when it&#39;s time for reviews or applications.</p>
<h3 id="be-specific-and-add-data">Be Specific and Add Data</h3>
<p>Detail your accomplishments with measurable outcomes, such as &quot;Reduced server response time by 25%.&quot; Back them up with data like improved performance, higher revenue, or customer satisfaction, as numbers add weight to your achievements.</p>
<h3 id="cover-a-range-of-contributions">Cover a Range of Contributions</h3>
<p>Include both technical and soft skills — leadership, communication, mentoring — alongside measurable successes. Modern industries value holistic contributions, so showcasing these broader impacts is essential.</p>
<h3 id="don39t-be-modest">Don&#39;t Be Modest</h3>
<p>A brag document is your opportunity to confidently highlight your value. Recognize and take pride in your achievements; it&#39;s a key tool for career advancement.</p>
<h2 id="best-practices-for-maintaining-a-brag-document-in-modern-industries">Best Practices for Maintaining a Brag Document in Modern Industries</h2>
<h3 id="use-tools-that-work-for-you">Use Tools That Work for You</h3>
<p>Choose a platform like Google Docs or Notion that&#39;s easy to access and update. Modern tools allow for seamless sharing and collaboration when needed.</p>
<h3 id="review-and-reflect-regularly">Review and Reflect Regularly</h3>
<p>Set aside time monthly or bi-weekly to review your brag document. Reflect on areas of improvement, and adjust your focus — whether that&#39;s balancing technical achievements with leadership or team-building.</p>
<h3 id="share-when-appropriate">Share When Appropriate</h3>
<p>While personal, don&#39;t hesitate to share parts of your brag document with your manager or peers. This transparency fosters discussions about your career goals and helps guide your next steps.</p>
<h2 id="my-approach">My Approach</h2>
<p>For nearly four years, I&#39;ve made it a habit to take daily notes, covering both work and personal life. Each day, I spend just five minutes reviewing the previous day, remembering important events, checking emails, and reviewing Slack/Teams messages. I summarize everything in bullet points. For work-related items, I use the <code>#brag</code> tag to highlight accomplishments that I want to track.</p>
<p>Monthly, I reflect on my brag document by using the <code>$brag</code> tag to quickly review what&#39;s happened recently. This practice helps me update my brag document with new progress, keeping it on track and ensuring it remains current and reflective of my ongoing achievements.</p>
<blockquote>
<p><strong>Anecdotal Insight:</strong> Throughout my career, I&#39;ve often advised colleagues to create a brag document to track their achievements. Unfortunately, many chose not to follow this advice and struggled during performance reviews and 1-on-1s. However, those who took the time to build and maintain a brag document have seen noticeable improvements in how they articulate their contributions, leading to more productive and successful reviews.</p>
</blockquote>
<h2 id="conclusion">Conclusion</h2>
<p>A brag document is more than just a list of accomplishments; it&#39;s a strategic tool for personal growth, career development, and professional recognition. It helps you advocate for yourself in a competitive, fast-moving industry and ensures that your achievements are properly acknowledged. Start building yours today to stay ahead and make sure your hard work never goes unnoticed.</p>
]]></content:encoded>
    </item>
    <item>
      <title>From Cloud Costs to Home Lab</title>
      <link>https://moj.dev/blog/home-lab/</link>
      <guid isPermaLink="true">https://moj.dev/blog/home-lab/</guid>
      <pubDate>Fri, 12 Sep 2025 00:00:00 GMT</pubDate>
      <category>Hobbies</category>
      <description>A $34 Railway invoice landed in my inbox. Not a big bill, but enough to ask: why rent cloud cycles when I've got unused hardware at home? Here's how I built a zero-bill self-hosted stack.</description>
      <content:encoded><![CDATA[<p>Last week, a $34 Railway invoice landed in my inbox for a couple of side projects. Not a big bill, but enough to make me ask: why rent cloud cycles when I&#39;ve got unused hardware at home?</p>
<p>Sitting in my utility room was an Intel NUC 11 Pro Kit with an i7, 32GB RAM, and a 1TB SSD. Instead of paying for convenience, I decided to wake it up and reclaim my infrastructure.</p>
<hr>
<h2 id="the-spark-that-invoice-and-the-nuc-resurrection">The Spark: That Invoice and the NUC Resurrection</h2>
<p>I flashed Ubuntu Server 22.04 onto a USB stick, booted the NUC, wired it to my router, and stashed it in the utility room. Total time: under an hour. Cost: zero.</p>
<p>Lesson I share with juniors: <strong>audit before you architect</strong>. That old box might be all you need.</p>
<hr>
<h2 id="enter-coolify-self-hosting-without-the-pain">Enter Coolify: Self-Hosting Without the Pain</h2>
<p>I didn&#39;t want Kubernetes on a single node. Too much overhead. Instead, I found <a href="https://coolify.io/" target="_blank" rel="noopener">Coolify</a>, an open-source, self-hosted Heroku alternative. Connect it to GitHub, push code, and it builds and deploys Docker containers automatically.</p>
<p>Installation was one command over SSH. Minutes later, I had a dashboard running on my NUC&#39;s local IP. My first deploy — a small API — was live in seconds. From there it snowballed: automated CI/CD, no cloud tax, no YAML hell.</p>
<hr>
<h2 id="solving-dynamic-ips-with-cloudflare-ddns">Solving Dynamic IPs with Cloudflare DDNS</h2>
<p>Home labs break when your ISP changes IPs. Instead of paying for DDNS, I ran a <a href="https://github.com/timothymiller/cloudflare-ddns" target="_blank" rel="noopener">Cloudflare DDNS</a> container through Coolify. It updates DNS records automatically using a lightweight Python script.</p>
<p>Config took minutes: create a Cloudflare token, drop it in a JSON config, and let the container run every 15 minutes. Now my services stay reachable at <code>api.mydomain.com</code> or <code>ha.mydomain.com</code> without static IPs or manual tweaks.</p>
<hr>
<h2 id="migrating-services-home-assistant-and-pi-hole">Migrating Services: Home Assistant and Pi-hole</h2>
<p>With the backbone ready, I pulled in existing services:</p>
<ul>
<li><a href="https://www.home-assistant.io/" target="_blank" rel="noopener">Home Assistant</a> for smart home orchestration</li>
<li><a href="https://pi-hole.net/" target="_blank" rel="noopener">Pi-hole</a> for network-wide ad blocking</li>
<li><a href="https://github.com/amir20/dozzle" target="_blank" rel="noopener">Dozzle</a> for monitoring Docker logs</li>
</ul>
<p>All services run as Docker containers in Coolify. For routing, I used the built-in Caddy reverse proxy, which maps subdomains to local ports and handles SSL automatically.</p>
<hr>
<h2 id="power-proofing-with-a-ups">Power-Proofing with a UPS</h2>
<p>Dutch grids are reliable, but outages happen. I added a small UPS to keep the NUC and router alive for 15–30 minutes — enough to ride out blips or shut down cleanly. Cheap insurance for reliability.</p>
<hr>
<h2 id="the-payoff-zero-bills-infinite-experiments">The Payoff: Zero Bills, Infinite Experiments</h2>
<p>A week later:</p>
<ul>
<li>Cloud costs: <strong>gone</strong></li>
<li>Deploys: <strong>instant</strong></li>
<li>Services: <strong>stable</strong></li>
<li>Mental bandwidth: <strong>free for real work</strong></li>
</ul>
<p>I can spin up experiments without guilt. This isn&#39;t just a home lab — it&#39;s a manifesto. In a world of ballooning cloud bills, <strong>owning your stack is freedom</strong>. Cloud has its place, but if you&#39;re prototyping or tinkering, you don&#39;t need to rent convenience.</p>
]]></content:encoded>
    </item>
    <item>
      <title>My Journey Through Home-Buying in the Netherlands</title>
      <link>https://moj.dev/blog/buying-home-netherlands/</link>
      <guid isPermaLink="true">https://moj.dev/blog/buying-home-netherlands/</guid>
      <pubDate>Thu, 01 Aug 2024 00:00:00 GMT</pubDate>
      <category>Lifestyle</category>
      <description>Buying a house in the Netherlands as a newcomer is a maze — sealed bids, Makelaars, bank guarantees, and notary days. Here's everything I wish I'd known before starting.</description>
      <content:encoded><![CDATA[<p>Given the lack of a comprehensive guide on the home-buying process — with information scattered across Reddit and other platforms — I decided to write this article based on my own experience.</p>
<p>Keep in mind that buying a house is often a lengthy, time-consuming, and challenging process, especially in the Netherlands. Everyone&#39;s experience can vary. The points I discuss aren&#39;t definitive and may differ based on your circumstances, but they provide a solid overview of what to expect.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>At the outset, evaluate your financial situation. Can you cover the various costs associated with buying a house — fees for consultants, notaries, and other expenses? Beyond the mortgage itself, you&#39;ll need approximately <strong>€7,000 to €10,000</strong> depending on your circumstances (I&#39;ve detailed these costs at the end).</p>
<blockquote>
<p>This amount covers incidental purchase costs and is separate from any overbid amount.</p>
</blockquote>
<h2 id="permanent-contract">Permanent Contract</h2>
<p>The most important factor in securing a mortgage is having a permanent contract. You either need a permanent employment contract, or a &quot;Declaration of Intent&quot; letter from your employer indicating they plan to renew your contract.</p>
<blockquote>
<p>In the Netherlands, a permanent contract offers significant job security — the employer can rarely terminate it except under very special circumstances. Banks value this stability highly.</p>
</blockquote>
<ul>
<li>A declaration of intent can reduce the loan amount, so a permanent contract is more advantageous.</li>
<li>For couples, it&#39;s sufficient for one person to have a permanent contract. If both do, the loan amount increases accordingly.</li>
</ul>
<h2 id="checking-your-loan-range">Checking Your Loan Range</h2>
<p>Dutch home mortgages typically span 30 years. To estimate your potential loan, use an online mortgage calculator — the <a href="https://www.abnamro.nl/en/personal/mortgages/calculating-your-maximum-mortgage.html" target="_blank" rel="noopener">ABN Bank calculator</a> is one of the simplest and most reliable. Enter your details and get an approximate figure accurate to within 1–2%.</p>
<p>Your loan amount can also increase based on the energy label of the house, thanks to government subsidies for higher energy efficiency. For example, if your maximum loan is €500K and the house has an A+ label, your loan could increase to €520K.</p>
<p><img src="https://moj.dev/blog/attachments/SampleEnergyLabel.png" alt="Sample energy label" loading="lazy"></p>
<h2 id="mortgage-advisor">Mortgage Advisor</h2>
<p>A mortgage advisor guides you through the entire process — preparing documents, applying for a loan, securing the bank guarantee, and more. Since all documents are in Dutch, they handle correspondence on your behalf.</p>
<p><strong>Having a mortgage advisor in the Netherlands is essential.</strong></p>
<h3 id="bank-affiliated-vs-independent">Bank-Affiliated vs. Independent</h3>
<ul>
<li><strong>Bank-affiliated advisors</strong> work for a specific bank. They&#39;ll help you secure a mortgage from that bank only.</li>
<li><strong>Independent advisors</strong> aren&#39;t tied to any single bank. They can shop around for better rates and terms.</li>
</ul>
<p>The fee is legally regulated and fixed at roughly <strong>€2,500–€3,000</strong> regardless of which type you choose.</p>
<p>I went with an independent advisor — and it paid off. The first bank they applied to denied our loan citing a three-year residency requirement I hadn&#39;t heard of. My advisor immediately applied to another bank, which approved without issue. That flexibility is the real advantage.</p>
<h3 id="initial-consultation">Initial Consultation</h3>
<p>Most advisors offer one or two free initial sessions to assess your situation before you sign a contract. After that, they&#39;ll request documents including:</p>
<ol>
<li>Employer statement (contract type, income, holiday allowance)</li>
<li>Latest payslip</li>
<li>Passport and residence card</li>
<li>UWV report (obtainable via DigiD)</li>
<li>Declaration of debts and savings</li>
</ol>
<h2 id="finding-a-house">Finding a House</h2>
<p>The main platform is <strong>Funda</strong> — all properties eventually end up there. Buying here is fundamentally different from many other countries. Coming from Iran, I was used to visiting an agent, negotiating, and meeting somewhere in the middle. In the Netherlands, houses are publicly listed and go through a <strong>single-round sealed bid process</strong>. No back-and-forth negotiation.</p>
<h2 id="real-estate-agent-makelaar">Real Estate Agent (Makelaar)</h2>
<p>A <em>Makelaar</em> accompanies you on viewings, inspects the property, and — crucially — determines the <strong>real market value</strong> of a house. This protects you from overbidding out of desperation.</p>
<blockquote>
<p>A colleague was bidding on a house listed at €350K. His agent discovered a comparable property nearby had sold for €340K the month before. They underbid, and he bought it for €340K — below asking price.</p>
</blockquote>
<p>Broker fees vary: some charge a fixed fee (~€4,000), others charge ~1% of the house price.</p>
<p><strong>Should you hire one?</strong> If you&#39;re new to the Netherlands, don&#39;t speak Dutch fluently, and lack a local network — yes, seriously consider it. I&#39;ve seen people spend months searching on their own without success. The agent&#39;s network and market knowledge can make the difference.</p>
<h3 id="finding-a-broker">Finding a Broker</h3>
<ol>
<li><strong>Personal recommendations</strong> — most reliable</li>
<li><strong>Search on <a href="https://www.funda.nl/" target="_blank" rel="noopener">Funda</a></strong> — includes ratings and reviews</li>
</ol>
<p><img src="https://moj.dev/blog/attachments/Funda.png" alt="Funda broker search" loading="lazy"></p>
<p>Broker contracts typically include 10–12 property visits. Beyond that, expect an extra €50–60 per visit.</p>
<h2 id="visiting-a-house">Visiting a House</h2>
<p>Before using one of your contracted broker visits, scout the area yourself first. Photos are staged to look their best — the neighborhood might not match your expectations. Save your visits for properties you&#39;re genuinely interested in.</p>
<p>On platforms like move.nl, you can request detailed property data: year of construction, materials, energy costs, heating systems, insulation, and more. Use this before committing to a viewing.</p>
<h2 id="bidding">Bidding</h2>
<p>Every house in the Netherlands must be publicly listed and sold through a <strong>single-round bid</strong>. When you visit, ask: <em>&quot;When is the bid day?&quot;</em> The agent will give you a deadline — e.g., &quot;Monday at noon.&quot;</p>
<p>Your bid includes:</p>
<ul>
<li><strong>Proposed price</strong> — what you&#39;re willing to pay</li>
<li><strong>Mortgage dependency</strong> — how much you&#39;re financing</li>
<li><strong>Personal savings</strong> — how much you&#39;re covering yourself</li>
</ul>
<p>Example: <em>&quot;I want this house for €500K — €480K mortgage, €20K savings.&quot;</em></p>
<h3 id="listing-price-vs-actual-value">Listing Price vs. Actual Value</h3>
<p>The <strong>listing price</strong> on Funda is typically set ~10% below actual market value to generate bidding competition. Don&#39;t treat it as the real price.</p>
<p>The <strong>home valuation price</strong> is what the bank&#39;s appraiser determines. The bank lends based on this number, not your bid. If you bid €480K but the bank values the house at €460K, they&#39;ll only lend €460K — the gap comes from your pocket.</p>
<h3 id="why-savings-matter">Why Savings Matter</h3>
<p>If you bid €480K relying entirely on a mortgage, and the bank only approves €470K, your bid looks risky to the seller. But if you have €15K in savings and bid €480K as €465K loan + €15K savings, the seller sees a more secure offer — even if the valuation comes in slightly lower than your bid.</p>
<p>If the valuation ends up <em>higher</em> than expected, you&#39;ll need less from savings. Small wins like this happen.</p>
<h3 id="bid-results">Bid Results</h3>
<p>Results aren&#39;t communicated proactively — if you don&#39;t hear back, your bid wasn&#39;t selected. Once accepted, the house status on Funda changes to <em>&quot;Under offer.&quot;</em></p>
<p><img src="https://moj.dev/blog/attachments/SampleUnderOffer.png" alt="Under offer status on Funda" loading="lazy"></p>
<blockquote>
<p>After two consecutive rejections on different houses, our broker called with surprising news: we were second choice on <em>both</em>. The first bidders had their loans rejected, and the sellers turned to us. On the same day, we had offers accepted on two houses and got to choose which one we wanted. I think that&#39;s a Guinness record for the Netherlands. 😄</p>
</blockquote>
<h2 id="technical-inspection">Technical Inspection</h2>
<p>Optional, but recommended for older houses. A specialist inspects the property and provides a detailed report categorizing costs into:</p>
<ol>
<li><strong>Short-term</strong> — immediate fixes (e.g., €1,400 for specific repairs)</li>
<li><strong>Mid-term</strong> — issues to address within a few years</li>
<li><strong>Improvement</strong> — optional upgrades</li>
</ol>
<p>Use short-term costs to negotiate a price reduction or ask the seller to fix issues before closing.</p>
<p><strong>Check especially for:</strong> foundation condition, moisture under the floor, pests in wooden structures, sewer system integrity.</p>
<p>Cost: ~€500.</p>
<h2 id="pre-sale-agreement">Pre-Sale Agreement</h2>
<p>Once both parties agree, an electronic pre-sale agreement is signed by buyer, seller, and both brokers. The house status changes to <em>&quot;Sold under reservation.&quot;</em> The sale is pending mortgage approval and deposit payment.</p>
<p><img src="https://moj.dev/blog/attachments/SampleUnderReservation.png" alt="Sold under reservation status" loading="lazy"></p>
<h2 id="notary">Notary</h2>
<p>The notary (arranged by the seller&#39;s agent) handles the final paperwork. They&#39;ll request documents from both parties and set three key dates:</p>
<ol>
<li><strong>Withdrawal deadline</strong> — last date you can legally back out (e.g., if the loan is rejected)</li>
<li><strong>Bank guarantee deadline</strong></li>
<li><strong>Notary date</strong> — when you sign in person, money is transferred, keys handed over</li>
</ol>
<p>A translator in your native language is mandatory on notary day — the notary arranges this in advance.</p>
<h2 id="bank-guarantee">Bank Guarantee</h2>
<p>To reassure the seller you won&#39;t back out, you must deposit 10% of the purchase price with the notary — or provide a bank guarantee for the same amount.</p>
<p>If you don&#39;t have that liquidity (common), intermediary companies issue a bank guarantee for ~0.1% of the property price. For a €500K house, that&#39;s ~€500. Your mortgage advisor handles this.</p>
<p>Once the guarantee is in place, the sale is final. Neither party can back out.</p>
<p><img src="https://moj.dev/blog/attachments/SampleSold.png" alt="Sold status on Funda" loading="lazy"></p>
<h2 id="cost-summary">Cost Summary</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Required</th>
<th>Approximate Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Mortgage advisor</td>
<td>Yes</td>
<td>€2,500–€3,000</td>
</tr>
<tr>
<td>Real estate agent (Makelaar)</td>
<td>No</td>
<td>~1% of house price</td>
</tr>
<tr>
<td>Property valuation</td>
<td>Yes</td>
<td>~€800</td>
</tr>
<tr>
<td>Technical inspection</td>
<td>No</td>
<td>~€500</td>
</tr>
<tr>
<td>Land registry</td>
<td>Yes</td>
<td>€300–€500</td>
</tr>
<tr>
<td>Bank guarantee</td>
<td>Yes</td>
<td>€300–€500</td>
</tr>
<tr>
<td>Translator (notary day)</td>
<td>Yes</td>
<td>~€400</td>
</tr>
<tr>
<td>Transfer fee</td>
<td>Yes</td>
<td>€40–€100</td>
</tr>
<tr>
<td>Incidental taxes</td>
<td>Yes</td>
<td>€100–€500</td>
</tr>
<tr>
<td>Transfer tax (2%)</td>
<td>Yes*</td>
<td>Exempt if under 35 and house &lt; €520K</td>
</tr>
</tbody></table>
<h2 id="a-few-final-notes">A Few Final Notes</h2>
<p>You don&#39;t pay anyone anything until the notary date — except for the technical inspection and home valuation, which are paid immediately after the service. All other fees are settled at the end.</p>
<p>One useful tool: <a href="https://www.wozwaardeloket.nl/" target="_blank" rel="noopener">WOZ Waardeloket</a> — enter any postal code to see the municipality&#39;s assessed value by year. It&#39;s a government site used for property tax estimates. Post-COVID price rises mean it often lags behind market reality, but it gives useful historical context.</p>
<p>Good luck. It&#39;s a process, but when you get those keys, it&#39;s worth it.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
