<?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>ikoonman.io</title>
    <link>https://ikoonman.io/</link>
    <description>Thoughts on software, systems, and curious ideas.</description>
    <language>en</language>
    <lastBuildDate>Fri, 11 Sep 2026 15:16:46 GMT</lastBuildDate>
    <atom:link href="https://ikoonman.io/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Do not over-engineer: keeping AI coding tools on task</title>
      <link>https://ikoonman.io/blog/do-not-over-engineer/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/do-not-over-engineer/</guid>
      <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
      <description>Why AI coding assistants tend to over-build, what it costs in tokens and deleted code, and the prompts that keep them delivering the change you actually asked for.</description>
      <category>AI</category>
      <category>Software Development</category>
      <category>Code Quality</category>
      <category>Prompting</category>
      <content:encoded><![CDATA[<p>Ask an AI coding assistant to add a retry to a single HTTP call and there is a fair chance you get back a <code>RetryPolicy</code> interface, three implementations of it, a <code>BackoffStrategy</code> enum, a configuration class, a factory, and a test suite for all of it. The retry works. It is also six files and two abstractions more than the problem needed. Nobody asked for a retry framework.</p>
<p>This is the most common failure mode in AI-assisted development, and it is not really a bug. It is the natural consequence of how these tools are built and how we prompt them. Worth understanding, because once you know why it happens you can prompt around it fairly reliably.</p>
<p>Over-building costs you in three separate currencies, and it is worth separating them up front, because the fixes differ:</p>
<ol>
<li><strong>Code you have to maintain</strong> — the extra abstractions, files and options that outlive the task.</li>
<li><strong>Tokens</strong> — the context, time and money burned producing and re-reading work nobody asked for.</li>
<li><strong>Code you have lost</strong> — the lines an agent quietly removed on its way to the change you did want, which is the one that bites hardest because it is invisible in the output and only shows up in the diff.</li>
</ol>
<p>The prompts in this post are written to be copied and adapted. Most of them are short.</p>
<h2 id="why-ai-tools-over-build" tabindex="-1"><a class="header-anchor" href="#why-ai-tools-over-build"><span>Why AI tools over-build</span></a></h2>
<p>A few forces push in the same direction.</p>
<p><strong>They are trained on published code.</strong> The material these models learned from is skewed towards libraries, frameworks, tutorials and well-regarded open source — code written to be reused by strangers, extended in unknown directions, and demonstrated in blog posts. That code is <em>correctly</em> full of interfaces, options and extension points. Your internal service handling one known case is not that code, but it looks superficially similar, so the same patterns come out.</p>
<p><strong>Helpfulness reads as thoroughness.</strong> A model optimised to be useful will tend to cover the cases you did not mention. You asked for a CSV parser; it handles quoted fields, BOM markers, custom delimiters and a streaming mode, because each of those is plausibly helpful. Each addition is defensible on its own. The sum is a small library you now own.</p>
<p><strong>They cannot feel the cost.</strong> A human developer who adds an abstraction pays for it later — in review, in debugging, in the next person’s onboarding. The model has no stake in the file six months from now. There is no friction pushing back against another layer.</p>
<p><strong>The prompt is usually underspecified.</strong> “Add caching to this” does not say where the boundary is. The model fills the gap with assumptions, and its default assumption is generality: make it configurable, make it swappable, make it work for cases you did not mention.</p>
<p><strong>Context is partial.</strong> The tool often cannot see that you already have a retry helper in <code>utils/http.py</code>, or that the project has a house convention for configuration. So it builds its own. Duplication is a form of bloat too, and it is the kind that tends to survive review, because the new code looks fine in isolation.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Vague request&quot;] --&gt; B[&quot;Model fills gaps with assumptions&quot;]
    B --&gt; C[&quot;Default assumption: make it general&quot;]
    C --&gt; D[&quot;Extra abstractions, options, files&quot;]
    D --&gt; E[&quot;Code that passes review in isolation&quot;]
    E --&gt; F[&quot;Maintenance cost paid later by humans&quot;]
    A2[&quot;Scoped request with stated constraints&quot;] --&gt; B2[&quot;Few gaps to fill&quot;]
    B2 --&gt; G[&quot;Minimal change to existing structure&quot;]
</pre>
<p>The key point of the diagram: over-engineering is not the model being wrong, it is the model resolving ambiguity in the direction of generality. Remove the ambiguity and most of it disappears.</p>
<h2 id="the-practical-guide" tabindex="-1"><a class="header-anchor" href="#the-practical-guide"><span>The practical guide</span></a></h2>
<h3 id="1.-state-the-scope-in-files%2C-not-in-adjectives" tabindex="-1"><a class="header-anchor" href="#1.-state-the-scope-in-files%2C-not-in-adjectives"><span>1. State the scope in files, not in adjectives</span></a></h3>
<p>“Keep it simple” is nearly useless as an instruction — simplicity is subjective and the model already believes its output is simple. A file budget is not subjective.</p>
<pre class="code-block"><code class="hljs language-text">Add retry-on-timeout to the fetchInvoice call in services/billing.ts.
Change that one file only. No new files, no new classes.
If you think this needs more than one file, stop and tell me why
instead of writing it.
</code></pre>
<p>If the change genuinely needs a new file, the model will say so, and now you are having a useful conversation about scope instead of reviewing code you did not want.</p>
<h3 id="2.-name-the-one-case-you-are-solving%2C-and-the-ones-you-are-not" tabindex="-1"><a class="header-anchor" href="#2.-name-the-one-case-you-are-solving%2C-and-the-ones-you-are-not"><span>2. Name the one case you are solving, and the ones you are not</span></a></h3>
<p>Over-building thrives on unstated cases. Close them explicitly.</p>
<pre class="code-block"><code class="hljs language-text">We only ever read UTF-8 CSVs written by our own exporter, always with
the same six columns. Do not handle other encodings, custom delimiters,
missing columns, or malformed rows beyond raising an error.
</code></pre>
<p>This single instruction prevents more bloat than any amount of “be concise”. A useful variant when you genuinely do not know the case list yet:</p>
<pre class="code-block"><code class="hljs language-text">Before writing anything, list the edge cases you are planning to handle
and wait. I will tell you which ones are real.
</code></pre>
<h3 id="3.-keep-tasks-small-and-incremental" tabindex="-1"><a class="header-anchor" href="#3.-keep-tasks-small-and-incremental"><span>3. Keep tasks small and incremental</span></a></h3>
<p>This is the rule that makes all the others easier to follow, and the one most often broken, because handing over a big task feels like the whole point of having an assistant.</p>
<p>Two prompts beat one. First: make it work for the exact case, in the existing structure. Then, once you have seen the diff, decide whether it needs generalising. The model is good at both jobs; it is just bad at guessing which one you wanted.</p>
<pre class="code-block"><code class="hljs language-text">Step 1 only: make this work for the single case I described, in the
existing structure, with the fewest lines you can. Do not generalise,
do not add options. Once I have reviewed it I will tell you whether
step 2 is needed.
</code></pre>
<p>The inversion matters. Generalising working code you have read is cheap. Reviewing a speculative framework to find the 10 lines that do the work is expensive — in review time and in tokens, since every follow-up re-reads the whole thing.</p>
<p>Size the increment so that you can still meaningfully review it. A good working limit is a change you would be happy to review as a pull request from a colleague: one behaviour, a diff you can hold in your head, a clear answer to “did this do what I asked?” Past that point, review quality collapses — you start skimming, and skimming is exactly how both bloat and silent deletions get merged.</p>
<p>Small increments also fail cheaply. If the model misunderstands the task, you find out after one reviewable change, not after eleven files that all assume the same wrong thing. And because each increment lands in a clean tree, every diff is attributable.</p>
<pre class="code-block"><code class="hljs language-text">We are doing this in steps, not all at once. Step one is only:
add the endpoint and return a hard-coded response. Do not touch the
database layer, auth, or tests yet — I will ask for those separately.
Stop when step one works.
</code></pre>
<p><strong>The exception, with a caveat.</strong> A larger task is reasonable when there is a genuine specification behind it: written requirements, defined acceptance criteria, named files or interfaces, and an explicit statement of what is out of scope. That is exactly what closes the ambiguity gap the model would otherwise fill with invention, and it is why teams with real task documents get better results from agentic tools than teams working from one-line tickets.</p>
<p>But a specification is not a substitute for checking. It changes what you check for, not whether you check. With a spec, the failure mode is rarely “the model did the wrong thing” — it is the model satisfying every stated criterion while adding structure nobody specified, or quietly dropping an existing behaviour the spec did not mention because the spec described the feature, not the code around it. Nothing in a document prevents that; only the diff shows it.</p>
<p>So even with a good spec, work through it in stages and review at each boundary:</p>
<pre class="code-block"><code class="hljs language-text">Here is the specification. Work through it in stages and stop after
each one for review. Do not implement anything the spec does not
call for, and if the spec is ambiguous or silent on something, ask
rather than deciding for me. List anything you had to assume.
</code></pre>
<p>The last sentence is the valuable one. Assumptions the model announces are cheap to correct; assumptions it bakes into code are not.</p>
<h3 id="4.-point-at-the-code-it-should-imitate" tabindex="-1"><a class="header-anchor" href="#4.-point-at-the-code-it-should-imitate"><span>4. Point at the code it should imitate</span></a></h3>
<p>Most bloat is the model inventing structure because it does not know yours.</p>
<pre class="code-block"><code class="hljs language-text">Follow the pattern in handlers/orders.py exactly. Use the existing
get_config() helper rather than adding configuration. If something in
that pattern does not fit, say so rather than inventing a new one.
</code></pre>
<p>This also fixes the duplication problem: told where to look, the model reuses instead of rebuilding.</p>
<h3 id="5.-ban-speculative-generality-by-name" tabindex="-1"><a class="header-anchor" href="#5.-ban-speculative-generality-by-name"><span>5. Ban speculative generality by name</span></a></h3>
<p>The specific phrases work better than a general plea for restraint, because they map to recognisable patterns:</p>
<ul>
<li>No new interfaces or abstract base classes unless there are two real implementations today.</li>
<li>No configuration options for values that have exactly one value in this codebase.</li>
<li>No new dependencies without asking first.</li>
<li>No backwards-compatibility shims — nothing is calling this yet.</li>
</ul>
<p>Put these in a project instructions file (<code>CLAUDE.md</code>, <code>.cursorrules</code>, or whatever your tool reads) so you are not retyping them. That is the single highest-leverage habit here: the rules apply to every prompt without costing you anything per prompt.</p>
<h3 id="6.-be-explicit-about-tests%2C-both-ways" tabindex="-1"><a class="header-anchor" href="#6.-be-explicit-about-tests%2C-both-ways"><span>6. Be explicit about tests, both ways</span></a></h3>
<p>Left alone, assistants either write no tests or write twenty, including tests for the abstractions they just invented. Say which you want:</p>
<pre class="code-block"><code class="hljs language-text">Add exactly one test, for the timeout path, to the existing test file.
Do not restructure that file, do not add fixtures, and do not test
anything that already had coverage.
</code></pre>
<h3 id="7.-review-the-diff-for-structure%2C-not-just-correctness" tabindex="-1"><a class="header-anchor" href="#7.-review-the-diff-for-structure%2C-not-just-correctness"><span>7. Review the diff for structure, not just correctness</span></a></h3>
<p>When you read the output, the question is not only “does this work?” but “is anything here that I did not ask for, and is anything missing that used to be there?” New files, new abstractions, new configuration — and removed lines. Each is a place where the model made a scope decision on your behalf. This matters enough that it gets <a href="#read-the-diff-twice-before-and-after">its own section below</a>.</p>
<p>Deleting the extra work is fast when you spot it in review. It is very slow once it has been merged, imported elsewhere, and become load-bearing.</p>
<h3 id="8.-reset-the-conversation-when-scope-drifts" tabindex="-1"><a class="header-anchor" href="#8.-reset-the-conversation-when-scope-drifts"><span>8. Reset the conversation when scope drifts</span></a></h3>
<p>Long sessions accumulate context, and accumulated context tends to grow the perceived scope of the task — as well as the cost of every subsequent message, since the whole history is re-read each turn. If the fourth follow-up in a thread is producing more machinery than the first, start a fresh conversation with a tight restatement of the remaining work. It is cheaper than steering a drifting one, in both senses of cheaper.</p>
<h3 id="9.-ask-for-the-plan-before-the-edits" tabindex="-1"><a class="header-anchor" href="#9.-ask-for-the-plan-before-the-edits"><span>9. Ask for the plan before the edits</span></a></h3>
<p>For anything beyond a small change, make the first response a plan rather than code. A plan is a few hundred tokens and takes twenty seconds to read; the implementation it would have written is thousands of tokens and takes ten minutes to review.</p>
<pre class="code-block"><code class="hljs language-text">Do not write any code yet. In under 15 lines, tell me:
which files you will change, roughly how many lines in each,
any new files or dependencies, and anything you are unsure about.
Then stop and wait.
</code></pre>
<p>This is the cheapest possible point to catch “I was going to add a strategy pattern here”. It also gives you a scope you can hold the model to afterwards: <em>“That plan said two files. You changed five. Explain the other three.”</em></p>
<h2 id="watch-the-token-burn" tabindex="-1"><a class="header-anchor" href="#watch-the-token-burn"><span>Watch the token burn</span></a></h2>
<p>Over-engineering has a twin that gets much less attention: the tokens spent getting there. Every file the tool reads, every file it re-reads, every unchanged line it echoes back, every dead end it explores — all of it is context consumed, and on metered plans, money. On agentic tools it compounds, because the agent is reading and writing on its own initiative between your messages.</p>
<p>The pattern to watch for is a straightforward task that quietly costs several times what it should. A one-line config change that ends with forty files read. A small feature that runs the full test suite four times. A refactor where the assistant reads a 2,000-line file three separate times because it forgot it had already seen it.</p>
<p>Some of that burn is the tool’s doing, but most of it is caused by the same thing that causes bloat: an unbounded request. “Fix the failing test” invites a hunt. “Fix the failing test in <code>test_billing.py::test_refund</code> — the cause is in <code>refund()</code> in <code>billing.py</code>, do not read anything else unless you need to” does not.</p>
<h3 id="where-the-tokens-actually-go" tabindex="-1"><a class="header-anchor" href="#where-the-tokens-actually-go"><span>Where the tokens actually go</span></a></h3>
<ul>
<li><strong>Broad search instead of targeted search.</strong> Reading whole files to absorb conventions, when a few grep matches would show them.</li>
<li><strong>Re-reading.</strong> Files read once, then read again later in the same session.</li>
<li><strong>Echoing unchanged content.</strong> Some editing styles reproduce an entire file to change one line. On a large file that is the whole file, every time.</li>
<li><strong>Over-wide verification.</strong> Running the complete test suite to confirm a change that touches one module.</li>
<li><strong>Exploratory sprawl.</strong> A search that keeps widening because nothing told it when to stop.</li>
<li><strong>Long sessions.</strong> Accumulated history is re-sent with every turn, so a drifting thread gets more expensive per message as it goes.</li>
</ul>
<h3 id="prompts-that-cap-the-burn" tabindex="-1"><a class="header-anchor" href="#prompts-that-cap-the-burn"><span>Prompts that cap the burn</span></a></h3>
<p>Put this in your project instructions file so it applies to every task:</p>
<pre class="code-block"><code class="hljs language-text">Minimise token use. Prefer targeted grep and narrow file reads over
reading whole files. Do not re-read a file you have already read in
this session. Do not read a large file end to end to learn conventions
when a few matches would show them. Run only the tests that cover the
change, not the whole suite. If usage looks unexpectedly high for the
size of the task, stop and tell me before continuing.
</code></pre>
<p>Give large tasks an explicit heads-up rule, so you find out about cost before it is spent rather than after:</p>
<pre class="code-block"><code class="hljs language-text">Before starting anything that touches more than a few files, tell me in
one or two lines: roughly how many files, whether it needs new packages,
migrations or tests, and whether it should be split. Then start — this
is a heads-up, not a request for permission.
</code></pre>
<p>And scope individual investigations rather than opening them up:</p>
<pre class="code-block"><code class="hljs language-text">The bug is somewhere in the checkout flow. Start with
services/checkout.py and the two functions it calls in cart.py.
Read only those. If the cause is not there, tell me what you would
look at next instead of going and looking.
</code></pre>
<p>When a session is already expensive, the right move is usually not to keep steering it. Summarise and restart:</p>
<pre class="code-block"><code class="hljs language-text">Summarise in 10 lines: what we changed, what is left, and the
constraints I gave you. I am going to start a fresh session from that
summary.
</code></pre>
<p>One last habit, cheap and surprisingly effective: if the pace or the cost looks wrong, say so mid-task. <em>“That seems like a lot of reading for a one-line change — what are you looking for?”</em> A tool that has to justify its search usually narrows it.</p>
<h2 id="read-the-diff-twice%3A-before-and-after" tabindex="-1"><a class="header-anchor" href="#read-the-diff-twice%3A-before-and-after"><span>Read the diff twice: before and after</span></a></h2>
<p>The most damaging thing an AI agent does to a codebase is not adding code. It is removing code you did not ask it to remove.</p>
<p>This happens more than people expect, and the mechanism is mundane. When a model rewrites a function or a file, it reproduces it from its own understanding rather than surgically patching it. Anything it did not consider important — or did not have in context — can simply fail to reappear. What gets lost is predictable and, in each case, quiet:</p>
<ul>
<li>An early-return guard for a null case that only occurs in production.</li>
<li>A <code>try/except</code> around a call that fails once a month.</li>
<li>A comment explaining why a line that looks wrong is actually correct.</li>
<li>A feature flag check, or a permissions check, sitting in the middle of a function being “cleaned up”.</li>
<li>Logging or metrics calls, removed as noise.</li>
<li>A workaround for a third-party bug, with no obvious reason for existing.</li>
</ul>
<p>None of these break a test suite reliably. Several of them are precisely the code that exists <em>because</em> of a past incident. And because the model’s summary of its work says “refactored <code>processPayment</code> for clarity”, the deletion never appears in the conversation at all. It exists only in the diff.</p>
<h3 id="the-practice" tabindex="-1"><a class="header-anchor" href="#the-practice"><span>The practice</span></a></h3>
<p><strong>Start every task from a clean tree.</strong> Commit or stash first. If the working tree already has changes in it, you cannot tell the agent’s edits from your own, and the diff — your only real record — becomes useless.</p>
<p><strong>Read the diff, not the summary.</strong> The model’s description of its change is a description of what it intended. The diff is what happened. Review the diff before you run anything, and treat removed lines as first-class findings rather than noise around the addition.</p>
<p><strong>Read removals specifically.</strong> Additions get scrutiny naturally because they are new and interesting. Deletions are easy to skim past, especially in a large diff. Look at them on purpose:</p>
<pre class="code-block"><code class="hljs language-bash"><span class="hljs-comment"># Only the removed lines, across the whole change</span>
git diff -U0 | grep <span class="hljs-string">&#x27;^-&#x27;</span> | grep -v <span class="hljs-string">&#x27;^---&#x27;</span>
</code></pre>
<p><strong>Ask for the deletions to be declared.</strong> You can make the agent surface them itself, which turns an invisible change into a reviewable statement:</p>
<pre class="code-block"><code class="hljs language-text">After making the change, list every line you deleted or replaced that
was not part of what I asked for, and why it went. If the answer is
&quot;nothing&quot;, say that explicitly.
</code></pre>
<p><strong>Forbid unrequested removal outright</strong> for narrow fixes:</p>
<pre class="code-block"><code class="hljs language-text">This is a surgical fix. Do not delete or rewrite any existing line that
is not directly required by the change — including comments, logging,
error handling, and guard clauses. If something looks dead or wrong,
tell me about it, do not remove it.
</code></pre>
<p><strong>Prefer patches over rewrites.</strong> The prompt-level version of this is simply saying so:</p>
<pre class="code-block"><code class="hljs language-text">Edit the existing function in place with the smallest possible change.
Do not rewrite it from scratch and do not reformat the surrounding code.
</code></pre>
<p><strong>Diff the before and after of the file itself</strong> when the change is large enough that a rewrite was likely:</p>
<pre class="code-block"><code class="hljs language-bash">git stash                      <span class="hljs-comment"># or: git switch -c ai-change</span>
git diff --<span class="hljs-built_in">stat</span> HEAD           <span class="hljs-comment"># scope of change, file by file</span>
git diff HEAD -- path/to/file  <span class="hljs-comment"># then read the ones that grew or shrank unexpectedly</span>
</code></pre>
<p>A file that <em>shrank</em> during a task that was supposed to add something is the single most reliable signal that scope was silently traded away. It takes one line of output to spot and can save you an incident.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Clean tree&quot;] --&gt; B[&quot;Scoped prompt&quot;]
    B --&gt; C[&quot;Agent makes change&quot;]
    C --&gt; D[&quot;git diff --stat&quot;]
    D --&gt; E{&quot;Anything shrink&lt;br/&gt;unexpectedly?&quot;}
    E --&gt;|&quot;Yes&quot;| F[&quot;Read removed lines&lt;br/&gt;ask why they went&quot;]
    E --&gt;|&quot;No&quot;| G[&quot;Review additions&lt;br/&gt;for scope creep&quot;]
    F --&gt; H[&quot;Restore what mattered&quot;]
    G --&gt; H
</pre>
<p>The point of that loop is that both halves of the review matter. Scope creep is what the agent added; silent deletion is what it took away. A review that only asks “is this code good?” catches the first and misses the second.</p>
<h2 id="the-cycle-this-fits-into" tabindex="-1"><a class="header-anchor" href="#the-cycle-this-fits-into"><span>The cycle this fits into</span></a></h2>
<p>None of this works as a set of isolated prompting tricks. It works when the prompting sits inside a loop that assumes the AI’s output needs checking, and gives you defined places to do the checking.</p>
<p><img src="/images/assisted-dev.png" alt="AI-assisted development workflow: a nine-step iterative cycle running from specification and task document, through development with AI assistance, peer or AI review, testing, bug fixing, verification against sources of truth, documentation updates, check-in to the repository and Git change review, to a decision point that either merges to main or loops back to step one"></p>
<p>The diagram shows that loop as nine steps, each with a named owner, plus two feedback paths.</p>
<p><strong>It starts with a specification, not a prompt.</strong> Step 1 is the task document: requirements and objectives, the work broken into tasks with acceptance criteria, references to the sources of truth (project bible, architecture, standards, policies), and an explicit statement of scope and constraints. That is the same content as a well-scoped prompt, just written down once and reusable — and it is where the ambiguity that causes over-building gets removed, before any code exists.</p>
<p><strong>The build step is explicitly incremental.</strong> Step 2 is implementation with AI assistance, and its own guidance is to follow the coding standards and <em>keep changes focused and incremental</em>. The AI is a tool inside the step, not the owner of it; the developer still owns the step.</p>
<p><strong>Three different checks follow, and they are not the same check.</strong> Step 3 is review for quality, logic, security and maintainability — by a peer, an AI, or both. Step 4 is testing against the acceptance criteria, including edge cases and error scenarios. Step 6 is verification against the sources of truth: does this align with the existing codebase, the project bible, and organisational policy — and, stated directly on the diagram, <em>were any unintended deviations introduced by the AI?</em> That last question is the one this post has been circling. It is a distinct check, and it belongs in the process rather than in someone’s good intentions.</p>
<p><strong>The Git review step is about diffs, not just code.</strong> Step 9 asks the reviewer to compare diffs to see exactly what changed — noted on the diagram as <em>especially important with AI-generated code</em> — and to look for unexpected or unnecessary changes. Unexpected covers the silent deletion; unnecessary covers the over-engineering. Same review, both failure modes.</p>
<p><strong>The two loops are the point.</strong> The top path sends anything incomplete or broken back through the cycle. The bottom path, from a rejected change review, returns all the way to step 1 — feedback, changed requirements or new requirements become a revised specification rather than another improvised prompt on top of a drifting session. That is the structural version of “reset the conversation when scope drifts”.</p>
<p>If the diagram cannot render for you, the summary is: specify, build small with AI help, review, test, fix, verify against your standards, document, commit, review the diff, then merge or loop back — with a human owning every step and the AI assisting inside them.</p>
<p>Worth being honest about the cost of this: it is more ceremony than “ask the assistant and merge what comes out”. The pay-off is that each step is small enough to actually perform. A nine-step cycle around a two-day change is bureaucracy. The same cycle around a one-behaviour increment is about twenty minutes, most of which you would have spent anyway.</p>
<h2 id="scenarios" tabindex="-1"><a class="header-anchor" href="#scenarios"><span>Scenarios</span></a></h2>
<h3 id="a-configuration-value-becomes-a-configuration-system" tabindex="-1"><a class="header-anchor" href="#a-configuration-value-becomes-a-configuration-system"><span>A configuration value becomes a configuration system</span></a></h3>
<p><strong>Ask:</strong> “Make the request timeout configurable.”</p>
<p><strong>What tends to come back:</strong> A <code>Config</code> class, environment-variable loading with type coercion, a defaults file, validation, and timeout wired through three layers of constructor injection.</p>
<p><strong>What was needed:</strong> A module constant, or one environment variable read at the call site.</p>
<p><strong>Prevention:</strong> Name the mechanism, not the goal. <em>“Read the timeout from an environment variable at the call site in <code>client.py</code>, defaulting to 30 seconds. Do not add a configuration class or file.”</em> Rule 1 and rule 5 do the work here.</p>
<h3 id="one-off-script-becomes-a-cli-application" tabindex="-1"><a class="header-anchor" href="#one-off-script-becomes-a-cli-application"><span>One-off script becomes a CLI application</span></a></h3>
<p><strong>Ask:</strong> “Write a script to rename these files.”</p>
<p><strong>What tends to come back:</strong> Argument parsing, <code>--dry-run</code>, <code>--verbose</code>, <code>--recursive</code>, logging configuration, a <code>main()</code> guard, progress output, and a docstring describing usage.</p>
<p><strong>What was needed:</strong> Fifteen lines you run once and delete.</p>
<p><strong>Prevention:</strong> State the lifespan. <em>“This is a throwaway script I will run once on this directory and then delete. No arguments, no flags, no logging — hard-code the path.”</em> Telling the model the code is disposable is remarkably effective, because it removes every justification for extensibility.</p>
<h3 id="a-bug-fix-becomes-a-refactor" tabindex="-1"><a class="header-anchor" href="#a-bug-fix-becomes-a-refactor"><span>A bug fix becomes a refactor</span></a></h3>
<p><strong>Ask:</strong> “Fix the off-by-one in the pagination.”</p>
<p><strong>What tends to come back:</strong> The fix, plus the surrounding function extracted into three smaller ones, renamed variables, added type hints, and a docstring — a 4-line fix inside a 60-line diff.</p>
<p><strong>What was needed:</strong> The 4 lines. The tidying may even be good, but it is now impossible to review the fix in isolation, and if the page breaks tomorrow you cannot tell which part did it.</p>
<p><strong>Prevention:</strong> Separate the jobs explicitly. <em>“Change only the lines required to fix the off-by-one. Do not rename, reformat, or extract anything. If you see other issues, list them at the end instead of fixing them.”</em> The “list them instead” clause is worth keeping — you get the model’s observations without the unrequested diff, and you can accept them as a separate change.</p>
<h3 id="a-crud-endpoint-arrives-with-a-service-layer" tabindex="-1"><a class="header-anchor" href="#a-crud-endpoint-arrives-with-a-service-layer"><span>A CRUD endpoint arrives with a service layer</span></a></h3>
<p><strong>Ask:</strong> “Add an endpoint to fetch a user by ID.”</p>
<p><strong>What tends to come back:</strong> A repository interface, a service class, a DTO, a mapper between the DTO and the model, a custom exception hierarchy, and the endpoint.</p>
<p><strong>What was needed:</strong> A handler that queries and returns, matching the five endpoints already in the file.</p>
<p><strong>Prevention:</strong> This is rule 4 almost exclusively. <em>“Add it to <code>routes/users.py</code> following the exact pattern of the existing <code>get_order</code> endpoint. No new files.”</em> When an existing pattern is named, the model copies rather than architects.</p>
<h3 id="a-parser-grows-a-plugin-system" tabindex="-1"><a class="header-anchor" href="#a-parser-grows-a-plugin-system"><span>A parser grows a plugin system</span></a></h3>
<p><strong>Ask:</strong> “Parse this log format.”</p>
<p><strong>What tends to come back:</strong> A format-detection layer, a registry of parser strategies, and an extension point for adding formats later — because logs come in many formats, and the model does not know that yours does not.</p>
<p><strong>What was needed:</strong> One regex and a loop.</p>
<p><strong>Prevention:</strong> Close the case list, as in rule 2. <em>“There is exactly one log format and it will not change. Parse it directly — no strategy pattern, no format detection.”</em></p>
<h3 id="a-shared-helper-gets-duplicated" tabindex="-1"><a class="header-anchor" href="#a-shared-helper-gets-duplicated"><span>A shared helper gets duplicated</span></a></h3>
<p><strong>Ask:</strong> “Add date formatting to this report.”</p>
<p><strong>What tends to come back:</strong> A new <code>formatDate</code> function in the report module — while <code>utils/dates.ts</code> already exports one.</p>
<p><strong>What was needed:</strong> An import.</p>
<p><strong>Prevention:</strong> Make searching part of the instruction, and make the result visible:</p>
<pre class="code-block"><code class="hljs language-text">Before writing any helper, grep utils/ for an existing one and tell me
what you found. If something close already exists, use it or extend it
rather than adding a second version.
</code></pre>
<p>This one is worth putting in your project rules permanently, since it applies to every task and the model cannot be expected to have your whole tree in context.</p>
<h3 id="a-%E2%80%9Ccleanup%E2%80%9D-quietly-removes-error-handling" tabindex="-1"><a class="header-anchor" href="#a-%E2%80%9Ccleanup%E2%80%9D-quietly-removes-error-handling"><span>A “cleanup” quietly removes error handling</span></a></h3>
<p><strong>Ask:</strong> “Tidy up <code>processPayment</code>, it has got messy.”</p>
<p><strong>What tends to come back:</strong> A shorter, more readable function — minus the <code>try/except</code> around the gateway call, minus the idempotency-key check, and minus a comment reading <code>// do not remove: gateway double-charges on retry</code>. Tests pass, because nothing tests a gateway that fails twice.</p>
<p><strong>What was needed:</strong> The same function, tidier, with every guard intact.</p>
<p><strong>Prevention:</strong> Two layers. First, constrain the edit:</p>
<pre class="code-block"><code class="hljs language-text">Improve readability only. Do not remove or alter any error handling,
retry logic, idempotency check, comment, or log line. Behaviour must be
identical.
</code></pre>
<p>Second, verify rather than trust:</p>
<pre class="code-block"><code class="hljs language-text">Show me a list of every line you removed, with the reason for each.
</code></pre>
<p>Then read the diff yourself anyway. This is the scenario that justifies the whole diff habit — the output looks better and is worse, and the only place the truth is recorded is in the removed lines.</p>
<h3 id="a-one-line-change-costs-a-hundred-thousand-tokens" tabindex="-1"><a class="header-anchor" href="#a-one-line-change-costs-a-hundred-thousand-tokens"><span>A one-line change costs a hundred thousand tokens</span></a></h3>
<p><strong>Ask:</strong> “The date on the invoice PDF is in the wrong format.”</p>
<p><strong>What tends to come back:</strong> The fix — after the agent has read the PDF renderer, the template system, three model files, the test suite, the configuration loader, and the changelog, run the full test suite twice, and explored a theory about timezones that turned out to be unrelated.</p>
<p><strong>What was needed:</strong> One grep for the format string, and one edit.</p>
<p><strong>Prevention:</strong> Point at the target and cap the search before it starts:</p>
<pre class="code-block"><code class="hljs language-text">The invoice date format is wrong. Grep for the format string in
templates/ first. Change only where it is defined. Do not read the
renderer or the test suite unless the grep comes back empty, and run
only the invoice tests.
</code></pre>
<p>The general form is worth internalising: when you already know roughly where the problem lives, saying so is not spoon-feeding, it is the single biggest cost control you have.</p>
<h2 id="a-prompt-block-worth-keeping" tabindex="-1"><a class="header-anchor" href="#a-prompt-block-worth-keeping"><span>A prompt block worth keeping</span></a></h2>
<p>Most of the constraints above are not per-task decisions — they are how you want the tool to behave always. Written once into a project instructions file (<code>CLAUDE.md</code>, <code>.cursorrules</code>, a system prompt, whatever your tool reads), they cost nothing per prompt and apply to work you are not watching closely:</p>
<pre class="code-block"><code class="hljs language-text">## Scope
- Do the task asked, and nothing adjacent. If you see other problems,
  list them at the end instead of fixing them.
- No new files unless I asked for one, or you tell me first and I agree.
- No new interfaces or abstract classes unless there are two real
  implementations today.
- No configuration options for values that have exactly one value in
  this codebase.
- No new dependencies without asking first.
- Reuse what exists: search before writing a helper, and follow the
  nearest existing pattern rather than inventing one.

## Edits
- Make the smallest change that works. Edit in place; do not rewrite
  files or functions wholesale.
- Never remove existing error handling, guards, comments, logging or
  tests that are not part of the change. Flag them instead.
- After each change, list anything you deleted that I did not ask you
  to delete.

## Cost
- Prefer targeted grep and narrow reads over reading whole files.
- Do not re-read files you have already read this session.
- Run only the tests covering the change.
- Before anything touching more than a few files, give me a one-line
  heads-up on size and whether it should be split.
- If cost looks high for the size of the task, stop and say so.
</code></pre>
<p>Trim it to taste. The value is not in these exact words but in the fact that they are written down somewhere the tool reads every time, instead of being remembered by you in some prompts and forgotten in others.</p>
<h2 id="the-pattern-underneath" tabindex="-1"><a class="header-anchor" href="#the-pattern-underneath"><span>The pattern underneath</span></a></h2>
<p>Every one of those preventions is the same move: replace an assumption the model would have to make with a fact you supply. Scope, lifespan, the case list, the pattern to follow, what already exists. The model is not trying to gold-plate your codebase. It is answering a question you asked less precisely than you thought, and generality is the safest-looking answer to an imprecise question.</p>
<p>That reframing is also the reason this is a tractable problem rather than a complaint about the tools. You cannot make a model want less code. You can remove the ambiguity that makes more code look like the right answer — and you only have to write most of those constraints down once.</p>
<p>The same move handles the other two currencies. Unbounded search burns tokens for exactly the reason unbounded scope burns files: nothing said where the edges were. And silent deletion happens because “improve this” gave the model licence to decide what mattered in code it could not see the history of. Say where the edges are, and say what must survive.</p>
<p>Four habits, then, and none of them take long:</p>
<ul>
<li>Keep the increment small enough that you will genuinely review it — and when a real specification lets you go bigger, still stop and check at each stage.</li>
<li>Before sending a prompt, spend ten seconds asking what the model would have to guess — then say it.</li>
<li>Before starting anything big, ask for the plan and the cost, not the code.</li>
<li>After every change, read the diff — the removed lines first.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>A Central Monitoring Stack for a Multi-Application SaaS Ecosystem</title>
      <link>https://ikoonman.io/blog/central-monitoring-saas-ecosystem/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/central-monitoring-saas-ecosystem/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>How a central monitoring platform combines application instrumentation, infrastructure metrics, and availability checks across multiple SaaS applications.</description>
      <category>observability</category>
      <category>saas</category>
      <category>infrastructure</category>
      <category>prometheus</category>
      <category>grafana</category>
      <content:encoded><![CDATA[<p>A SaaS application can be online and still be failing its users. An API might respond while background jobs stop processing. A database might accept connections while queries become slow. A server might look healthy while one container runs out of memory. The Enterprise Monitoring Stack brings these different signals into a central monitoring environment, helping operators understand both whether applications are available and what is happening underneath them.</p>
<p>The project provides a Docker-based foundation for monitoring multiple SaaS applications across Linux virtual machines. It combines Prometheus for metrics, Grafana for visualisation, and Uptime Kuma for availability checks, with collectors running alongside each application.</p>
<p>Its purpose is to give a solo developer or a small platform team consistent operational visibility across an application portfolio.</p>
<h2 id="the-problem%3A-fragmented-visibility" tabindex="-1"><a class="header-anchor" href="#the-problem%3A-fragmented-visibility"><span>The problem: fragmented visibility</span></a></h2>
<p>Without a shared monitoring approach, each application tends to develop its own operational habits: a health endpoint here, container logs there, and a server dashboard somewhere else.</p>
<p>That makes basic questions harder to answer:</p>
<ul>
<li>Which applications are affected?</li>
<li>Is the problem in the API, database, queue, container, or host?</li>
<li>Did performance deteriorate gradually or change suddenly?</li>
<li>Is a background workflow progressing even though the website still loads?</li>
<li>Is the application unavailable, or has monitoring lost contact with it?</li>
</ul>
<p>The stack addresses this by collecting comparable signals from each deployment and storing metrics centrally. Operators can investigate application behaviour alongside the infrastructure supporting it.</p>
<h2 id="two-parts-of-one-ecosystem" tabindex="-1"><a class="header-anchor" href="#two-parts-of-one-ecosystem"><span>Two parts of one ecosystem</span></a></h2>
<p>The architecture separates the central monitoring environment from the software that produces measurements.</p>
<pre class="mermaid">
flowchart TB
    subgraph Apps[&quot;SaaS application environments&quot;]
        App[&quot;Application code and metrics library&quot;]
        Infra[&quot;Host, containers, proxy, and data services&quot;]
        Exporters[&quot;Infrastructure exporters&quot;]
        Endpoint[&quot;Private application metrics endpoint&quot;]
        Public[&quot;Public website or API&quot;]

        App --&gt; Endpoint
        Infra --&gt; Exporters
    end

    subgraph Central[&quot;Central monitoring VM&quot;]
        Prom[&quot;Prometheus&quot;]
        Grafana[&quot;Grafana&quot;]
        Kuma[&quot;Uptime Kuma&quot;]

        Grafana --&gt;|&quot;Queries stored metrics&quot;| Prom
    end

    Prom --&gt;|&quot;Scrapes over private network&quot;| Endpoint
    Prom --&gt;|&quot;Scrapes over private network&quot;| Exporters
    Kuma --&gt;|&quot;Checks public availability&quot;| Public
</pre>
<p>The central environment collects, stores, and presents operational data. Application environments expose measurements through a common format.</p>
<p>This is monitoring for SaaS applications under the operator’s control: integrating their backend code and deployment infrastructure, rather than connecting to arbitrary third-party SaaS accounts.</p>
<h3 id="the-central-environment" tabindex="-1"><a class="header-anchor" href="#the-central-environment"><span>The central environment</span></a></h3>
<p>Three services form the core:</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prometheus</td>
<td>Collects numerical measurements, stores their history, and evaluates alert rules.</td>
</tr>
<tr>
<td>Grafana</td>
<td>Queries Prometheus and displays metrics in dashboards.</td>
</tr>
<tr>
<td>Uptime Kuma</td>
<td>Checks configured endpoints for availability and response behaviour.</td>
</tr>
</tbody>
</table>
<p>Grafana is the central metrics dashboard. Uptime Kuma provides a complementary availability view through its own interface. They belong to the same monitoring environment, but the repository does not implement a custom dashboard that merges every signal into one application.</p>
<h3 id="what-runs-alongside-each-application" tabindex="-1"><a class="header-anchor" href="#what-runs-alongside-each-application"><span>What runs alongside each application</span></a></h3>
<p>Each application VM runs collectors appropriate to its workload:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Collector or integration</th>
<th>Typical visibility</th>
</tr>
</thead>
<tbody>
<tr>
<td>Linux host</td>
<td>node-exporter</td>
<td>CPU, memory, disk, and network usage</td>
</tr>
<tr>
<td>Docker containers</td>
<td>cAdvisor</td>
<td>Container resource consumption</td>
</tr>
<tr>
<td>Nginx</td>
<td>Nginx Prometheus exporter</td>
<td>Connections and request activity</td>
</tr>
<tr>
<td>Databases</td>
<td>PostgreSQL, MySQL, or MongoDB exporters</td>
<td>Database-specific operational measurements</td>
</tr>
<tr>
<td>Cache</td>
<td>Redis exporter</td>
<td>Memory use, connections, and cache activity</td>
</tr>
<tr>
<td>Message broker</td>
<td>RabbitMQ Prometheus plugin</td>
<td>Broker and queue measurements</td>
</tr>
<tr>
<td>Application</td>
<td>A private <code>/metrics</code> endpoint</td>
<td>Request counts, latency, errors, and custom workflow metrics</td>
</tr>
</tbody>
</table>
<p>Database and cache exporters are optional Compose profiles. A deployment enables the components it actually needs.</p>
<p>Infrastructure collectors and application instrumentation serve different purposes. An exporter can describe a container’s memory usage, but application code must provide context such as how many jobs failed or how long an API operation took.</p>
<h2 id="how-status-data-is-collected" tabindex="-1"><a class="header-anchor" href="#how-status-data-is-collected"><span>How status data is collected</span></a></h2>
<p>The stack combines two perspectives.</p>
<p><strong>Availability checks</strong> test whether a configured endpoint responds from the monitoring location. These help answer, “Can this service be reached?” They do not establish that every feature or every user’s network path is working.</p>
<p><strong>Internal metrics</strong> describe activity and resource usage inside the deployment. These help explain slowdowns, failures, and capacity pressure.</p>
<p>The metrics pipeline uses a pull model: Prometheus requests measurements from each registered endpoint.</p>
<pre class="mermaid">
sequenceDiagram
    participant User as User or background task
    participant App as Application
    participant Library as Metrics library
    participant Prom as Prometheus
    participant Dash as Grafana

    User-&gt;&gt;App: Perform an operation
    App-&gt;&gt;Library: Record count, duration, or outcome
    Note over Library: Keep aggregated measurements locally
    Prom-&gt;&gt;App: Request private /metrics endpoint
    App-&gt;&gt;Library: Read current measurements
    Library--&gt;&gt;App: Prometheus-format metrics
    App--&gt;&gt;Prom: Return measurements
    Note over Prom: Store samples and evaluate rules
    Dash-&gt;&gt;Prom: Query a time range
    Prom--&gt;&gt;Dash: Return time-series results
</pre>
<p>Although it is natural to describe applications as “sending monitoring data,” they generally return it when Prometheus scrapes them. They do not contact Grafana directly or transmit a separate monitoring event for every request.</p>
<p>Prometheus discovers endpoints through configuration files. Labels associate measurements with applications, hosts, environments, and components, making it possible to query related signals together.</p>
<h2 id="how-the-libraries-inside-other-projects-work" tabindex="-1"><a class="header-anchor" href="#how-the-libraries-inside-other-projects-work"><span>How the libraries inside other projects work</span></a></h2>
<p>The application integration guides describe using standard Prometheus client libraries, including <code>prom-client</code> for Node.js and <code>prometheus_client</code> for Python.</p>
<p>These libraries run inside the backend application. A small integration layer connects them to HTTP middleware, framework interceptors, workers, or scheduled collectors.</p>
<p>The basic building blocks are:</p>
<ul>
<li><strong>Counters:</strong> totals that increase, such as completed requests or failed jobs.</li>
<li><strong>Gauges:</strong> current values that rise or fall, such as active requests or waiting jobs.</li>
<li><strong>Histograms:</strong> measurements grouped into buckets, such as request durations, from which latency percentiles can be estimated.</li>
</ul>
<p>For an API request, instrumentation records the method, a normalised route, the response status, and the elapsed time. A worker can record completed and failed jobs. Queue integration can periodically read queue state and expose the results as gauges.</p>
<p>The library keeps these measurements in a registry and exposes them through a private <code>/metrics</code> endpoint. Prometheus then collects them using the same mechanism it uses for infrastructure exporters.</p>
<p>Labels should describe bounded categories, such as route templates and status codes. Customer identifiers, request bodies, and raw URLs are generally unsuitable metric labels: they can disclose sensitive information and create excessive numbers of time series.</p>
<h3 id="a-shared-integration-pattern" tabindex="-1"><a class="header-anchor" href="#a-shared-integration-pattern"><span>A shared integration pattern</span></a></h3>
<p>The repository contains application preparation guides and instrumentation examples. It does not contain a separately published, proprietary monitoring SDK.</p>
<p>The reusable element is currently the integration pattern: consistent endpoints, metric names, labels, and instrumentation conventions. Each application implements that contract using its own framework and a standard client library.</p>
<p>Consistency matters because central queries and alert rules depend on the metric names and labels that applications expose. Installing a library alone does not make an application compatible with every dashboard or alert.</p>
<h2 id="what-%E2%80%9Creal-time%E2%80%9D-means-here" tabindex="-1"><a class="header-anchor" href="#what-%E2%80%9Creal-time%E2%80%9D-means-here"><span>What “real-time” means here</span></a></h2>
<p>This is near-real-time monitoring rather than a continuous stream of individual events.</p>
<p>The current Prometheus configuration scrapes metrics every 15 seconds and evaluates alert rules every 15 seconds. Dashboard refresh settings, query windows, and alert waiting periods introduce additional delay.</p>
<p>Counters can preserve activity between scrapes: a burst of completed requests still contributes to the next collected total. A short-lived gauge spike, however, may occur entirely between samples and go unseen.</p>
<p>Alerts can also require a condition to persist before firing. That helps distinguish a sustained problem from a brief fluctuation.</p>
<p>A failed scrape needs interpretation, too. It means Prometheus could not collect measurements from an endpoint; it does not, by itself, prove that the public application is down. Availability checks provide another piece of evidence.</p>
<h2 id="from-measurements-to-useful-diagnosis" tabindex="-1"><a class="header-anchor" href="#from-measurements-to-useful-diagnosis"><span>From measurements to useful diagnosis</span></a></h2>
<p>The benefit comes from comparing signals.</p>
<p>Consider an illustrative case where a website remains reachable but background processing slows down:</p>
<ol>
<li>Availability checks continue to pass.</li>
<li>Application metrics show an increasing number of waiting jobs.</li>
<li>Worker completion rates fall.</li>
<li>Database metrics show connection pressure.</li>
<li>Host and container metrics help establish whether resource exhaustion is also involved.</li>
</ol>
<p>This narrows the investigation. It does not automatically prove a root cause, but it provides a much stronger starting point than a single green uptime indicator.</p>
<p>The repository includes alert rules covering application availability, latency, server errors, infrastructure pressure, data services, and RabbitMQ queues. Their usefulness depends on the corresponding measurements being available and correctly labelled.</p>
<p>Rule evaluation and notification delivery are separate concerns. The checked-in central stack does not include Alertmanager, so escalation routing and notification configuration remain additional operational work.</p>
<h2 id="infrastructure-and-deployment" tabindex="-1"><a class="header-anchor" href="#infrastructure-and-deployment"><span>Infrastructure and deployment</span></a></h2>
<p>The deployment model uses a dedicated monitoring VM and a companion Docker Compose stack on each application VM.</p>
<p>Metrics collection is designed to travel over a private overlay network, such as Tailscale or WireGuard. Exporters bind to private interfaces, and application integration guides call for private monitoring endpoints. Public websites remain accessible to users without exposing their internal telemetry.</p>
<p>The central services use persistent Docker volumes. Prometheus defaults to 30 days of metric retention, with that period configurable.</p>
<p>Configuration files define scrape targets, alert rules, and Grafana provisioning. This makes the monitoring setup reviewable and repeatable alongside other infrastructure configuration.</p>
<p>A dedicated monitoring VM also separates monitoring from any one application’s host. It can continue observing other deployments if an application VM fails. The current single-VM central design does not provide high availability for monitoring itself.</p>
<h2 id="what-the-project-provides-today" tabindex="-1"><a class="header-anchor" href="#what-the-project-provides-today"><span>What the project provides today</span></a></h2>
<p>The implemented foundation includes central service definitions, infrastructure exporter configurations, target discovery files, alert rules, Grafana provisioning, and application onboarding guidance.</p>
<p>Some capabilities still require deployment-specific work:</p>
<ul>
<li><strong>Dashboards:</strong> the provisioning mechanism is present, but the dashboard directory currently describes recommended dashboards rather than shipping a completed dashboard collection.</li>
<li><strong>Application instrumentation:</strong> integration guides explain the required code changes; those changes live in the individual application projects.</li>
<li><strong>Availability checks:</strong> Uptime Kuma must be configured with the endpoints and checks relevant to each application.</li>
<li><strong>Advanced observability:</strong> central log aggregation, distributed tracing, richer alert routing, and SLO reporting are future extensions.</li>
</ul>
<p>The current project is therefore a metrics and availability foundation, with a defined path toward broader observability.</p>
<h2 id="key-advantages" tabindex="-1"><a class="header-anchor" href="#key-advantages"><span>Key advantages</span></a></h2>
<p><strong>Consistent visibility across applications.</strong> A common collection model reduces the need to learn a different monitoring approach for every product.</p>
<p><strong>Better incident investigation.</strong> Application symptoms can be examined alongside queues, databases, containers, and hosts.</p>
<p><strong>Visibility beyond uptime.</strong> Latency, error rates, queue growth, and resource pressure can reveal degradation while a service still responds.</p>
<p><strong>Incremental adoption.</strong> Teams can start with host and container metrics, then add application instrumentation and dependency-specific collectors.</p>
<p><strong>Control over telemetry.</strong> The core metrics pipeline and storage run in infrastructure controlled by the operator, with private collection paths.</p>
<p><strong>A reusable foundation.</strong> Standard Prometheus metrics allow different application languages and frameworks to participate in the same monitoring environment.</p>
<p>The practical result is a shared way to understand a growing SaaS ecosystem: whether services respond, how they behave, and where to investigate when their behaviour changes.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Beyond Copy-Paste: Giving AI Instructions a Shared Standard</title>
      <link>https://ikoonman.io/blog/beyond-copy-paste-ai-instructions-shared-standard/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/beyond-copy-paste-ai-instructions-shared-standard/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>The prompts, instructions, and policies that shape AI-assisted work have become operational assets, and OAI2 is an attempt to package, validate, and share them the way we already do with code.</description>
      <category>ai-governance</category>
      <category>developer-tooling</category>
      <category>standards</category>
      <category>oai2</category>
      <content:encoded><![CDATA[<p>Someone on your backend team writes a genuinely good code-review prompt. It knows which framework you use, which failure modes matter, and how strict to be about style versus correctness. It gets pasted into a Slack thread. Two other teams copy it. Someone tightens the security section. Someone else trims it because it was too long for their context window. Six weeks later there are four versions in circulation, one of them subtly wrong, and nobody can say which one the compliance review actually looked at.</p>
<p>Nothing here is exotic. It is the same thing that happens to any useful text that has no owner, no version, and no distribution mechanism. What makes it uncomfortable is that this text is now load-bearing: it shapes what gets flagged in a pull request, what an assistant refuses to answer, and which risks a workflow checks for. We have decades of practice managing that kind of dependency for code. We have almost none for instructions.</p>
<h2 id="what-an-ai-artifact-actually-is" tabindex="-1"><a class="header-anchor" href="#what-an-ai-artifact-actually-is"><span>What an AI artifact actually is</span></a></h2>
<p>An <strong>AI artifact</strong> is a reusable unit of operating knowledge that shapes AI-assisted work. In practice that means prompt templates, system instructions, workflow definitions, review guidelines, test patterns, knowledge articles, code templates, policy packs, and evaluation suites.</p>
<p>The important shift is not the vocabulary. It is treating these things as assets with an owner, a version, a compatibility statement, and a lifecycle — rather than as text that lives wherever it was last pasted.</p>
<p><strong>OAI2 — the Open AI Artifact Interchange Initiative</strong> — is an open standard and registry for packaging, validating, sharing, and consuming those artifacts. (The name refers to AI artifact interchange; the project has no affiliation with OpenAI.)</p>
<h2 id="the-package-and-the-registry" tabindex="-1"><a class="header-anchor" href="#the-package-and-the-registry"><span>The package and the registry</span></a></h2>
<p>An OAI2 package is a directory with content plus an <code>oai2.yaml</code> manifest:</p>
<pre class="code-block"><code class="hljs">artifact/
├── oai2.yaml              # Manifest (required)
├── content/main.md        # Primary content (required)
├── variables.schema.json  # Variable definitions, JSON Schema 2020-12 (optional)
└── tests/cases.yaml       # Test cases (optional)
</code></pre>
<p>The manifest is where the file stops being a document and starts being a dependency. From the project’s TypeScript code-review sample:</p>
<pre class="code-block"><code class="hljs language-yaml"><span class="hljs-attr">oai2Version:</span> <span class="hljs-string">&quot;0.1&quot;</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">&quot;PROMPT_TEMPLATE&quot;</span>

<span class="hljs-attr">artifact:</span>
  <span class="hljs-attr">id:</span> <span class="hljs-string">&quot;org.acme.code-review.typescript&quot;</span>
  <span class="hljs-attr">version:</span> <span class="hljs-string">&quot;1.2.0&quot;</span>
  <span class="hljs-attr">title:</span> <span class="hljs-string">&quot;TypeScript Code Review Prompt&quot;</span>

<span class="hljs-attr">ownership:</span>
  <span class="hljs-attr">maintainers:</span> [<span class="hljs-string">&quot;platform-governance@acme.example&quot;</span>]
  <span class="hljs-attr">team:</span> <span class="hljs-string">&quot;platform-engineering&quot;</span>

<span class="hljs-attr">visibility:</span>
  <span class="hljs-attr">mode:</span> <span class="hljs-string">&quot;public&quot;</span>

<span class="hljs-attr">compatibility:</span>
  <span class="hljs-attr">providers:</span> [<span class="hljs-string">&quot;openai&quot;</span>, <span class="hljs-string">&quot;anthropic&quot;</span>, <span class="hljs-string">&quot;azure-openai&quot;</span>]
  <span class="hljs-attr">agentTargets:</span> [<span class="hljs-string">&quot;cli&quot;</span>, <span class="hljs-string">&quot;ide&quot;</span>, <span class="hljs-string">&quot;web&quot;</span>]
  <span class="hljs-attr">minTokenContext:</span> <span class="hljs-number">8000</span>

<span class="hljs-attr">validation:</span>
  <span class="hljs-attr">profiles:</span> [<span class="hljs-string">&quot;oai2-core&quot;</span>, <span class="hljs-string">&quot;secure-prompting-v1&quot;</span>]
</code></pre>
<p>Identity, ownership, visibility, compatibility, validation profiles — declared once, machine-readable, and travelling with the content. Variables move out of the prose and into a schema, so <code>strictness</code> becomes an enum with a default rather than a sentence someone has to remember to edit.</p>
<p>The registry is the other half. It handles publishing, validation, search with visibility enforcement, controlled sharing, retrieval, and subscriptions. Artifacts sit in one of three visibility modes: <code>public</code>, <code>private</code>, or <code>private-shareable</code> — the last being the interesting one, where an artifact is invisible by default but grantable to named partner organizations.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Author package&quot;] --&gt; B[&quot;Validate against profiles&quot;]
    B --&gt; C[&quot;Publication gate&quot;]
    C --&gt; D[&quot;Registry: versioned, searchable&quot;]
    D --&gt; E[&quot;Share grant to named org&quot;]
    D --&gt; F[&quot;Subscription: notify on new release&quot;]
</pre>
<p>The key point of the flow: validation is a gate, not a formality. A package that only parses can be stored, but publication requires it to pass the deterministic rule set.</p>
<h2 id="an-illustrative-walkthrough" tabindex="-1"><a class="header-anchor" href="#an-illustrative-walkthrough"><span>An illustrative walkthrough</span></a></h2>
<p>The following scenario is illustrative — it uses the project’s own sample artifacts and demo seed data, not a real customer.</p>
<p>Acme’s platform team owns that TypeScript review prompt. They package it, run <code>oai2 validate</code> locally, and publish. The registry checks the manifest, verifies the declared entrypoint actually exists in the archive, scans for secret patterns, computes a SHA-256 over the package, and — if the checks pass — publishes it as an immutable <code>1.2.0</code>.</p>
<p>Globex wants it. Because the artifact is public, they find it via search, <code>oai2 inspect</code> it to see maintainers and compliance level, and <code>oai2 pull</code> a specific version. They then subscribe with a compliance filter, so when Acme ships <code>1.3.0</code> a signed webhook arrives rather than a rumour.</p>
<p>Now change one detail: Acme’s <em>safety constraints</em> artifact is <code>private-shareable</code>. Globex cannot find it at all until Acme creates an org-to-org grant, optionally scoped to a semver range and an expiry date. Same registry, same package format, different disclosure decision — expressed as configuration rather than as a forwarded email.</p>
<h2 id="who-this-is-for%2C-and-what-it-solves" tabindex="-1"><a class="header-anchor" href="#who-this-is-for%2C-and-what-it-solves"><span>Who this is for, and what it solves</span></a></h2>
<ul>
<li><strong>Engineering leads</strong> get an answer to “which version of the review standard is this team actually using?” that does not depend on someone’s memory.</li>
<li><strong>AI platform teams</strong> get one distribution path instead of per-team copies, and a subscription mechanism so downstream teams learn about changes.</li>
<li><strong>Artifact maintainers</strong> get named ownership, immutable versions, and deprecation with a declared successor — so improving an artifact is not a broadcast-and-hope exercise.</li>
<li><strong>Governance functions</strong> get visibility modes, validation profiles, risk-tier declarations, and an audit log of state-changing operations.</li>
</ul>
<h2 id="what-this-adds-over-git-or-a-shared-doc" tabindex="-1"><a class="header-anchor" href="#what-this-adds-over-git-or-a-shared-doc"><span>What this adds over Git or a shared doc</span></a></h2>
<p>Git already gives you history, review, and diffs, and for a single team that is often enough. What it does not give you is a <em>manifest contract</em>: a machine-readable declaration of identity, compatibility, and ownership that other systems can act on. Nor does it give you cross-organization discovery with visibility enforcement, validation as a publication gate, selective sharing with expiry, or update subscriptions to a repository you have no access to.</p>
<p>Two honest limits. <strong>Interoperability is a design objective, not a promise</strong>: the <code>compatibility</code> block declares intended providers and targets — it does not mean an artifact behaves identically across tools or models. And <strong>validation checks specified rules only</strong>: passing <code>oai2-core</code> means the package is well-formed and free of the patterns the rules describe. It is not a claim of correctness, safety, or reproducible model behaviour. The project’s own design principles put it plainly — AI-assisted validation is an assistive layer, not a guarantee.</p>
<h2 id="where-the-project-actually-stands" tabindex="-1"><a class="header-anchor" href="#where-the-project-actually-stands"><span>Where the project actually stands</span></a></h2>
<p>Implemented today: organization registration and API keys; artifact catalog with lifecycle states; versioned package upload with hashing; fifteen <code>oai2-core</code> manifest checks with a publication gate; full-text search with visibility scoping and facets; org-to-org share grants with version ranges and expiry; subscriptions with HMAC-signed webhook delivery; an audit log; a web UI; and a CLI covering <code>login</code>, <code>validate</code>, <code>publish</code>, <code>search</code>, <code>inspect</code>, <code>pull</code>, and <code>subscribe</code>.</p>
<p>Specified but not yet built: standards profile management, publisher domain verification, artifact signing and provenance attestations, deeper dependency resolution, and the extended CLI surface (<code>share</code>, <code>fork</code>, drift detection, upstream pull). That matters for the compliance ladder — <em>Declared</em> and <em>Validated</em> are reachable now; <em>Verified</em> and <em>Trusted</em> depend on the profile and trust work still ahead. The licensing model is also still open.</p>
<h2 id="when-you-do-not-need-this" tabindex="-1"><a class="header-anchor" href="#when-you-do-not-need-this"><span>When you do not need this</span></a></h2>
<p>If one team owns three prompts and everyone who uses them sits in the same standup, a versioned file in the repo is the right answer. The cost of packaging is only worth paying when artifacts cross a boundary — between teams, between an authoring group and its consumers, or between organizations — and when someone downstream needs to know that what they have is current, owned, and checked.</p>
<p>If that boundary exists in your organization, the four sample artifacts in the project repository are the fastest way to judge whether the model fits: a prompt template, a system instruction, a policy pack, and a workflow definition, each complete with manifest, variable schema, and test cases. Read one manifest end to end. Either it describes a problem you recognise, or it does not.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Building a Raspberry Pi Voice Assistant With AI</title>
      <link>https://ikoonman.io/blog/raspberry-pi-ai-voice-assistant/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/raspberry-pi-ai-voice-assistant/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>A practical technical overview of building an Alexa-like AI voice assistant using a Raspberry Pi, microphone, camera, local speech output, and cloud AI services.</description>
      <category>raspberry-pi</category>
      <category>ai</category>
      <category>voice-assistant</category>
      <category>python</category>
      <category>edge-computing</category>
      <content:encoded><![CDATA[<p>A Raspberry Pi with a camera and microphone is a surprisingly capable base for a small interactive AI assistant. The goal is not to build a full Alexa replacement immediately, but to create a practical voice interface that can listen, understand, respond, speak, and eventually see. The most efficient approach is to let the Raspberry Pi handle local device interaction while using cloud AI services for the expensive language and speech processing.</p>
<p>The project can start simple: press a key, speak into a microphone, send the audio for transcription, pass the text to an AI model, then play the spoken response through a speaker. From there it can grow into a wake-word assistant, camera-aware helper, local automation controller, or even a small household AI terminal.</p>
<h2 id="what-the-project-is" tabindex="-1"><a class="header-anchor" href="#what-the-project-is"><span>What the Project Is</span></a></h2>
<p>The project is an AI-powered voice assistant running on a Raspberry Pi.</p>
<p>At a high level, it does this:</p>
<ol>
<li>Captures speech from a USB microphone.</li>
<li>Converts speech to text.</li>
<li>Sends the text to an AI model.</li>
<li>Receives a response.</li>
<li>Converts the response to speech.</li>
<li>Plays the answer through a speaker.</li>
<li>Optionally captures images from a camera for visual questions.</li>
</ol>
<p>The design is intentionally hybrid. The Raspberry Pi does the local hardware work, while the AI model runs in the cloud.</p>
<p>That gives the best balance of cost, performance, and simplicity.</p>
<h2 id="why-not-run-everything-locally%3F" tabindex="-1"><a class="header-anchor" href="#why-not-run-everything-locally%3F"><span>Why Not Run Everything Locally?</span></a></h2>
<p>A Raspberry Pi can do a lot, but it is not ideal for running a modern large language model locally. Small models may run, but they are usually slow, limited, or frustrating for natural conversation.</p>
<p>Speech recognition is also computationally expensive. Lightweight local models can work, but they may be slower or less accurate than cloud-based speech-to-text services.</p>
<p>The better early design is:</p>
<pre class="code-block"><code class="hljs language-text">Raspberry Pi = local appliance
Cloud AI = intelligence layer
</code></pre>
<p>That means the Pi handles microphones, speakers, camera, wake word detection, and local actions. The cloud handles transcription, reasoning, and advanced language understanding.</p>
<p>This keeps the device responsive without needing expensive hardware.</p>
<h2 id="basic-architecture" tabindex="-1"><a class="header-anchor" href="#basic-architecture"><span>Basic Architecture</span></a></h2>
<pre class="mermaid">
flowchart TD
    A[&quot;User speaks&quot;] --&gt; B[&quot;USB microphone&quot;]
    B --&gt; C[&quot;Raspberry Pi audio capture&quot;]
    C --&gt; D[&quot;Speech-to-text service&quot;]
    D --&gt; E[&quot;AI chat model&quot;]
    E --&gt; F[&quot;Text response&quot;]
    F --&gt; G[&quot;Text-to-speech engine&quot;]
    G --&gt; H[&quot;Speaker output&quot;]
    H --&gt; I[&quot;User hears response&quot;]
</pre>
<p>The important design decision is that not every stage has to use the same provider. For example, the project could use one service for speech-to-text, another for the AI model, and a local engine for text-to-speech.</p>
<p>That makes the system flexible and cost-conscious.</p>
<h2 id="recommended-version-1" tabindex="-1"><a class="header-anchor" href="#recommended-version-1"><span>Recommended Version 1</span></a></h2>
<p>The first version should avoid unnecessary complexity.</p>
<p>A practical first milestone is a push-to-talk assistant:</p>
<pre class="code-block"><code class="hljs language-text">Press Enter → record speech → transcribe → ask AI → speak answer
</code></pre>
<p>This avoids wake-word detection, continuous listening, and streaming audio. Those can be added later once the basic loop works.</p>
<h3 id="version-1-components" tabindex="-1"><a class="header-anchor" href="#version-1-components"><span>Version 1 Components</span></a></h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Recommended Choice</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
<tr>
<td>Operating system</td>
<td>Raspberry Pi OS</td>
<td>Stable, common, well-supported</td>
</tr>
<tr>
<td>Language</td>
<td>Python</td>
<td>Simple hardware and API integration</td>
</tr>
<tr>
<td>Microphone</td>
<td>USB webcam microphone or USB mic</td>
<td>Raspberry Pi headphone jack is output-only</td>
</tr>
<tr>
<td>Camera</td>
<td>USB camera or Pi camera</td>
<td>Optional at first</td>
</tr>
<tr>
<td>Speech-to-text</td>
<td>Cloud STT, such as OpenAI Whisper-style transcription</td>
<td>Better accuracy and lower Pi workload</td>
</tr>
<tr>
<td>AI model</td>
<td>Cost-effective cloud model</td>
<td>Faster and better than local Pi models</td>
</tr>
<tr>
<td>Text-to-speech</td>
<td>Piper running locally</td>
<td>Free, fast, and avoids paying for every spoken reply</td>
</tr>
<tr>
<td>Audio playback</td>
<td>ALSA / <code>aplay</code></td>
<td>Simple and already available on Raspberry Pi OS</td>
</tr>
</tbody>
</table>
<h2 id="important-hardware-lesson" tabindex="-1"><a class="header-anchor" href="#important-hardware-lesson"><span>Important Hardware Lesson</span></a></h2>
<p>A standard Raspberry Pi headphone jack does not accept microphone input. It is for audio output only.</p>
<p>That means a phone-style headset with an inline microphone will not appear as a capture device.</p>
<p>To capture speech, one of these is needed:</p>
<ul>
<li>USB microphone</li>
<li>USB webcam with built-in microphone</li>
<li>USB sound card with microphone input</li>
<li>I2S microphone module</li>
<li>Microphone HAT or array board</li>
</ul>
<p>For a simple assistant, a USB webcam microphone or USB microphone is the easiest option.</p>
<p>Once the USB camera/microphone is plugged in, the Pi should show a capture device:</p>
<pre class="code-block"><code class="hljs language-bash">arecord -l
</code></pre>
<p>Example output:</p>
<pre class="code-block"><code class="hljs language-text">**** List of CAPTURE Hardware Devices ****
card 3: gadget [USB Webcam gadget], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0
</code></pre>
<p>That tells us the microphone is available as:</p>
<pre class="code-block"><code class="hljs language-text">plughw:3,0
</code></pre>
<p>A direct recording test can then be done with:</p>
<pre class="code-block"><code class="hljs language-bash">arecord -D plughw:3,0 -f S16_LE -r 16000 -c 1 -d 5 test.wav
aplay test.wav
</code></pre>
<p>If the recording plays back clearly, the microphone is working.</p>
<h2 id="proposed-tech-stack" tabindex="-1"><a class="header-anchor" href="#proposed-tech-stack"><span>Proposed Tech Stack</span></a></h2>
<pre class="mermaid">
flowchart LR
    subgraph Hardware[&quot;Hardware&quot;]
        Mic[&quot;USB microphone&quot;]
        Cam[&quot;USB / Pi camera&quot;]
        Speaker[&quot;Speaker or headphones&quot;]
    end

    subgraph Pi[&quot;Raspberry Pi&quot;]
        Python[&quot;Python assistant app&quot;]
        Audio[&quot;ALSA audio capture/playback&quot;]
        Piper[&quot;Piper local TTS&quot;]
        Skills[&quot;Local skills / commands&quot;]
    end

    subgraph Cloud[&quot;Cloud AI Services&quot;]
        STT[&quot;Speech-to-text&quot;]
        LLM[&quot;AI chat model&quot;]
        Vision[&quot;Vision model&quot;]
    end

    Mic --&gt; Audio
    Audio --&gt; Python
    Python --&gt; STT
    STT --&gt; LLM
    LLM --&gt; Python
    Python --&gt; Piper
    Piper --&gt; Speaker
    Cam --&gt; Python
    Python --&gt; Vision
    Vision --&gt; LLM
    Python --&gt; Skills
</pre>
<p>The Raspberry Pi becomes the physical interface. The AI services provide the intelligence. Local skills can be added later for actions that should not need cloud reasoning.</p>
<h2 id="software-to-install-on-the-raspberry-pi" tabindex="-1"><a class="header-anchor" href="#software-to-install-on-the-raspberry-pi"><span>Software to Install on the Raspberry Pi</span></a></h2>
<p>Assuming Raspberry Pi OS is already installed, updated, and accessible via SSH, the basic dependencies are:</p>
<pre class="code-block"><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> apt update
<span class="hljs-built_in">sudo</span> apt upgrade -y

<span class="hljs-built_in">sudo</span> apt install -y \
  git curl wget unzip jq \
  python3 python3-venv python3-pip \
  portaudio19-dev python3-pyaudio \
  libasound2-dev alsa-utils \
  ffmpeg sox \
  mpg123 \
  espeak-ng
</code></pre>
<p>For Python:</p>
<pre class="code-block"><code class="hljs language-bash"><span class="hljs-built_in">mkdir</span> -p ~/pi-ai-assistant
<span class="hljs-built_in">cd</span> ~/pi-ai-assistant

python3 -m venv --system-site-packages .venv
<span class="hljs-built_in">source</span> .venv/bin/activate

pip install --upgrade pip wheel setuptools

pip install \
  openai \
  sounddevice \
  scipy \
  numpy \
  python-dotenv \
  webrtcvad \
  requests
</code></pre>
<p>For camera support on Raspberry Pi OS:</p>
<pre class="code-block"><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> apt install -y python3-picamera2
</code></pre>
<p>The <code>--system-site-packages</code> option is useful because some Raspberry Pi camera packages are installed through the OS package manager rather than through <code>pip</code>.</p>
<h2 id="local-text-to-speech-with-piper" tabindex="-1"><a class="header-anchor" href="#local-text-to-speech-with-piper"><span>Local Text-to-Speech With Piper</span></a></h2>
<p>For cost control, spoken output should ideally be local.</p>
<p>Piper is a good fit because it runs locally and can generate natural enough speech without sending every AI response to a cloud text-to-speech provider.</p>
<p>A typical folder layout might look like this:</p>
<pre class="code-block"><code class="hljs language-text">~/pi-ai-assistant/
├── assistant.py
├── camera_test.py
├── .venv/
├── piper/
│   ├── piper/
│   │   └── piper
│   └── voices/
│       ├── en_GB-semaine-medium.onnx
│       └── en_GB-semaine-medium.onnx.json
└── snapshots/
</code></pre>
<p>The assistant can send text into Piper and play the resulting <code>.wav</code> file using <code>aplay</code>.</p>
<h2 id="core-voice-flow" tabindex="-1"><a class="header-anchor" href="#core-voice-flow"><span>Core Voice Flow</span></a></h2>
<p>The first working Python version only needs a few functions:</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Start assistant&quot;] --&gt; B[&quot;Wait for user input&quot;]
    B --&gt; C[&quot;Record 6 seconds of audio&quot;]
    C --&gt; D[&quot;Save temporary WAV file&quot;]
    D --&gt; E[&quot;Send audio for transcription&quot;]
    E --&gt; F[&quot;Send text to AI model&quot;]
    F --&gt; G[&quot;Receive answer&quot;]
    G --&gt; H[&quot;Generate speech locally with Piper&quot;]
    H --&gt; I[&quot;Play audio response&quot;]
    I --&gt; B
</pre>
<p>This is deliberately simple. It avoids the harder problem of knowing exactly when the user has finished speaking.</p>
<p>Later, the fixed six-second recording can be replaced with voice activity detection.</p>
<h2 id="basic-assistant-script" tabindex="-1"><a class="header-anchor" href="#basic-assistant-script"><span>Basic Assistant Script</span></a></h2>
<p>A minimal assistant can be structured like this:</p>
<pre class="code-block"><code class="hljs language-python"><span class="hljs-keyword">import</span> os
<span class="hljs-keyword">import</span> subprocess
<span class="hljs-keyword">import</span> tempfile
<span class="hljs-keyword">from</span> pathlib <span class="hljs-keyword">import</span> Path

<span class="hljs-keyword">import</span> sounddevice <span class="hljs-keyword">as</span> sd
<span class="hljs-keyword">from</span> scipy.io.wavfile <span class="hljs-keyword">import</span> write
<span class="hljs-keyword">from</span> openai <span class="hljs-keyword">import</span> OpenAI

client = OpenAI()

BASE_DIR = Path.home() / <span class="hljs-string">&quot;pi-ai-assistant&quot;</span>
PIPER_BIN = BASE_DIR / <span class="hljs-string">&quot;piper&quot;</span> / <span class="hljs-string">&quot;piper&quot;</span> / <span class="hljs-string">&quot;piper&quot;</span>
PIPER_MODEL = BASE_DIR / <span class="hljs-string">&quot;piper&quot;</span> / <span class="hljs-string">&quot;voices&quot;</span> / <span class="hljs-string">&quot;en_GB-semaine-medium.onnx&quot;</span>

SAMPLE_RATE = <span class="hljs-number">16000</span>
RECORD_SECONDS = <span class="hljs-number">6</span>
INPUT_DEVICE = <span class="hljs-literal">None</span>


<span class="hljs-keyword">def</span> <span class="hljs-title function_">record_audio</span>(<span class="hljs-params">path: <span class="hljs-built_in">str</span></span>):
    <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;Recording for <span class="hljs-subst">{RECORD_SECONDS}</span> seconds...&quot;</span>)
    audio = sd.rec(
        <span class="hljs-built_in">int</span>(RECORD_SECONDS * SAMPLE_RATE),
        samplerate=SAMPLE_RATE,
        channels=<span class="hljs-number">1</span>,
        dtype=<span class="hljs-string">&quot;int16&quot;</span>,
        device=INPUT_DEVICE,
    )
    sd.wait()
    write(path, SAMPLE_RATE, audio)
    <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;Recording complete.&quot;</span>)


<span class="hljs-keyword">def</span> <span class="hljs-title function_">transcribe_audio</span>(<span class="hljs-params">path: <span class="hljs-built_in">str</span></span>) -&gt; <span class="hljs-built_in">str</span>:
    <span class="hljs-keyword">with</span> <span class="hljs-built_in">open</span>(path, <span class="hljs-string">&quot;rb&quot;</span>) <span class="hljs-keyword">as</span> f:
        result = client.audio.transcriptions.create(
            model=<span class="hljs-string">&quot;gpt-4o-mini-transcribe&quot;</span>,
            file=f,
        )
    <span class="hljs-keyword">return</span> result.text.strip()


<span class="hljs-keyword">def</span> <span class="hljs-title function_">ask_ai</span>(<span class="hljs-params">user_text: <span class="hljs-built_in">str</span></span>) -&gt; <span class="hljs-built_in">str</span>:
    response = client.responses.create(
        model=<span class="hljs-string">&quot;gpt-4.1-mini&quot;</span>,
        <span class="hljs-built_in">input</span>=[
            {
                <span class="hljs-string">&quot;role&quot;</span>: <span class="hljs-string">&quot;system&quot;</span>,
                <span class="hljs-string">&quot;content&quot;</span>: (
                    <span class="hljs-string">&quot;You are a concise voice assistant running on a Raspberry Pi. &quot;</span>
                    <span class="hljs-string">&quot;Answer clearly and briefly.&quot;</span>
                ),
            },
            {
                <span class="hljs-string">&quot;role&quot;</span>: <span class="hljs-string">&quot;user&quot;</span>,
                <span class="hljs-string">&quot;content&quot;</span>: user_text,
            },
        ],
    )
    <span class="hljs-keyword">return</span> response.output_text.strip()


<span class="hljs-keyword">def</span> <span class="hljs-title function_">speak</span>(<span class="hljs-params">text: <span class="hljs-built_in">str</span></span>):
    <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> PIPER_BIN.exists():
        <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;Piper not found. Printing only.&quot;</span>)
        <span class="hljs-built_in">print</span>(text)
        <span class="hljs-keyword">return</span>

    <span class="hljs-keyword">with</span> tempfile.NamedTemporaryFile(suffix=<span class="hljs-string">&quot;.wav&quot;</span>, delete=<span class="hljs-literal">False</span>) <span class="hljs-keyword">as</span> tmp:
        output_path = tmp.name

    <span class="hljs-keyword">try</span>:
        piper = subprocess.Popen(
            [
                <span class="hljs-built_in">str</span>(PIPER_BIN),
                <span class="hljs-string">&quot;--model&quot;</span>,
                <span class="hljs-built_in">str</span>(PIPER_MODEL),
                <span class="hljs-string">&quot;--output_file&quot;</span>,
                output_path,
            ],
            stdin=subprocess.PIPE,
            text=<span class="hljs-literal">True</span>,
        )
        piper.communicate(text)

        subprocess.run([<span class="hljs-string">&quot;aplay&quot;</span>, output_path], check=<span class="hljs-literal">False</span>)
    <span class="hljs-keyword">finally</span>:
        <span class="hljs-keyword">try</span>:
            os.remove(output_path)
        <span class="hljs-keyword">except</span> FileNotFoundError:
            <span class="hljs-keyword">pass</span>


<span class="hljs-keyword">def</span> <span class="hljs-title function_">main</span>():
    <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;Pi AI Assistant ready.&quot;</span>)
    <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;Press Enter to speak. Type q then Enter to quit.&quot;</span>)

    <span class="hljs-keyword">while</span> <span class="hljs-literal">True</span>:
        command = <span class="hljs-built_in">input</span>(<span class="hljs-string">&quot;\nPress Enter to record &gt; &quot;</span>).strip().lower()
        <span class="hljs-keyword">if</span> command <span class="hljs-keyword">in</span> {<span class="hljs-string">&quot;q&quot;</span>, <span class="hljs-string">&quot;quit&quot;</span>, <span class="hljs-string">&quot;exit&quot;</span>}:
            <span class="hljs-keyword">break</span>

        <span class="hljs-keyword">with</span> tempfile.NamedTemporaryFile(suffix=<span class="hljs-string">&quot;.wav&quot;</span>, delete=<span class="hljs-literal">False</span>) <span class="hljs-keyword">as</span> tmp:
            audio_path = tmp.name

        <span class="hljs-keyword">try</span>:
            record_audio(audio_path)
            user_text = transcribe_audio(audio_path)

            <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> user_text:
                <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;No speech detected.&quot;</span>)
                <span class="hljs-keyword">continue</span>

            <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;You: <span class="hljs-subst">{user_text}</span>&quot;</span>)

            answer = ask_ai(user_text)
            <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;Assistant: <span class="hljs-subst">{answer}</span>&quot;</span>)

            speak(answer)

        <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
            <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;Error: <span class="hljs-subst">{e}</span>&quot;</span>)
            speak(<span class="hljs-string">&quot;Sorry, something went wrong.&quot;</span>)
        <span class="hljs-keyword">finally</span>:
            <span class="hljs-keyword">try</span>:
                os.remove(audio_path)
            <span class="hljs-keyword">except</span> FileNotFoundError:
                <span class="hljs-keyword">pass</span>


<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">&quot;__main__&quot;</span>:
    main()
</code></pre>
<p>This is not the final product, but it gives the project a working spine.</p>
<p>Once this works, every improvement becomes incremental.</p>
<h2 id="handling-audio-devices" tabindex="-1"><a class="header-anchor" href="#handling-audio-devices"><span>Handling Audio Devices</span></a></h2>
<p>One practical issue is that Linux audio devices can appear under different card numbers after reboot.</p>
<p>For example, a USB webcam microphone may appear as:</p>
<pre class="code-block"><code class="hljs language-text">card 3, device 0
</code></pre>
<p>So the test command would be:</p>
<pre class="code-block"><code class="hljs language-bash">arecord -D plughw:3,0 -f S16_LE -r 16000 -c 1 -d 5 test.wav
</code></pre>
<p>But after reboot, the same device might become card 1 or card 2.</p>
<p>For early testing, this is acceptable. Later, the assistant should use a more stable device name or configuration.</p>
<p>A useful debugging command in Python is:</p>
<pre class="code-block"><code class="hljs language-bash">python - &lt;&lt;<span class="hljs-string">&#x27;PY&#x27;</span>
import sounddevice as sd
<span class="hljs-built_in">print</span>(sd.query_devices())
PY
</code></pre>
<p>This lists devices as Python sees them, which helps when choosing the correct microphone input.</p>
<h2 id="adding-the-camera" tabindex="-1"><a class="header-anchor" href="#adding-the-camera"><span>Adding the Camera</span></a></h2>
<p>The camera should not be treated as always-on intelligence. That increases complexity, cost, and privacy risk.</p>
<p>A better design is on-demand vision.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-text">User: &quot;What do you see?&quot;
Assistant:
  1. Captures a still image.
  2. Sends the image and question to a vision model.
  3. Speaks the answer.
</code></pre>
<p>The camera flow looks like this:</p>
<pre class="mermaid">
sequenceDiagram
    participant User
    participant Pi as Raspberry Pi
    participant Camera
    participant Vision as Vision Model
    participant AI as Chat Model

    User-&gt;&gt;Pi: &quot;What do you see?&quot;
    Pi-&gt;&gt;Camera: Capture snapshot
    Camera--&gt;&gt;Pi: snapshot.jpg
    Pi-&gt;&gt;Vision: Send image with question
    Vision--&gt;&gt;Pi: Description / visual answer
    Pi-&gt;&gt;AI: Combine context and response
    AI--&gt;&gt;Pi: Final answer
    Pi--&gt;&gt;User: Spoken response
</pre>
<p>A basic camera test script could save a snapshot:</p>
<pre class="code-block"><code class="hljs language-python"><span class="hljs-keyword">from</span> picamera2 <span class="hljs-keyword">import</span> Picamera2
<span class="hljs-keyword">import</span> time

camera = Picamera2()
config = camera.create_still_configuration()
camera.configure(config)
camera.start()

time.sleep(<span class="hljs-number">1</span>)

camera.capture_file(<span class="hljs-string">&quot;snapshot.jpg&quot;</span>)
camera.stop()

<span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;Saved snapshot.jpg&quot;</span>)
</code></pre>
<p>For a USB camera, the implementation may use OpenCV instead of Picamera2.</p>
<h2 id="what-the-assistant-could-do" tabindex="-1"><a class="header-anchor" href="#what-the-assistant-could-do"><span>What the Assistant Could Do</span></a></h2>
<p>Once the basic voice loop works, the project can become much more useful.</p>
<p>Possible capabilities include:</p>
<h3 id="general-conversation" tabindex="-1"><a class="header-anchor" href="#general-conversation"><span>General Conversation</span></a></h3>
<p>The assistant can answer ordinary questions:</p>
<pre class="code-block"><code class="hljs language-text">&quot;What is the difference between RAM and storage?&quot;
&quot;Explain Docker like I&#x27;m new to it.&quot;
&quot;Give me three ideas for dinner.&quot;
</code></pre>
<h3 id="coding-helper" tabindex="-1"><a class="header-anchor" href="#coding-helper"><span>Coding Helper</span></a></h3>
<p>Since the Pi is accessible over SSH and can sit on a desk, it can become a voice-driven development companion:</p>
<pre class="code-block"><code class="hljs language-text">&quot;Summarise this error.&quot;
&quot;What does this command do?&quot;
&quot;Remind me of the Git command to undo the last commit.&quot;
</code></pre>
<h3 id="local-device-controller" tabindex="-1"><a class="header-anchor" href="#local-device-controller"><span>Local Device Controller</span></a></h3>
<p>Local commands can be routed without needing the AI model to invent shell commands.</p>
<p>Examples:</p>
<pre class="code-block"><code class="hljs language-text">&quot;What is the CPU temperature?&quot;
&quot;How much disk space is left?&quot;
&quot;Restart the service.&quot;
&quot;Take a photo.&quot;
&quot;Check if Docker is running.&quot;
</code></pre>
<p>These should be implemented as safe predefined functions, not arbitrary AI-generated terminal commands.</p>
<h3 id="home-assistant-integration" tabindex="-1"><a class="header-anchor" href="#home-assistant-integration"><span>Home Assistant Integration</span></a></h3>
<p>The Pi could call a Home Assistant API or MQTT broker:</p>
<pre class="code-block"><code class="hljs language-text">&quot;Turn on the office light.&quot;
&quot;Set the room to evening mode.&quot;
&quot;Is the garage door open?&quot;
</code></pre>
<p>In this design, the AI interprets the request, but the actual action is executed by a controlled integration.</p>
<h3 id="visual-assistant" tabindex="-1"><a class="header-anchor" href="#visual-assistant"><span>Visual Assistant</span></a></h3>
<p>With a camera, it can answer visual questions:</p>
<pre class="code-block"><code class="hljs language-text">&quot;What is on my desk?&quot;
&quot;Can you read this label?&quot;
&quot;Does this cable look plugged in?&quot;
&quot;What does the screen say?&quot;
</code></pre>
<p>This could be especially useful as a physical troubleshooting assistant.</p>
<h3 id="personal-desk-console" tabindex="-1"><a class="header-anchor" href="#personal-desk-console"><span>Personal Desk Console</span></a></h3>
<p>It could become a small local AI terminal:</p>
<pre class="code-block"><code class="hljs language-text">&quot;What am I working on today?&quot;
&quot;Summarise my project notes.&quot;
&quot;Create a checklist for setting up this Pi.&quot;
&quot;Give me the next three steps.&quot;
</code></pre>
<p>If connected to local files or a project folder, it could answer questions about specific work.</p>
<h2 id="skill-routing" tabindex="-1"><a class="header-anchor" href="#skill-routing"><span>Skill Routing</span></a></h2>
<p>A serious assistant should not send every request blindly to a general-purpose model.</p>
<p>Instead, it should classify intent and route requests.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;User request&quot;] --&gt; B[&quot;Transcribe speech&quot;]
    B --&gt; C[&quot;Intent router&quot;]

    C --&gt; D[&quot;General AI chat&quot;]
    C --&gt; E[&quot;Local system command&quot;]
    C --&gt; F[&quot;Camera snapshot&quot;]
    C --&gt; G[&quot;Home automation&quot;]
    C --&gt; H[&quot;Timer / reminder&quot;]
    C --&gt; I[&quot;Project notes lookup&quot;]

    D --&gt; J[&quot;Spoken response&quot;]
    E --&gt; J
    F --&gt; J
    G --&gt; J
    H --&gt; J
    I --&gt; J
</pre>
<p>This matters because it keeps the assistant safer, faster, and cheaper.</p>
<p>For example, asking “what time is it?” should not need a cloud AI call. Asking “what is the temperature of the Pi?” should run a local command. Asking “explain the difference between these two architecture choices” can go to the AI model.</p>
<h2 id="cost-control" tabindex="-1"><a class="header-anchor" href="#cost-control"><span>Cost Control</span></a></h2>
<p>The biggest cost risks are:</p>
<ul>
<li>Sending continuous audio to the cloud</li>
<li>Using premium models for every request</li>
<li>Using cloud text-to-speech for every response</li>
<li>Sending camera frames continuously</li>
<li>Allowing long conversations to grow without limits</li>
</ul>
<p>The cost-effective design is:</p>
<pre class="code-block"><code class="hljs language-text">Local wake word
Local silence detection
Cloud speech-to-text only after activation
Cheap/fast AI model by default
Local Piper text-to-speech
On-demand camera only
</code></pre>
<p>A premium model can still be used for harder questions, but it should not be the default for every interaction.</p>
<h2 id="development-roadmap" tabindex="-1"><a class="header-anchor" href="#development-roadmap"><span>Development Roadmap</span></a></h2>
<h3 id="phase-1%3A-prove-the-audio-loop" tabindex="-1"><a class="header-anchor" href="#phase-1%3A-prove-the-audio-loop"><span>Phase 1: Prove the Audio Loop</span></a></h3>
<p>Goal:</p>
<pre class="code-block"><code class="hljs language-text">Record → transcribe → ask AI → speak answer
</code></pre>
<p>This confirms that the microphone, API key, Python environment, and speaker output all work.</p>
<h3 id="phase-2%3A-add-better-recording" tabindex="-1"><a class="header-anchor" href="#phase-2%3A-add-better-recording"><span>Phase 2: Add Better Recording</span></a></h3>
<p>Replace fixed six-second recording with silence detection.</p>
<p>This allows the user to speak naturally without needing to fit into a fixed time window.</p>
<h3 id="phase-3%3A-add-wake-word" tabindex="-1"><a class="header-anchor" href="#phase-3%3A-add-wake-word"><span>Phase 3: Add Wake Word</span></a></h3>
<p>Add a wake-word engine such as Porcupine or openWakeWord.</p>
<p>The assistant then behaves more like:</p>
<pre class="code-block"><code class="hljs language-text">&quot;Hey Pi&quot; → listen → answer
</code></pre>
<p>This is the first point where it starts to feel Alexa-like.</p>
<h3 id="phase-4%3A-add-camera-awareness" tabindex="-1"><a class="header-anchor" href="#phase-4%3A-add-camera-awareness"><span>Phase 4: Add Camera Awareness</span></a></h3>
<p>Add commands such as:</p>
<pre class="code-block"><code class="hljs language-text">&quot;What do you see?&quot;
&quot;Take a photo.&quot;
&quot;Read this.&quot;
</code></pre>
<p>This should be on-demand, not continuous.</p>
<h3 id="phase-5%3A-add-local-skills" tabindex="-1"><a class="header-anchor" href="#phase-5%3A-add-local-skills"><span>Phase 5: Add Local Skills</span></a></h3>
<p>Add controlled local actions:</p>
<pre class="code-block"><code class="hljs language-text">Check CPU temperature
Check disk space
Restart a known service
Read a local note
Call a local HTTP endpoint
Control Home Assistant
</code></pre>
<h3 id="phase-6%3A-run-as-a-service" tabindex="-1"><a class="header-anchor" href="#phase-6%3A-run-as-a-service"><span>Phase 6: Run as a Service</span></a></h3>
<p>Once stable, run the assistant under <code>systemd</code> so it starts automatically on boot.</p>
<p>Example service shape:</p>
<pre class="code-block"><code class="hljs language-ini"><span class="hljs-section">[Unit]</span>
<span class="hljs-attr">Description</span>=Raspberry Pi AI Voice Assistant
<span class="hljs-attr">After</span>=network-<span class="hljs-literal">on</span>line.target sound.target
<span class="hljs-attr">Wants</span>=network-<span class="hljs-literal">on</span>line.target

<span class="hljs-section">[Service]</span>
<span class="hljs-attr">Type</span>=simple
<span class="hljs-attr">User</span>=dian
<span class="hljs-attr">WorkingDirectory</span>=/home/dian/pi-ai-assistant
<span class="hljs-attr">Environment</span>=OPENAI_API_KEY=your_api_key_here
<span class="hljs-attr">ExecStart</span>=/home/dian/pi-ai-assistant/.venv/bin/python /home/dian/pi-ai-assistant/assistant.py
<span class="hljs-attr">Restart</span>=always
<span class="hljs-attr">RestartSec</span>=<span class="hljs-number">5</span>

<span class="hljs-section">[Install]</span>
<span class="hljs-attr">WantedBy</span>=multi-user.target
</code></pre>
<p>The API key should eventually be handled more safely than hardcoding it directly in the service file, but this shape is enough to understand the moving parts.</p>
<h2 id="security-considerations" tabindex="-1"><a class="header-anchor" href="#security-considerations"><span>Security Considerations</span></a></h2>
<p>A voice assistant that can run commands must be treated carefully.</p>
<p>The assistant should not be allowed to execute arbitrary shell commands generated by an AI model.</p>
<p>Instead, use a whitelist:</p>
<pre class="code-block"><code class="hljs language-python">ALLOWED_ACTIONS = {
    <span class="hljs-string">&quot;cpu_temperature&quot;</span>: get_cpu_temperature,
    <span class="hljs-string">&quot;disk_space&quot;</span>: get_disk_space,
    <span class="hljs-string">&quot;take_photo&quot;</span>: take_photo,
    <span class="hljs-string">&quot;restart_known_service&quot;</span>: restart_known_service,
}
</code></pre>
<p>The model can choose from known actions, but the code decides what those actions are allowed to do.</p>
<p>This distinction is important.</p>
<p>Bad design:</p>
<pre class="code-block"><code class="hljs language-text">User asks → AI writes shell command → Pi executes it
</code></pre>
<p>Better design:</p>
<pre class="code-block"><code class="hljs language-text">User asks → AI identifies intent → Pi runs predefined safe function
</code></pre>
<h2 id="privacy-considerations" tabindex="-1"><a class="header-anchor" href="#privacy-considerations"><span>Privacy Considerations</span></a></h2>
<p>The microphone should not constantly stream to the cloud.</p>
<p>A safer design is:</p>
<pre class="code-block"><code class="hljs language-text">Local wake word detection
Then record
Then send only the captured request
</code></pre>
<p>The camera should also be explicit:</p>
<pre class="code-block"><code class="hljs language-text">Only capture when the user asks a visual question
</code></pre>
<p>This keeps the assistant useful without turning it into a constant surveillance device.</p>
<h2 id="possible-final-form" tabindex="-1"><a class="header-anchor" href="#possible-final-form"><span>Possible Final Form</span></a></h2>
<p>The finished assistant could become a small desk-based AI companion:</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Raspberry Pi AI Assistant&quot;] --&gt; B[&quot;Voice chat&quot;]
    A --&gt; C[&quot;Camera-based visual help&quot;]
    A --&gt; D[&quot;Local system tools&quot;]
    A --&gt; E[&quot;Home automation&quot;]
    A --&gt; F[&quot;Project assistant&quot;]
    A --&gt; G[&quot;Reminders and timers&quot;]
    A --&gt; H[&quot;Developer helper&quot;]

    B --&gt; B1[&quot;Ask questions&quot;]
    B --&gt; B2[&quot;Have short conversations&quot;]

    C --&gt; C1[&quot;Describe scene&quot;]
    C --&gt; C2[&quot;Read text&quot;]
    C --&gt; C3[&quot;Troubleshoot physical objects&quot;]

    D --&gt; D1[&quot;CPU temperature&quot;]
    D --&gt; D2[&quot;Disk space&quot;]
    D --&gt; D3[&quot;Service status&quot;]

    E --&gt; E1[&quot;Lights&quot;]
    E --&gt; E2[&quot;Sensors&quot;]
    E --&gt; E3[&quot;MQTT / Home Assistant&quot;]

    F --&gt; F1[&quot;Read local notes&quot;]
    F --&gt; F2[&quot;Summarise project state&quot;]
    F --&gt; F3[&quot;Suggest next steps&quot;]
</pre>
<p>The useful part is not merely that it talks. The useful part is that it bridges the physical room, local devices, project context, and cloud intelligence.</p>
<h2 id="what-should-be-built-first" tabindex="-1"><a class="header-anchor" href="#what-should-be-built-first"><span>What Should Be Built First</span></a></h2>
<p>The correct first version is intentionally modest.</p>
<p>Build this first:</p>
<pre class="code-block"><code class="hljs language-text">USB microphone
Python script
Cloud speech-to-text
Cloud AI response
Local Piper speech output
Manual press-to-talk loop
</code></pre>
<p>Only after that works reliably should the project add:</p>
<pre class="code-block"><code class="hljs language-text">Wake word
Silence detection
Camera vision
Local action routing
systemd startup
Home Assistant integration
</code></pre>
<p>That progression avoids wasting time on complicated assistant behaviour before the basic hardware and software path is proven.</p>
<h2 id="conclusion" tabindex="-1"><a class="header-anchor" href="#conclusion"><span>Conclusion</span></a></h2>
<p>A Raspberry Pi AI assistant is very achievable if the project is built in layers. The Pi should not try to be the entire AI system. It should be the local appliance: microphone, speaker, camera, wake word, and safe local actions. The cloud AI service should handle the heavy language and speech intelligence.</p>
<p>The most efficient and cost-effective path is:</p>
<pre class="code-block"><code class="hljs language-text">Start simple.
Use the cloud where the Pi is weak.
Run locally where cloud cost is unnecessary.
Add intelligence in controlled layers.
</code></pre>
<p>That gives a practical assistant now, with a clear route toward something much more capable later.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Building my own blog engine in an afternoon</title>
      <link>https://ikoonman.io/blog/building-my-own-blog-engine/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/building-my-own-blog-engine/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>Why I stopped fighting an off-the-shelf static site generator and wrote the ~43 kB engine that runs ikoonman.io instead.</description>
      <category>static-sites</category>
      <category>node</category>
      <category>markdown</category>
      <category>tooling</category>
      <content:encoded><![CDATA[<p>I wanted a blog. Not a platform, not a CMS, not a subscription — a place at <a href="http://ikoonman.io">ikoonman.io</a> where I could write a Markdown file, run one command, and have it appear. Free to run, fast to publish, and simple enough that I would still understand it six months later.</p>
<p>So I did the sensible thing first and went looking at what already exists. WordPress was the obvious candidate, and it is genuinely good at what it does — but it is a database, a PHP runtime, an admin interface, an update treadmill and a security surface, all standing between me and a text file. That is a fair trade for a site with editors, plugins and comment threads. For one person publishing occasional posts it is an installation to maintain in exchange for nothing I actually wanted.</p>
<p>That pointed me at the open-source static site generators instead, which are much closer to the right shape. I picked one, installed it, and then spent the next stretch of time doing everything except writing: reading theme documentation, working out which of three config files owned a setting, tracing why a layout override was ignored, and discovering that the small visual change I wanted lived somewhere inside a theme I had not written and did not want to learn.</p>
<p>None of that was the generator’s fault. It was solving a much bigger problem than mine. But at some point the frustration tipped over into curiosity: how much of this do I actually need?</p>
<p>The honest answer turned out to be <em>very little</em>. A couple of hours later I had a working engine, and about thirty minutes after starting it I had a real post rendered and published. This project was an absolute pleasure to work on — one of those rare ones where the scope stays exactly where you put it.</p>
<h2 id="what-it-actually-is" tabindex="-1"><a class="header-anchor" href="#what-it-actually-is"><span>What it actually is</span></a></h2>
<p>The whole engine is one Node script, three HTML templates and a stylesheet:</p>
<pre class="code-block"><code class="hljs language-text">build.js       the entire build, ~25 kB, read top to bottom
templates/     layout.html, post.html, index.html, contact.html
src/styles.css the whole design
blog/          Markdown posts, one flat folder
public/        assets copied verbatim into dist/
dist/          generated output — safe to delete, never edited by hand
</code></pre>
<p>That comes to roughly 43 kB of source in total. Five npm dependencies do the heavy lifting: <a href="https://github.com/markdown-it/markdown-it">markdown-it</a> for rendering, <a href="https://github.com/valeriangalliat/markdown-it-anchor">markdown-it-anchor</a> for heading links, <a href="https://github.com/jonschlinkert/gray-matter">gray-matter</a> for frontmatter, <a href="https://highlightjs.org/">highlight.js</a> for code, and fs-extra for file operations.</p>
<p>There is no database, no admin interface, and nothing to install on the server beyond Node and nginx. The build runs on my laptop or over SSH and produces plain static HTML files; nginx serves those files and does nothing else. Nothing executes when a visitor loads a page, so there is no login to protect, no schema to migrate, and no backup to take that a <code>git clone</code> does not already cover. The posts <em>are</em> the backup — they are Markdown files in a folder.</p>
<p>Writing a post means creating <code>blog/&lt;slug&gt;.md</code>:</p>
<pre class="code-block"><code class="hljs language-markdown">---
title: A clear, specific title
date: 2026-09-10
tags:
<span class="hljs-bullet">  -</span> static-sites
slug: optional-override    # defaults to the filename
draft: false               # drafts are excluded from the build
<span class="hljs-section">description: Optional      # otherwise the first paragraph is used
---</span>

Markdown, raw HTML, and inline <span class="hljs-code">`style`</span> attributes all work.
</code></pre>
<p><code>blog/my-post.md</code> becomes <code>dist/blog/my-post/index.html</code>, served at <code>/blog/my-post/</code>. Only <code>title</code> and <code>date</code> are required. A missing title, an unparseable date, or a slug that collides with an existing post fails the build loudly — I would much rather see an error in the terminal than a broken page on the live site.</p>
<h2 id="how-the-build-works" tabindex="-1"><a class="header-anchor" href="#how-the-build-works"><span>How the build works</span></a></h2>
<p>The pipeline is deliberately linear. Nothing is incremental, nothing is cached, and the whole site rebuilds in milliseconds because there is almost nothing to do.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;blog/*.md&quot;] --&gt; B[&quot;gray-matter: split frontmatter from body&quot;]
    B --&gt; C[&quot;Validate: title, date, unique slug&quot;]
    C --&gt; D[&quot;markdown-it: render body to HTML&quot;]
    D --&gt; E[&quot;Custom fence rule&quot;]
    E --&gt; F[&quot;mermaid fence → pre.mermaid, rendered in the browser&quot;]
    E --&gt; G[&quot;Other languages → highlight.js at build time&quot;]
    F --&gt; H[&quot;Post objects: slug, dates, tags, reading time, excerpt, prev/next&quot;]
    G --&gt; H
    I[&quot;templates/*.html&quot;] --&gt; J[&quot;{{name}} substitution&quot;]
    H --&gt; J
    J --&gt; K[&quot;dist/&quot;]
    L[&quot;public/ + docs/banners/&quot;] --&gt; K
    M[&quot;src/styles.css + code theme&quot;] --&gt; N[&quot;Concatenate, hash, styles.HASH.css&quot;]
    N --&gt; K
    K --&gt; O[&quot;Post pages, homepage, archive, contact&quot;]
    K --&gt; P[&quot;rss.xml, sitemap.xml, robots.txt&quot;]
</pre>
<p>The key point the diagram makes is that there is exactly one path from a Markdown file to a page, and one templating mechanism holding it together. Posts are loaded and validated, rendered to HTML, decorated with derived metadata, poured into templates via <code>{{name}}</code> substitution, and written to disk alongside the copied assets and the feed. There is no plugin system, no theme layer, no lifecycle hooks. When something looks wrong on the page, the code that produced it is in one file and I can find it by reading downwards.</p>
<p>The template engine is four lines:</p>
<pre class="code-block"><code class="hljs language-js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">render</span>(<span class="hljs-params">template, vars</span>) {
  <span class="hljs-keyword">return</span> template.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/\{\{\s*([\w.]+)\s*\}\}/g</span>, <span class="hljs-function">(<span class="hljs-params">match, key</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> value = vars[key];
    <span class="hljs-keyword">return</span> value === <span class="hljs-literal">undefined</span> || value === <span class="hljs-literal">null</span> ? <span class="hljs-string">&#x27;&#x27;</span> : <span class="hljs-title class_">String</span>(value);
  });
}
</code></pre>
<p>That is the entire abstraction. No conditionals, no loops, no partials. Anything that needs logic is a small JavaScript function that returns an HTML string, which is a perfectly good template language when you already know JavaScript.</p>
<h2 id="what-it-supports" tabindex="-1"><a class="header-anchor" href="#what-it-supports"><span>What it supports</span></a></h2>
<p><strong>Markdown, with the typographer on.</strong> Standard CommonMark plus smart quotes, dashes, and automatic linkification of bare URLs. Headings at levels 2–4 get permalink anchors.</p>
<p><strong>Raw HTML and inline CSS.</strong> <code>html: true</code> is set intentionally. When Markdown is not expressive enough — a two-column block, a bit of colour, a <code>&lt;details&gt;</code> disclosure — I drop into HTML in the middle of a post and carry on. This is a single-author site, so the usual reason to sanitise Markdown does not apply; the escape hatch is worth more than the restriction.</p>
<p><strong>Embedded media.</strong> Anything in <code>public/</code> is copied to the root of <code>dist/</code>, so <code>public/images/diagram.webp</code> is referenced as <code>/images/diagram.webp</code>. Video works the same way — a plain <code>&lt;video&gt;</code> tag with a file from <code>public/</code>, no plugin or shortcode involved.</p>
<p><strong>An image viewer.</strong> After the page loads, every image in the post body is wrapped in a button that opens it in a native <code>&lt;dialog&gt;</code> lightbox. Using the browser’s own dialog element means Escape, focus trapping, and backdrop clicks all work without me implementing any of them. If an image has a <code>data-full</code> attribute pointing at a larger original, the lightbox opens that instead, so pages can ship a smaller display copy.</p>
<p><strong>Mermaid diagrams.</strong> A fenced block tagged <code>mermaid</code> is emitted as <code>&lt;pre class=&quot;mermaid&quot;&gt;</code> rather than highlighted as code, and Mermaid 11 renders it in the browser. The loader script is only injected into pages that actually contain a diagram — the build checks the rendered HTML for <code>class=&quot;mermaid&quot;</code> and adds the module import only where it is needed. Every other fenced block is highlighted at build time, so ordinary posts ship no JavaScript for code at all.</p>
<p><strong>Syntax highlighting with no client-side cost.</strong> highlight.js runs during the build and its GitHub theme is concatenated onto the stylesheet, which is then content-hashed into <code>styles.&lt;hash&gt;.css</code> so a design change invalidates caches by itself.</p>
<p><strong>RSS, sitemap, robots.</strong> The feed carries the twenty most recent posts with full content, not truncated summaries. The sitemap lists every published URL with the post’s own date as <code>lastmod</code>.</p>
<p><strong>Derived metadata I never have to type.</strong> Reading time from a word count, an excerpt taken from the first real paragraph when no description is given, previous/next navigation between adjacent posts, and tag lists.</p>
<p><strong>A contact form with no backend.</strong> The form posts to <a href="https://web3forms.com/">Web3Forms</a>, which relays submissions to email. A static site stays static.</p>
<p><strong>Rotating banners.</strong> Each page picks a banner image at random, but tracks what it has shown in <code>sessionStorage</code> so every image appears once before any of them repeats. Intrinsic width and height are read directly out of the PNG, WebP, or SVG header at build time — about forty lines of byte-offset parsing instead of an image library — so the page reserves the right space and does not jump while the image loads.</p>
<h2 id="publishing" tabindex="-1"><a class="header-anchor" href="#publishing"><span>Publishing</span></a></h2>
<p>Local development is <code>npm run dev</code>: a watcher on <code>blog/</code>, <code>templates/</code>, <code>src/</code>, <code>public/</code> and the banners folder, a debounced rebuild, and a small static server on port 3000. <code>npm run drafts</code> does the same but includes posts marked <code>draft: true</code>, so unfinished writing is visible to me and invisible to everyone else.</p>
<p>Releases are a tarball with a checksum. <code>package.sh</code> builds the archive, <code>deploy.sh</code> installs the engine on the server and installs dependencies there, and <code>publish.sh</code> rebuilds the live site from whatever is currently in the server’s <code>blog/</code> directory. Deployment never overwrites posts or uploaded images, and both scripts take a lock directory so a deploy and a publish cannot interleave. Publishing a new post is: copy the Markdown up, run <code>publish.sh</code>. No pipeline, no build minutes, no vendor.</p>
<h2 id="was-it-worth-it%3F" tabindex="-1"><a class="header-anchor" href="#was-it-worth-it%3F"><span>Was it worth it?</span></a></h2>
<p>For a personal blog, easily. The trade is real and worth stating plainly: I gave up an ecosystem. There are no plugins, no themes, no community answers to search when something breaks, and no one else maintaining it. If this were a site with several authors, non-technical editors, comments, or requirements I could not predict, that would be the wrong trade — I would install WordPress or learn a mature generator’s theme system properly, and be glad the work had already been done.</p>
<p>But my requirements were fully known on day one, and they were small. The cost of learning someone else’s abstraction over a large problem turned out to be higher than the cost of writing my own small solution to a small one. And there is something genuinely pleasant about a project where reading the source <em>is</em> reading the documentation — where the answer to “why does the page look like that” is always about forty lines away.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Compass: Deciding Where AI Actually Fits in How You Build Software</title>
      <link>https://ikoonman.io/blog/introducing-compass/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/introducing-compass/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>Compass is a decision-support tool that helps software teams assess their delivery practices and platform foundations, then turn that context into a deliberate, explainable AI adoption plan.</description>
      <category>ai-adoption</category>
      <category>software-delivery</category>
      <category>architecture</category>
      <category>decision-support</category>
      <content:encoded><![CDATA[<p>Most teams do not have an AI problem. They have a decision problem that happens to be about AI.</p>
<h2 id="the-situation-this-is-built-for" tabindex="-1"><a class="header-anchor" href="#the-situation-this-is-built-for"><span>The situation this is built for</span></a></h2>
<p>A familiar version goes like this. Someone senior asks what the team is doing about AI. The answer is honest but unsatisfying: a few engineers use an assistant in their editor, someone prototyped a chatbot against internal documents, and there is a slide deck circulating about agents. Meanwhile the list of candidate tools grows every month, each with a persuasive demo and a different assumption about how you already work.</p>
<p>Readiness inside the team is uneven. One squad has strong test coverage and fast CI; another is maintaining a service where a change takes three days to verify. The data that would make a promising use case viable is scattered across systems with unclear ownership. Nobody is quite sure which constraints are real and which are habits.</p>
<p>The instinctive response is to pick tools. That is where the trouble starts, because a tool choice made in isolation quietly assumes several things are already true:</p>
<ul>
<li>That your delivery practices can absorb the change — that code review, testing, and release processes will still hold when more code arrives faster.</li>
<li>That the platform underneath can support the use case — that the data is accessible, described, and trustworthy enough to be worth pointing a model at.</li>
<li>That the team can supervise the output — that someone can tell a good result from a plausible-looking wrong one, and has the time to check.</li>
</ul>
<p>When those assumptions do not hold, the tool is not the thing that fails. Delivery is. The pilot stalls, the enthusiasm drains, and the organisation concludes that “AI didn’t work for us” when what actually happened is that a reasonable idea was applied to an unready context.</p>
<h2 id="what-compass-is" tabindex="-1"><a class="header-anchor" href="#what-compass-is"><span>What Compass is</span></a></h2>
<p>Compass is a decision-support application for AI adoption in software delivery and platform architecture. Its central idea is straightforward: assess the context first, understand the trade-offs that context creates, and turn the findings into a practical adoption plan the team can actually run.</p>
<p>It is not an autonomous engineering team. It does not write your services, deploy your changes, or enforce policy on your behalf. It is also not a universal AI strategy product — it is deliberately scoped to how software teams build, ship, and operate systems.</p>
<p>The work is organised around two complementary perspectives:</p>
<ul>
<li><strong>Software delivery.</strong> Which AI tools and workflows fit the way this team actually builds and ships software?</li>
<li><strong>Platform architecture.</strong> Where could AI add value inside this system, and which foundations need attention before that is sensible?</li>
</ul>
<p>Teams often start with one and discover they need the other. A delivery assessment surfaces a promising use case that turns out to depend on data the platform cannot yet serve. An architecture assessment identifies a strong opportunity that the team has no realistic capacity to supervise. Holding both perspectives is the point.</p>
<h2 id="how-it-works" tabindex="-1"><a class="header-anchor" href="#how-it-works"><span>How it works</span></a></h2>
<p>You describe the project and the team: what you are building, who is on it, how you work now, which tools you already use, and the constraints you are operating under. Where a platform perspective is relevant, you describe the capabilities that matter for it.</p>
<p>From there you explore delivery needs, platform needs, or both, through guided assessments. Compass uses that context to produce findings you can review rather than verdicts you have to accept: AI readiness and project complexity insights, recommendations across the software development lifecycle, architecture capability and gap analysis, candidate AI opportunities with their prerequisites, and risk, oversight and governance considerations attached to each.</p>
<p>You then compare possible changes. Save an assessment, adjust the assumptions, and look at an alternative scenario side by side — a more cautious rollout against a more ambitious one, or the same ambition with a different sequence. From the comparison you agree priorities and a phased plan, and produce reports you can take into a leadership conversation.</p>
<p>Later, when the situation has moved, you revisit the assessment.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Project and team context&quot;] --&gt; B[&quot;Guided assessment&quot;]
    B --&gt; C[&quot;Software delivery perspective&quot;]
    B --&gt; D[&quot;Platform architecture perspective&quot;]
    C --&gt; E[&quot;Findings and recommendations&quot;]
    D --&gt; E
    E --&gt; F[&quot;Compare options and priorities&quot;]
    F --&gt; G[&quot;Phased adoption plan&quot;]
    G --&gt; H[&quot;Reassess as circumstances change&quot;]
    H --&gt; B
</pre>
<p>The loop at the end of that diagram is a team activity, not continuous automated monitoring. Nothing is watching your repositories and silently updating a score. Reassessment happens when you decide something has changed enough to warrant it — a platform capability landed, a team grew, a pilot taught you something. Saved assessments, history, and trends exist so that when you come back, you are comparing against what you actually thought last time rather than against memory.</p>
<p>Shared workspaces and comments serve the same purpose. The output of an assessment is most useful as something a group argues with.</p>
<h2 id="a-worked-example-(hypothetical)" tabindex="-1"><a class="header-anchor" href="#a-worked-example-(hypothetical)"><span>A worked example (hypothetical)</span></a></h2>
<p><em>The following scenario is illustrative. It is not a customer case study, and it does not represent specific Compass outputs.</em></p>
<p>Imagine a team of nine maintaining an established logistics application. The codebase is a decade old in places, test coverage is patchy in exactly the modules that change most, and the release cadence is fortnightly. Two engineers use an AI assistant informally; nobody else does. A board member has asked why delivery is not faster.</p>
<p>Working through a delivery assessment, the team articulates something they already half-knew: their bottleneck is not typing code, it is the time between “change written” and “change trusted”. That reframes the shortlist. Generating more code faster into a codebase with weak verification is not obviously an improvement. Bounded assistance in areas with clear feedback — test scaffolding, refactoring under existing coverage, review support — looks more defensible as a first step.</p>
<p>An architecture perspective adds a second finding. The team is interested in an AI-assisted feature for exception handling in shipment data, and the case for it is genuinely good. But the assessment surfaces prerequisites: the relevant data is spread across two systems with inconsistent identifiers, and there is no established path for a human to review and correct a suggested resolution. That does not kill the idea. It sequences it.</p>
<p>The team then compares a more ambitious scenario — pursuing the customer-facing feature within the quarter — against the cautious one. Seeing the prerequisites and oversight requirements laid out next to the timeline makes the trade-off concrete rather than rhetorical. They choose the cautious sequence, with the ambitious use case explicitly parked and a review checkpoint set for when the data work completes.</p>
<p>The valuable output here is not a tool recommendation. It is a defensible answer to “why aren’t we doing the exciting thing yet”, and a date on which that answer gets re-examined.</p>
<h2 id="who-it-is-for" tabindex="-1"><a class="header-anchor" href="#who-it-is-for"><span>Who it is for</span></a></h2>
<ul>
<li><strong>Engineering leaders</strong> deciding where adoption effort goes first across several teams with different readiness, and needing to explain that ordering to people above and below them.</li>
<li><strong>Technical founders</strong> balancing genuine ambition against a small team’s capacity, who need to know which single change is worth the disruption this quarter.</li>
<li><strong>Architects</strong> evaluating whether the platform can support a proposed AI capability, and what has to be true first.</li>
<li><strong>Delivery managers</strong> working out how a change lands in existing workflows — what it does to review load, testing, estimation, and the shape of a sprint.</li>
<li><strong>Consultants</strong> who need a repeatable way to structure a client assessment and communicate recommendations that hold up under challenge.</li>
</ul>
<h2 id="why-this-shape-of-tool-works" tabindex="-1"><a class="header-anchor" href="#why-this-shape-of-tool-works"><span>Why this shape of tool works</span></a></h2>
<p><strong>Context makes recommendations relevant.</strong> A recommendation that ignores your test coverage, your release process, and your team’s experience is a generic list. The same recommendation, produced against a described context, can be argued with on its merits.</p>
<p><strong>Two perspectives expose different constraints.</strong> Delivery assessment finds workflow and capacity limits. Architecture assessment finds data, integration, and foundation limits. Teams that only look at one tend to be surprised by the other.</p>
<p><strong>Explanations matter more than conclusions.</strong> Findings come with the reasoning that produced them, which is what lets a team disagree productively. A recommendation you cannot interrogate is not decision support; it is an instruction.</p>
<p><strong>Comparison beats commitment.</strong> Examining two plausible futures side by side is cheaper than discovering the difference by living through one.</p>
<p><strong>Governance outputs are planning aids.</strong> Risk considerations, human oversight guidance, and governance templates help a team think through supervision and accountability before adopting something. They are inputs to your own process — not evidence of compliance, and not automated enforcement.</p>
<p>Two design principles are worth stating plainly, because they are easy to get wrong.</p>
<p>The first: <em>unknown is not the same as absent</em>. If a team cannot answer a question about their data lineage, the honest reading is that they do not currently know — which is itself useful information, and often the first thing worth fixing. Treating every gap in knowledge as a confirmed deficiency produces a bleaker and less accurate picture than the team deserves.</p>
<p>The second: <em>readiness is not one number</em>. A team can be strong on engineering practice and weak on data foundations, or the reverse. Collapsing those into a single verdict destroys exactly the information that would tell you what to do next. Different dimensions deserve to stay separate.</p>
<h2 id="deferring-is-a-real-answer" tabindex="-1"><a class="header-anchor" href="#deferring-is-a-real-answer"><span>Deferring is a real answer</span></a></h2>
<p>The most useful output of an assessment is often “not this, not yet”.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Potential AI use case&quot;] --&gt; B[&quot;Consider value and context&quot;]
    B --&gt; C[&quot;Review foundations, risk and oversight&quot;]
    C --&gt; D[&quot;Suitable to explore now&quot;]
    C --&gt; E[&quot;Foundations need attention&quot;]
    C --&gt; F[&quot;Defer or retain the current approach&quot;]
    D --&gt; G[&quot;Bounded pilot with human review&quot;]
    E --&gt; H[&quot;Prioritise improvements&quot;]
    H --&gt; C
    G --&gt; I[&quot;Evaluate and revisit&quot;]
    F --&gt; I
</pre>
<p>This diagram describes a decision-making philosophy, not Compass’s internal rules. Its point is that three outcomes are legitimate. A use case can be suitable to explore now, as a bounded pilot with human review. It can be blocked on foundations, in which case the improvement work is the AI work. Or it can be deferred, with the current approach retained — sometimes because the value is thin, sometimes because the oversight burden exceeds what the team can carry.</p>
<p>Naming a prerequisite is a result. It converts a vague sense that “we’re not ready” into a specific piece of work with a name and an owner. And an explicit deferral, recorded with its reasoning and a date to revisit, is far more durable than an idea that quietly stalls and reappears in six months with nobody able to say what happened last time.</p>
<h2 id="the-takeaway" tabindex="-1"><a class="header-anchor" href="#the-takeaway"><span>The takeaway</span></a></h2>
<p>Adopting AI well is less about finding the best tool than about knowing which decisions you are actually making, on what evidence, and in what order. Compass exists to make those decisions deliberate and explainable — to give a team a clear view of its own context, an honest account of its gaps, and a defensible next step rather than a backlog of enthusiasm.</p>
<p>If your team is somewhere between “we should be using AI” and knowing what to do on Monday, the useful move is to assess a real project — one with actual constraints, actual people, and an actual deadline — and see what the context tells you.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Extending Teamcentric Forge from Live Documents to Dashboards</title>
      <link>https://ikoonman.io/blog/extending-teamcentric-forge-from-documents-to-dashboards/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/extending-teamcentric-forge-from-documents-to-dashboards/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>A high-level technical overview of how Teamcentric Forge can evolve from document generation into live dashboards while keeping Markdown as the source of truth.</description>
      <category>teamcentric</category>
      <category>forge</category>
      <category>architecture</category>
      <category>dashboards</category>
      <category>developer-tools</category>
      <content:encoded><![CDATA[<p>Teamcentric Forge started with a deliberately simple idea: keep technical documentation in Markdown, allow selected blocks to retrieve or generate fresh information at build time, and use a CLI to turn that source into an up-to-date document.</p>
<p>That model is useful on its own, but it also creates a natural path toward something broader: the same Markdown source can drive a live or periodically refreshed dashboard without introducing a second definition format.</p>
<p>The important architectural decision is that <strong>the Markdown document remains the source of truth</strong>. The dashboard, generated Markdown, and exported PDF are all different views of the same underlying definition.</p>
<h2 id="the-original-forge-model" tabindex="-1"><a class="header-anchor" href="#the-original-forge-model"><span>The Original Forge Model</span></a></h2>
<p>At its simplest, Forge consists of a document library, an editor, and a CLI.</p>
<p>A document stored in the tenant library contains normal Markdown alongside executable or retrievable blocks. The CLI retrieves that source, executes the dynamic sections, and generates an updated Markdown document.</p>
<p>The resulting document can remain local, be posted back into the Forge library, or be exported further, for example to PDF.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Forge SaaS Library&quot;] --&gt; B[&quot;Markdown Source&quot;]
    B --&gt; C[&quot;Forge CLI&quot;]
    C --&gt; D[&quot;Execute dynamic blocks&quot;]
    D --&gt; E[&quot;Generated Markdown&quot;]
    E --&gt; F[&quot;Local file&quot;]
    E --&gt; G[&quot;Post back to Forge&quot;]
    E --&gt; H[&quot;Export to PDF&quot;]
</pre>
<p>The strength of this model is that execution happens where the CLI runs.</p>
<p>That means a Forge document can obtain information that a hosted SaaS platform would not normally be able to reach, including local files, private databases, Docker, internal services, operating-system information, or commands that require elevated privileges.</p>
<h2 id="markdown-remains-the-source-of-truth" tabindex="-1"><a class="header-anchor" href="#markdown-remains-the-source-of-truth"><span>Markdown Remains the Source of Truth</span></a></h2>
<p>The extension into dashboards should not introduce a second configuration system.</p>
<p>Instead of maintaining one definition for documentation and another for monitoring, Forge can use the same Markdown document to define both.</p>
<p>Conceptually:</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Forge Markdown&quot;] --&gt; B[&quot;Execution Layer&quot;]

    B --&gt; C[&quot;Generated Document&quot;]
    B --&gt; D[&quot;Dashboard State&quot;]

    C --&gt; E[&quot;Markdown&quot;]
    C --&gt; F[&quot;PDF&quot;]

    D --&gt; G[&quot;Hosted Dashboard&quot;]
</pre>
<p>The document defines:</p>
<ul>
<li>what information is required,</li>
<li>how that information should be retrieved,</li>
<li>how often a live value should be refreshed,</li>
<li>where execution should take place,</li>
<li>and how the result should be represented.</li>
</ul>
<p>The dashboard is therefore not a separate application definition. It is a visual rendering of the same document.</p>
<h2 id="from-document-blocks-to-dashboard-widgets" tabindex="-1"><a class="header-anchor" href="#from-document-blocks-to-dashboard-widgets"><span>From Document Blocks to Dashboard Widgets</span></a></h2>
<p>An executable Forge block can be treated as both a document element and a dashboard widget.</p>
<p>For example, a document might contain blocks representing:</p>
<ul>
<li>production API health,</li>
<li>server disk usage,</li>
<li>Docker container state,</li>
<li>Git branch or release information,</li>
<li>SSL certificate expiry,</li>
<li>database statistics,</li>
<li>deployment status,</li>
<li>open issues,</li>
<li>or custom script output.</li>
</ul>
<p>When the document is generated normally, these blocks become Markdown.</p>
<p>When the document is viewed as a dashboard, the same blocks become visual widgets.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Widget definition in Markdown&quot;] --&gt; B[&quot;Execute&quot;]
    B --&gt; C[&quot;Structured result&quot;]

    C --&gt; D[&quot;Markdown renderer&quot;]
    C --&gt; E[&quot;Dashboard renderer&quot;]

    D --&gt; F[&quot;Table / text / status&quot;]
    E --&gt; G[&quot;Card / gauge / chart / status&quot;]
</pre>
<p>This separation between <strong>execution</strong> and <strong>presentation</strong> is what makes the extension practical.</p>
<p>A disk-space block, for example, should ideally return structured information rather than preformatted text:</p>
<pre class="code-block"><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;totalBytes&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">250000000000</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;usedBytes&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">184000000000</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;freeBytes&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">66000000000</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;usedPercent&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">73.6</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>The Markdown renderer might turn that into a table.</p>
<p>The dashboard renderer might display a progress bar or gauge.</p>
<p>The underlying result remains the same.</p>
<h2 id="every-document-can-have-a-dashboard" tabindex="-1"><a class="header-anchor" href="#every-document-can-have-a-dashboard"><span>Every Document Can Have a Dashboard</span></a></h2>
<p>Once widgets produce structured state, every Forge document can automatically have an associated dashboard.</p>
<p>The default dashboard layout can follow the order of the Markdown document.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-markdown"><span class="hljs-section">## Production</span>

:::
type: http-health
title: API
refresh: 30s
:::

:::
type: disk-space
title: Disk
path: /
refresh: 5m
:::

:::
type: docker-status
title: Containers
refresh: 1m
:::
</code></pre>
<p>Forge could automatically derive a dashboard similar to:</p>
<pre class="code-block"><code class="hljs language-text">┌──────────────────┐  ┌──────────────────┐
│ API              │  │ Disk             │
│ Healthy          │  │ 73.6% used       │
└──────────────────┘  └──────────────────┘

┌──────────────────┐
│ Containers       │
│ 6 / 6 running    │
└──────────────────┘
</code></pre>
<p>The user can then visually rearrange those widgets.</p>
<p>That rearrangement does not need to alter the underlying Markdown. It can be stored separately as presentation metadata.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Markdown source&quot;] --&gt; B[&quot;Widget definitions&quot;]
    B --&gt; C[&quot;Default dashboard layout&quot;]

    C --&gt; D[&quot;User rearranges widgets&quot;]
    D --&gt; E[&quot;Dashboard layout metadata&quot;]

    A --&gt; F[&quot;Still remains canonical source&quot;]
</pre>
<p>This keeps the responsibilities clean:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<tr>
<td>What should be executed</td>
<td>Markdown</td>
</tr>
<tr>
<td>What the widget means</td>
<td>Markdown</td>
</tr>
<tr>
<td>Refresh interval</td>
<td>Markdown</td>
</tr>
<tr>
<td>Privilege requirement</td>
<td>Markdown</td>
</tr>
<tr>
<td>Latest runtime value</td>
<td>Widget state</td>
</tr>
<tr>
<td>Dashboard position and size</td>
<td>Layout metadata</td>
</tr>
</tbody>
</table>
<h2 id="the-cli-becomes-a-generator-and-a-watcher" tabindex="-1"><a class="header-anchor" href="#the-cli-becomes-a-generator-and-a-watcher"><span>The CLI Becomes a Generator and a Watcher</span></a></h2>
<p>The existing CLI can be extended rather than replaced.</p>
<p>Its first role remains document generation.</p>
<pre class="code-block"><code class="hljs language-bash">forge generate production.md
</code></pre>
<p>Its second role becomes continuous or scheduled execution.</p>
<pre class="code-block"><code class="hljs language-bash">forge agent production.md
</code></pre>
<p>In agent mode, the CLI periodically executes the widgets defined in the Markdown and sends only the resulting structured state back to the SaaS platform.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Forge Library&quot;] --&gt; B[&quot;Markdown source&quot;]
    B --&gt; C[&quot;Forge CLI / Agent&quot;]

    C --&gt; D[&quot;Run widgets locally&quot;]
    D --&gt; E[&quot;Structured widget state&quot;]

    E --&gt; F[&quot;Forge SaaS&quot;]
    F --&gt; G[&quot;Hosted dashboard&quot;]
</pre>
<p>This is important because the hosted dashboard does not need direct access to the customer’s infrastructure.</p>
<p>The CLI executes where the data is actually available.</p>
<h2 id="why-the-local-runner-matters" tabindex="-1"><a class="header-anchor" href="#why-the-local-runner-matters"><span>Why the Local Runner Matters</span></a></h2>
<p>A hosted Forge service can directly query many external APIs, but it cannot automatically inspect a private server.</p>
<p>For example, a dashboard widget may want to display:</p>
<pre class="code-block"><code class="hljs language-text">Production server disk usage
</code></pre>
<p>The Forge SaaS platform cannot normally run:</p>
<pre class="code-block"><code class="hljs language-bash"><span class="hljs-built_in">df</span> -h /
</code></pre>
<p>on a user’s private Linux server.</p>
<p>The local Forge agent can.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Private Server&quot;] --&gt; B[&quot;Forge CLI / Agent&quot;]
    B --&gt; C[&quot;df / Docker / DB / local API&quot;]
    C --&gt; D[&quot;Structured result&quot;]
    D --&gt; E[&quot;Outbound HTTPS&quot;]
    E --&gt; F[&quot;Forge SaaS Dashboard&quot;]
</pre>
<p>Only an outbound connection is required.</p>
<p>There is no need for Forge SaaS to open a connection into the private network.</p>
<p>That makes the model suitable for small companies and individual developers who want useful system visibility without deploying a larger monitoring stack.</p>
<h2 id="generate-once-or-watch-continuously" tabindex="-1"><a class="header-anchor" href="#generate-once-or-watch-continuously"><span>Generate Once or Watch Continuously</span></a></h2>
<p>The same Markdown source can therefore support two execution patterns.</p>
<h3 id="point-in-time-generation" tabindex="-1"><a class="header-anchor" href="#point-in-time-generation"><span>Point-in-time generation</span></a></h3>
<pre class="code-block"><code class="hljs language-bash">forge generate production.md
</code></pre>
<p>This executes the document once and produces a snapshot.</p>
<h3 id="continuous-execution" tabindex="-1"><a class="header-anchor" href="#continuous-execution"><span>Continuous execution</span></a></h3>
<pre class="code-block"><code class="hljs language-bash">forge agent production.md
</code></pre>
<p>This keeps selected widgets refreshed and sends their latest values to the hosted dashboard.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Forge Markdown&quot;] --&gt; B{&quot;Execution mode&quot;}

    B --&gt;|&quot;generate&quot;| C[&quot;Run once&quot;]
    C --&gt; D[&quot;Generate Markdown&quot;]
    D --&gt; E[&quot;Optional PDF export&quot;]

    B --&gt;|&quot;agent&quot;| F[&quot;Run periodically&quot;]
    F --&gt; G[&quot;Publish widget state&quot;]
    G --&gt; H[&quot;Live hosted dashboard&quot;]
</pre>
<p>The important part is that these are not two separate products.</p>
<p>They are two execution modes over the same source.</p>
<h2 id="a-lightweight-publish-mode" tabindex="-1"><a class="header-anchor" href="#a-lightweight-publish-mode"><span>A Lightweight Publish Mode</span></a></h2>
<p>A persistent agent does not even need to be the first implementation.</p>
<p>A simpler intermediate command could be:</p>
<pre class="code-block"><code class="hljs language-bash">forge publish production.md
</code></pre>
<p>That command would:</p>
<ol>
<li>retrieve or read the Markdown,</li>
<li>execute the widgets locally,</li>
<li>send the resulting widget state to Forge SaaS,</li>
<li>update the dashboard,</li>
<li>exit.</li>
</ol>
<p>The operating system can handle scheduling.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-cron">*/5 * * * * forge publish production.md
</code></pre>
<p>This would provide a dashboard refreshed every five minutes without requiring Forge to initially build a persistent daemon.</p>
<p>The progression can therefore be incremental:</p>
<pre class="mermaid">
flowchart LR
    A[&quot;forge generate&quot;] --&gt; B[&quot;forge publish&quot;]
    B --&gt; C[&quot;forge agent&quot;]
    C --&gt; D[&quot;Installed Forge runner service&quot;]
</pre>
<p>Each stage adds capability without invalidating the previous one.</p>
<h2 id="elevated-execution-remains-local" tabindex="-1"><a class="header-anchor" href="#elevated-execution-remains-local"><span>Elevated Execution Remains Local</span></a></h2>
<p>One particularly useful property of the local execution model is that some widgets may inspect information requiring elevated privileges.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-bash">smartctl -H /dev/nvme0
</code></pre>
<p>or protected service information.</p>
<p>Forge should not silently escalate privileges.</p>
<p>Instead, the document can declare that a widget requires elevation:</p>
<pre class="code-block"><code class="hljs language-yaml"><span class="hljs-attr">type:</span> <span class="hljs-string">shell</span>
<span class="hljs-attr">title:</span> <span class="hljs-string">SMART</span> <span class="hljs-string">Health</span>
<span class="hljs-attr">command:</span> <span class="hljs-string">smartctl</span> <span class="hljs-string">-H</span> <span class="hljs-string">/dev/nvme0</span>
<span class="hljs-attr">elevation:</span> <span class="hljs-string">required</span>
</code></pre>
<p>The CLI can inspect the document before execution:</p>
<pre class="code-block"><code class="hljs language-bash">forge inspect production.md
</code></pre>
<p>and report which widgets require additional permissions.</p>
<p>Execution might then support options such as:</p>
<pre class="code-block"><code class="hljs language-bash">forge generate production.md --skip-elevated
</code></pre>
<p>or:</p>
<pre class="code-block"><code class="hljs language-bash">forge generate production.md --allow-elevation
</code></pre>
<p>The key security principle is:</p>
<blockquote>
<p>The SaaS may request privileged execution, but only the local environment can approve it.</p>
</blockquote>
<pre class="mermaid">
flowchart TD
    A[&quot;Markdown requests elevated action&quot;] --&gt; B[&quot;Forge CLI&quot;]
    B --&gt; C{&quot;Approved locally?&quot;}

    C --&gt;|&quot;Yes&quot;| D[&quot;Execute with permitted elevation&quot;]
    C --&gt;|&quot;No&quot;| E[&quot;Skip or fail widget&quot;]

    D --&gt; F[&quot;Publish result&quot;]
    E --&gt; F
</pre>
<p>The user remains in control of the machine.</p>
<p>For persistent agents, it is preferable that the entire Forge process does not run as root. Individual approved actions can be elevated when necessary.</p>
<h2 id="turning-the-dashboard-back-into-documentation" tabindex="-1"><a class="header-anchor" href="#turning-the-dashboard-back-into-documentation"><span>Turning the Dashboard Back into Documentation</span></a></h2>
<p>The flow also works in reverse.</p>
<p>A user looking at the dashboard may want to capture its current state.</p>
<p>Forge can generate a Markdown snapshot using the latest published widget values.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Live Dashboard&quot;] --&gt; B[&quot;Generate Snapshot&quot;]
    B --&gt; C[&quot;Markdown document&quot;]
    C --&gt; D[&quot;Forge Library&quot;]
    C --&gt; E[&quot;PDF export&quot;]
</pre>
<p>This creates a useful connection between operational visibility and documentation.</p>
<p>Examples might include:</p>
<ul>
<li>release-readiness reports,</li>
<li>weekly engineering summaries,</li>
<li>deployment snapshots,</li>
<li>environment status reports,</li>
<li>incident records,</li>
<li>infrastructure inventories,</li>
<li>audit evidence.</li>
</ul>
<p>The live view answers:</p>
<blockquote>
<p>What does the system look like now?</p>
</blockquote>
<p>The generated document answers:</p>
<blockquote>
<p>What did the system look like at this point in time?</p>
</blockquote>
<h2 id="static-context-and-live-data-can-coexist" tabindex="-1"><a class="header-anchor" href="#static-context-and-live-data-can-coexist"><span>Static Context and Live Data Can Coexist</span></a></h2>
<p>A Forge dashboard does not have to consist only of metrics.</p>
<p>Because the source is a document, static explanatory text and dynamic data can exist together.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-markdown"><span class="hljs-section">## Redis</span>

The Redis instance is intentionally undersized during beta.

:::
type: redis-memory
refresh: 1m
:::

The planned upgrade threshold is 85%.
</code></pre>
<p>The dashboard can display both the explanation and the current value.</p>
<p>That is useful because technical status rarely makes complete sense without context.</p>
<p>A conventional monitoring dashboard might tell someone that Redis memory is at 78%.</p>
<p>Forge can also explain why 78% is currently acceptable.</p>
<h2 id="not-another-prometheus-or-grafana" tabindex="-1"><a class="header-anchor" href="#not-another-prometheus-or-grafana"><span>Not Another Prometheus or Grafana</span></a></h2>
<p>This extension does not require Forge to become a full observability platform.</p>
<p>The aim is not to implement:</p>
<ul>
<li>high-frequency metrics ingestion,</li>
<li>distributed tracing,</li>
<li>log aggregation,</li>
<li>long-term time-series storage,</li>
<li>metric query languages,</li>
<li>or complex alert-routing systems.</li>
</ul>
<p>Forge can deliberately remain lightweight.</p>
<p>Its job is closer to:</p>
<pre class="code-block"><code class="hljs language-text">retrieve → execute → structure → display → document
</code></pre>
<p>Where historical monitoring already exists, Forge can consume it.</p>
<p>A widget could retrieve information from Prometheus, CloudWatch, Grafana, or another source rather than replacing them.</p>
<p>This makes Forge useful both for developers who have no monitoring infrastructure and for teams that already do.</p>
<h2 id="the-resulting-architecture" tabindex="-1"><a class="header-anchor" href="#the-resulting-architecture"><span>The Resulting Architecture</span></a></h2>
<p>The resulting architecture extends the existing Forge model rather than replacing it.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Forge Tenant Library&quot;] --&gt; B[&quot;Markdown Source of Truth&quot;]
    B --&gt; C[&quot;Forge Editor&quot;]
    B --&gt; D[&quot;Forge CLI / Runner&quot;]

    D --&gt; E[&quot;Execute local widgets&quot;]
    D --&gt; F[&quot;Execute remote/API widgets&quot;]

    E --&gt; G[&quot;Structured widget results&quot;]
    F --&gt; G

    G --&gt; H[&quot;Markdown renderer&quot;]
    G --&gt; I[&quot;Dashboard renderer&quot;]

    H --&gt; J[&quot;Generated Markdown&quot;]
    J --&gt; K[&quot;PDF export&quot;]
    J --&gt; L[&quot;Optional post back to library&quot;]

    I --&gt; M[&quot;Customisable hosted dashboard&quot;]
    M --&gt; N[&quot;Generate snapshot&quot;]
    N --&gt; J
</pre>
<p>The architecture retains the original strengths of Forge:</p>
<ul>
<li>portable Markdown,</li>
<li>local execution,</li>
<li>reusable blocks,</li>
<li>document generation,</li>
<li>tenant-hosted source,</li>
<li>and optional publication back to the SaaS platform.</li>
</ul>
<p>It adds:</p>
<ul>
<li>structured widget output,</li>
<li>live or periodically refreshed state,</li>
<li>automatic dashboards,</li>
<li>visual dashboard layouts,</li>
<li>local runner execution,</li>
<li>dashboard snapshots,</li>
<li>and a path toward lightweight operational visibility.</li>
</ul>
<p>The defining principle remains simple:</p>
<blockquote>
<p><strong>Define the technical state once in Markdown, execute it where the truth lives, display it live when useful, and generate a document whenever a durable snapshot is needed.</strong></p>
</blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>NeuroDesk: How Little Input Does a Computer Actually Need?</title>
      <link>https://ikoonman.io/blog/neurodesk-silent-interaction/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/neurodesk-silent-interaction/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>An early-stage research project exploring whether a few silent, deliberate signals — attention, subtle gestures, and experimental neuro-input — are enough to direct and supervise a capable AI agent.</description>
      <category>human-computer-interaction</category>
      <category>accessibility</category>
      <category>ai-agents</category>
      <category>research</category>
      <content:encoded><![CDATA[<p>Imagine you are standing in a queue with a coffee in one hand and a bag in the other. A long-running coding agent has been working on a failing test for the last twenty minutes. You glance at a small notification, hold your gaze for a moment, make a small deliberate movement, and a short proposal appears: <em>investigate the failing test in the payments module and report back</em>. You look at it, confirm it, and put your phone away. You have not typed anything. You have not said anything out loud. Nobody around you noticed.</p>
<p>That scene is not a demonstration. NeuroDesk is currently an early concept and architecture project — there is documentation and a standalone project configurator, but no implemented runtime, no sensor pipeline, no AI gateway, and no hardware prototype. The scene describes the experience we want to test, and the rest of this post is about why that experience seems worth investigating and what has to be true for it to work.</p>
<p>The research question underneath it is deliberately narrow: <strong>how little reliable human input is needed to direct a capable AI agent?</strong></p>
<h2 id="the-problem-with-the-devices-we-have" tabindex="-1"><a class="header-anchor" href="#the-problem-with-the-devices-we-have"><span>The problem with the devices we have</span></a></h2>
<p>Keyboards, mice, and voice assistants are excellent, and none of them is going anywhere. But each assumes conditions that are not always available.</p>
<p>A keyboard assumes free hands and a surface. A mouse assumes both, plus fine motor control. Voice assumes you are somewhere you can speak, that the people around you don’t mind, and that you don’t mind them hearing. Every one of those assumptions fails regularly: walking, cooking, carrying something, sitting in a shared office, riding public transport, being in a meeting, being in a quiet home at night with someone asleep in the next room.</p>
<p>There is also a group of people for whom conventional input is difficult or exhausting rather than merely inconvenient. Their needs are not an afterthought here, but they are also not a solved problem, and I will come back to why.</p>
<p>Something else has changed alongside this. When computers only did what you told them step by step, input bandwidth was the bottleneck — you needed a keyboard because you had a lot of characters to transmit. As AI systems become able to carry out longer, more open-ended tasks, the balance shifts. Increasingly the valuable human contributions are <em>specifying a goal</em> and <em>reviewing a result</em>, not entering each individual instruction. Those two acts might not need a keyboard’s worth of bandwidth.</p>
<h2 id="what-the-experience-could-feel-like" tabindex="-1"><a class="header-anchor" href="#what-the-experience-could-feel-like"><span>What the experience could feel like</span></a></h2>
<p>The interaction NeuroDesk is exploring has a consistent shape, whatever the task.</p>
<p>Something presents itself as a possible target — a notification, an item in a short list, a device in the room. Your attention lands on it. Then you make a small, deliberate signal to say <em>that one</em>. Contextual AI turns that minimal selection into a concrete proposal for work. You see the proposal. You approve it, adjust it, or cancel. If you approve it, something happens, and you get told what happened.</p>
<p>Three examples of the same loop:</p>
<ul>
<li><strong>Checking on a coding agent.</strong> You are away from your desk. An agent has stalled. You select the alert and confirm a proposed next step: look into why this is failing and summarise it. You review the summary later, at a keyboard, where reviewing is easy.</li>
<li><strong>A discreet personal assistant.</strong> In a meeting, you want to note a follow-up, or ask a private assistant something you would not say out loud in the room. A silent selection is more appropriate than speech here — though silence is a matter of social discretion, not a privacy guarantee.</li>
<li><strong>A familiar smart-home device.</strong> Hands full, you look at a lamp or a speaker and select the one action you almost always want from it. The vocabulary is tiny and the context does the rest.</li>
</ul>
<p>Coding agents are one application. The broader objective is interacting with a computer without traditional input devices at all.</p>
<h2 id="how-it-works%2C-conceptually" tabindex="-1"><a class="header-anchor" href="#how-it-works%2C-conceptually"><span>How it works, conceptually</span></a></h2>
<p>Several kinds of signal can feed the same intent, and the AI layer sits between that intent and anything that actually runs.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Eye movement&quot;] --&gt; D[&quot;Deliberate user intent&quot;]
    B[&quot;Subtle gestures&quot;] --&gt; D
    C[&quot;Experimental neuro-signals&quot;] --&gt; D
    D --&gt; E[&quot;Contextual AI&quot;]
    E --&gt; F[&quot;Proposed action&quot;]
    F --&gt; G[&quot;User review and authorization&quot;]
    G --&gt; H[&quot;Computer or connected device&quot;]
    H --&gt; I[&quot;Feedback to the user&quot;]
</pre>
<p>The diagram’s key point is that no single input method carries the whole burden, and nothing reaches a device or a piece of software without passing through a step where the person reviews and authorises it.</p>
<p>The complementary signals, in plain language:</p>
<ul>
<li><strong>Eye movement</strong> is good for <em>navigation</em> — moving among candidates, indicating what you are currently considering.</li>
<li><strong>Subtle muscle activity or small gestures</strong> are good for <em>selection</em> — a discrete, intentional act that you either did or did not perform.</li>
<li><strong>Experimental brain-signal input</strong> may be useful for <em>constrained choices</em>, where the system only needs to distinguish between a small number of options.</li>
</ul>
<p>Not every interaction needs all three. A given task might use one. The point of having several is that each is asked to do the thing it is plausibly good at.</p>
<p>The most important distinction in the whole design is between <strong>looking and selecting</strong>. Attention must never, on its own, become permission to act. If merely resting your gaze on something could trigger it, the system becomes a minefield — you would have to be careful about where you looked, which is an absurd thing to ask of a person. Looking is a proposal for consideration. Selecting is an act.</p>
<h3 id="what-the-ai-is-and-is-not-doing" tabindex="-1"><a class="header-anchor" href="#what-the-ai-is-and-is-not-doing"><span>What the AI is and is not doing</span></a></h3>
<p>The AI’s job is to take a small, contextual selection and expand it into a richer, well-specified task, using context you have authorised it to see — what you are working on, what is on screen, what device you are near, what happened a minute ago.</p>
<p>It is not decoding thoughts. It is not inferring intentions you never expressed. When the selection is ambiguous, the correct behaviour is to ask, not to guess and act.</p>
<h2 id="the-loop%2C-end-to-end" tabindex="-1"><a class="header-anchor" href="#the-loop%2C-end-to-end"><span>The loop, end to end</span></a></h2>
<pre class="mermaid">
flowchart TD
    A[&quot;User identifies a target&quot;] --&gt; B[&quot;Deliberately selects a request&quot;]
    B --&gt; C[&quot;AI interprets the available context&quot;]
    C --&gt; D[&quot;User reviews the proposed action&quot;]
    D --&gt; E{&quot;Approve?&quot;}
    E --&gt;|Yes| F[&quot;Authorized action runs&quot;]
    E --&gt;|Revise| B
    E --&gt;|Cancel| G[&quot;No action&quot;]
    F --&gt; H[&quot;Result returned to the user&quot;]
</pre>
<p>Two things matter in that flow. The first is that revision loops back rather than dead-ends: getting it slightly wrong should cost one more small interaction, not a restart. The second is that cancelling is a first-class outcome. A system built around very small inputs will sometimes misread them, so an easy, obvious way to say <em>no, stop</em> is part of the design rather than an error path.</p>
<h2 id="who-this-might-be-for" tabindex="-1"><a class="header-anchor" href="#who-this-might-be-for"><span>Who this might be for</span></a></h2>
<ul>
<li>People looking for <strong>accessible input alternatives</strong>, where a keyboard and mouse are difficult, tiring, or unavailable.</li>
<li>People whose <strong>hands are occupied</strong> — in a workshop, a kitchen, a lab, a vehicle, or simply carrying things.</li>
<li>People in <strong>shared or quiet environments</strong> where speaking to a computer is intrusive or awkward.</li>
<li>People <strong>supervising AI work away from their desk</strong>, who need to check in, redirect, and approve rather than author.</li>
</ul>
<p>On accessibility specifically: I want to be careful. Accessibility here is a design opportunity that demands user research and individual adaptation. It is not a proven clinical benefit, and it is certainly not a universal solution — the range of individual difference in this space is large, and anything that works will work because it was adapted to a person, not because it was shipped to everyone.</p>
<h2 id="why-the-approach-seems-worth-trying" tabindex="-1"><a class="header-anchor" href="#why-the-approach-seems-worth-trying"><span>Why the approach seems worth trying</span></a></h2>
<p>These are reasons to investigate, not results.</p>
<ul>
<li><strong>Communicating a goal can take fewer inputs than performing every step.</strong> “Find out why this is failing” is a smaller message than the sequence of clicks and keystrokes that would investigate it manually.</li>
<li><strong>Context reduces what has to be specified.</strong> If the system already knows what you are looking at and what you were doing, the part you must supply gets much smaller.</li>
<li><strong>Complementary signals avoid overloading any one channel.</strong> Asking eye tracking alone to handle both navigation and confirmation is where a lot of gaze-based interaction historically becomes uncomfortable.</li>
<li><strong>Control comes from feedback, correction, cancellation, and deliberate confirmation.</strong> Small inputs are only safe when the person can always see what is about to happen and stop it.</li>
<li><strong>Private or locally controlled AI is a design goal</strong>, because this interaction model depends on the system seeing context that is genuinely personal.</li>
<li><strong>A phone is a realistic first display.</strong> It is already in your pocket and already good at short glanceable feedback. Wearable displays are interesting later, not a prerequisite.</li>
</ul>
<h2 id="the-honest-uncertainties" tabindex="-1"><a class="header-anchor" href="#the-honest-uncertainties"><span>The honest uncertainties</span></a></h2>
<p>This is not arbitrary thought reading, and it will not be. Silent, free-form text — composing a paragraph without moving or speaking — remains genuinely hard. Constrained selections and contextual requests are a far more realistic starting point, and that constraint is doing real work in the design rather than being a temporary limitation to be engineered away.</p>
<p>Open questions that need actual testing:</p>
<ul>
<li><strong>Signal reliability.</strong> Small signals are noisy, and noise near a confirmation step is a serious problem.</li>
<li><strong>Accidental activation.</strong> How often does the system act when you did not mean it to, and how bad is it when that happens?</li>
<li><strong>Individual variation.</strong> Signals differ enormously between people, and probably between days for the same person.</li>
<li><strong>Comfort.</strong> Anything worn or held has to be tolerable for hours, not minutes.</li>
<li><strong>Privacy.</strong> Contextual AI requires access to context; being silent in a room is not the same as being private in a system.</li>
</ul>
<p>And two claims I will not make: silence does not guarantee privacy, and conventional input devices are not going to become obsolete. Keyboards will remain the best tool for writing a lot of text for a long time.</p>
<h2 id="what-we-are-actually-chasing" tabindex="-1"><a class="header-anchor" href="#what-we-are-actually-chasing"><span>What we are actually chasing</span></a></h2>
<p>The possibility that motivates NeuroDesk is a simple one. If a person can express what they want and stay genuinely in control of the resulting work through a handful of silent, deliberate interactions — chosen on purpose, reviewed before anything happens, and reversible when they are wrong — then a large amount of useful computer work stops requiring a desk, a keyboard, or a voice.</p>
<p>Whether that holds up is exactly what there is to find out.</p>
]]></content:encoded>
    </item>
    <item>
      <title>PACE: Giving AI-Assisted Development a Memory of the Project</title>
      <link>https://ikoonman.io/blog/pace-persistent-ai-context-engine/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/pace-persistent-ai-context-engine/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>Why AI coding assistants produce plausible code that doesn&apos;t fit the system, and how a persistent, project-scoped context layer might change that.</description>
      <category>ai-assisted-development</category>
      <category>software-architecture</category>
      <category>developer-experience</category>
      <category>context-engineering</category>
      <content:encoded><![CDATA[<!-- Publication excerpt:
A coding assistant can write plausible code without knowing why a service boundary exists, which architectural decision still stands, or what your team agreed to three months ago. PACE — Persistent AI Context Engine — is an attempt to make that knowledge available repeatedly, at the point where a change is being written rather than in the review that follows. This post explains the engineering problem, the design principles behind a project-scoped context layer, who it's for, and what it honestly cannot promise yet.
-->
<p><img src="/images/pace.png" alt="Description of the image"></p>
<p><em>A working note on why AI-assisted development keeps running into the same wall, and what a project-scoped context layer would have to get right.</em></p>
<p>There’s a particular kind of pull request that has become common enough to be recognisable. The code is clean. The tests pass. The naming is idiomatic. And the first substantive review comment is some version of: <em>this isn’t how we do it here</em>.</p>
<p>This post is about the gap that comment reveals, and about PACE — Persistent AI Context Engine — a project I’ve been building to explore whether that gap can be closed with infrastructure rather than with more careful prompting. PACE is early. Its core works; large parts of the ambition around it do not exist yet. I’ll be specific about which is which, because a post that blurs the two isn’t worth reading.</p>
<h2 id="the-review-comment-that-keeps-recurring" tabindex="-1"><a class="header-anchor" href="#the-review-comment-that-keeps-recurring"><span>The review comment that keeps recurring</span></a></h2>
<p>Picture a developer adding a feature to an established service — a billing service, say, five years old, with the accumulated scar tissue that implies. They describe the feature to an assistant. They get back a reasonable-looking implementation: a new handler, a couple of helpers, some tests.</p>
<p>Review finds three things. The handler talks to the customer database directly, bypassing an access layer the team introduced specifically to keep that dependency in one place. It reimplements a retry-and-idempotency pattern that already exists two directories over under a name nobody would guess. And it takes an approach that was explicitly considered and rejected eighteen months ago, for reasons written down in a decision record that nobody reads and the assistant never saw.</p>
<p>None of this is dramatic. It’s a normal review. But notice what happens next.</p>
<p>The developer explains the abstraction in follow-up prompts. They paste in a chunk of the decision record. They point at the existing helper. The second attempt is better. The change ships. The explanation evaporates — it lived in a chat session that ends when the tab closes.</p>
<p>Three weeks later a teammate touches the same service and reconstructs the same explanation from scratch, slightly differently. The reviewer catches slightly different things, because reviewers are human and attention is finite. And the same two or three people who actually hold the system’s history in their heads get pulled into another review, because they’re the only ones who reliably notice.</p>
<p>The cost here isn’t the bad first draft. Bad first drafts are cheap now. The cost is that <strong>project knowledge gets reconstructed by hand, per person, per task, per tool, and then thrown away.</strong> Multiply that across a team and a year and it becomes one of the more expensive things an engineering organisation does without ever putting it on a roadmap.</p>
<p>To be fair about the landscape: modern assistants are not blind. Many index the repository, read instruction files, and follow rules the team has written down. The problem I’m describing isn’t that tools can’t see code. It’s that seeing code is not the same as knowing which of the many things visible in a repository actually govern the change in front of you — and context is not the only reason AI-generated code goes wrong.</p>
<h2 id="code-shows-you-what-exists%2C-not-what-was-decided" tabindex="-1"><a class="header-anchor" href="#code-shows-you-what-exists%2C-not-what-was-decided"><span>Code shows you what exists, not what was decided</span></a></h2>
<p>A repository is a complete record of what a system currently is and a very incomplete record of why. That gap has a specific shape, and it’s worth naming the parts.</p>
<p><strong>Rejected alternatives leave no trace.</strong> The simpler design that would have been obvious to any competent engineer, and that failed for a reason nobody wants to rediscover — it isn’t in the code, because it isn’t in the code. An assistant reading the repository sees a structure that looks needlessly indirect and has every reason to propose the simpler thing. So does a new hire, for the same reason.</p>
<p><strong>Deliberate compromises look like mistakes.</strong> A duplicated model in two services can be an accident or a considered decoupling choice. The code reads identically either way. “Clean this up” is correct in one case and a regression in the other, and only the reasoning distinguishes them.</p>
<p><strong>Constraints outlive their explanations.</strong> A service can’t call another synchronously because of a latency budget agreed with a downstream team. A table can’t gain a column without a migration window. These are real, current, binding — and typically recorded, if at all, somewhere other than the code.</p>
<p><strong>The knowledge is scattered by nature.</strong> Decision records answer <em>why</em>. Documentation answers <em>how it’s meant to work</em>. The dependency graph answers <em>what breaks if I change this</em>. Review history answers <em>what we keep getting wrong</em>. Recent commits answer <em>what’s in flux right now</em>. Test coverage answers <em>how confident should I be</em>. Each of those answers a different part of a single engineering question — “is this change safe and appropriate?” — and they live in different systems with different lifecycles. That fragmentation is the underlying problem. I want to be careful here: PACE does not connect to everywhere this knowledge might live, and any tool claiming otherwise deserves scrutiny about which sources it actually reads.</p>
<h2 id="more-context-is-not-automatically-better-context" tabindex="-1"><a class="header-anchor" href="#more-context-is-not-automatically-better-context"><span>More context is not automatically better context</span></a></h2>
<p>The obvious response — throw everything at the model, context windows are large now — is wrong in an interesting way, and understanding why is most of the design problem.</p>
<p>Four properties matter independently:</p>
<p><strong>Relevance.</strong> A change to a payment handler is governed by a handful of decisions and constraints out of possibly hundreds. Including the rest doesn’t just waste budget; it dilutes the signal that mattered.</p>
<p><strong>Freshness.</strong> A decision record from 2023 may have been superseded in 2024. A cached summary may describe a module that has since been rewritten. Stale material is worse than absent material, because it’s confidently wrong.</p>
<p><strong>Authority.</strong> Not all project knowledge carries the same weight, and this is the distinction I find most important in practice. An accepted architectural decision is a commitment the team made. The code is the ground truth of what currently runs. An inferred summary — a heuristic risk score, a generated description of a module’s purpose — is a guess produced by a machine reading other machine-readable things. Those three are not interchangeable, and a system that flattens them into one undifferentiated pile of “context” will eventually let a guess override a decision.</p>
<pre class="mermaid">
flowchart TB
    A[&quot;Recorded facts&lt;br/&gt;(code, dependencies, commits, coverage)&quot;] --&gt; D[&quot;Assembled context&lt;br/&gt;for this task&quot;]
    B[&quot;Accepted decisions&lt;br/&gt;(ADRs, constraints, conventions)&quot;] --&gt; D
    C[&quot;Inferred signals&lt;br/&gt;(risk scores, summaries, derived hints)&quot;] --&gt; D
    D --&gt; E[&quot;Guidance the developer reads&lt;br/&gt;and is expected to question&quot;]
    B -. &quot;outranks&quot; .-&gt; C
    A -. &quot;outranks&quot; .-&gt; C
</pre>
<p>The dotted edges are the point: an inferred signal is a hint, not a ruling. When a derived summary contradicts an accepted decision or the actual code, the derived summary should lose, and the disagreement should be visible rather than silently resolved.</p>
<p><strong>Attention.</strong> Even with a large window, models weight what they’re given unevenly. A long, contradictory, partially-stale context is not a superset of a short, well-chosen one. It can be strictly worse.</p>
<p>So the engineering problem isn’t storage. It’s <em>selection under a budget</em>: given a task, decide what small subset of everything known about this project actually bears on it, and assemble that within a fixed token allowance. Getting freshness and conflict handling right at that level is genuinely hard, and I’d describe PACE’s current handling as functional rather than solved — snapshot invalidation is incomplete, and there’s no universal policy for resolving a superseded decision record against a conflicting convention file.</p>
<h2 id="the-individual-solves-this%3B-the-team-doesn%E2%80%99t" tabindex="-1"><a class="header-anchor" href="#the-individual-solves-this%3B-the-team-doesn%E2%80%99t"><span>The individual solves this; the team doesn’t</span></a></h2>
<p>One more failure mode deserves its own paragraph, because it’s the one that quietly determines whether AI assistance improves a team or just improves a few individuals on it.</p>
<p>An experienced engineer will, over a few weeks, develop a genuinely good working prompt for a codebase. It names the abstractions, mentions the constraints, warns about the traps. It’s effective. It’s also private — sitting in their notes, their shell history, their editor config.</p>
<p>Instruction files in the repository are the natural fix, and for many teams they’re a good one. They’re versioned, reviewed, shared. But they’re also a single flat document that everyone reads for every task, which means they get long, then get skimmed, then get stale, then get contradicted by a decision nobody went back to update. They work well up to a size and then stop scaling — not because the idea is wrong, but because “what governs <em>this</em> change” is a query, and a static file can’t answer a query.</p>
<p>That, roughly, is the line where a dedicated context service starts to be worth its cost: when the knowledge is large enough that selection matters, distributed enough that no single document holds it, and valuable enough that a handful of people keep getting interrupted to restate it.</p>
<h2 id="what-pace-actually-is" tabindex="-1"><a class="header-anchor" href="#what-pace-actually-is"><span>What PACE actually is</span></a></h2>
<p>PACE is a self-hostable developer-context service, scoped to a project. Its implemented core does five things:</p>
<ol>
<li><strong>Gathers evidence about the project.</strong> It ingests repository content and developer events, and derives code and architecture signals from them — structure, relationships, change activity, coverage, risk indicators.</li>
<li><strong>Stores that as durable project intelligence,</strong> rather than as a per-session index that disappears.</li>
<li><strong>Selects what matters for a specific task,</strong> rather than returning everything it knows.</li>
<li><strong>Assembles that selection into context within a token budget,</strong> and can use it in model-assisted guidance.</li>
<li><strong>Retains inspectable records</strong> — persisted context snapshots, guidance sessions, project guidance, and versioned artifacts with review workflows — so that what was assembled can be examined later rather than being taken on faith.</li>
</ol>
<p>You reach it through a dashboard, a CLI, a REST API, and a VS Code extension. Those differ in maturity: the API and dashboard are the most developed, the CLI covers a useful slice, and the extension is a working prototype rather than a polished product. Model access works against Ollama, Anthropic, and OpenAI-compatible services — which is not the same as saying every coding tool you already use is integrated. It isn’t.</p>
<p>I sometimes describe PACE as a <em>control plane</em> for project context. In ordinary language: it’s the place a team configures and manages what their AI tooling knows about their project, separate from the tools themselves — one authority that clients ask, rather than each tool maintaining its own private idea of the codebase.</p>
<!-- Image to create/upload:
Brief: The PACE conceptual diagram — inputs (developer tools, repositories, team inputs) on the left, code evidence and recorded project guidance including the "Project Bible" in the centre feeding an orchestration pipeline, intended benefits on the right, and a feedback loop returning outcomes to project evidence. Source file already exists at docs/assets/pace-linkedin-concept-diagram.png; convert and copy it to the blog's public assets.
Save as: public/images/pace-concept-diagram.webp
Uncomment after the asset is available:
![Conceptual diagram of PACE: developer tools, repositories and team inputs on the left feed code evidence and recorded project guidance in the centre, including a "Project Bible" of accepted decisions; an orchestration pipeline gathers, selects, applies constraints and compiles context, producing guidance, onboarding help, risk visibility and reviewable artifacts on the right, with a feedback loop returning outcomes to the evidence store.](/images/pace-concept-diagram.webp)

*PACE's conceptual model: bringing repository evidence and project guidance into the context used for AI-assisted engineering. The diagram includes both implemented elements and roadmap ambitions.*
-->
<p>The diagram reads left to right. On the left are the sources: developer tools, repositories, and inputs from the team itself. In the centre, two distinct bodies of knowledge sit side by side — code evidence derived from the repository, and recorded project guidance contributed by people. The pipeline between them gathers candidate material, selects what’s relevant to the task, applies the project’s constraints, and compiles a bounded context. On the right are intended outcomes: more relevant guidance, easier onboarding, earlier visibility of risk, and artifacts that can be reviewed rather than trusted blindly. A feedback loop runs back from outcomes into project evidence, so that what happened informs what gets assembled next time.</p>
<p>Two labels deserve honest treatment.</p>
<p><strong>“Project Bible”</strong> is the diagram’s name for the body of accepted decisions, constraints, terminology, and guidance that a team has deliberately recorded. The label is evocative and slightly misleading, so let me defuse it: it is not infallible and not automatically authoritative. It’s a maintained artifact, and like any maintained artifact it can be out of date, internally inconsistent, or simply wrong. Its value is that it’s <em>explicit</em> — someone chose to write it down, and someone can be pointed at it when it turns out to be stale.</p>
<p><strong>The organisational-governance elements are labelled ROADMAP,</strong> and that label is accurate. Organisation-wide policy, cross-team knowledge, and team-awareness features are ambitions, not shipped capabilities. Similarly, “architecture-safe guidance” describes an intent, not a guarantee — PACE can surface a constraint that a proposed change appears to violate; it cannot promise that a change is architecturally sound. And the infrastructure boxes in the diagram indicate where things run, not evidence of comprehensive security review or enforced tenant isolation. More on that below.</p>
<p>Treat the whole image as a conceptual overview: it shows relationships and intent, not a runtime trace and not a completion certificate.</p>
<h3 id="persistent-evidence-is-not-perfect-memory" tabindex="-1"><a class="header-anchor" href="#persistent-evidence-is-not-perfect-memory"><span>Persistent evidence is not perfect memory</span></a></h3>
<p>It’s worth separating two ideas that get conflated. PACE persists project evidence: repository-derived intelligence, recorded decisions, snapshots of what was assembled for a task, guidance sessions. That’s durable and inspectable.</p>
<p>It does not follow you between applications, remember every conversation you’ve had with every tool, or maintain an accurate model of your unfinished intentions. First-class memory — durable task threads, promoted summaries, goals that survive across sessions — is a design direction, not a current capability. Anyone promising automatic cross-tool memory today should be asked exactly which tools, storing exactly what.</p>
<h2 id="who-this-is-for" tabindex="-1"><a class="header-anchor" href="#who-this-is-for"><span>Who this is for</span></a></h2>
<p>These are intended audiences and plausible use cases. They’re hypotheses about who benefits, not validated customer segments — PACE has no adoption data I could honestly cite.</p>
<p><strong>Developers in established codebases</strong> are the everyday users. They need to know, while writing a change, which conventions apply and which decisions constrain them — not in review, when the cost of being wrong is a rewrite.</p>
<p><strong>Staff engineers and architects</strong> are the people currently paying the highest tax. They are the ones repeatedly explaining why a boundary exists. The value proposition for them is leverage: write the reasoning down once in a form the tooling can select from, instead of restating it in every review.</p>
<p><strong>Platform and developer-experience teams</strong> are the likeliest internal champions, because they own the question “how do we make AI-assisted development work consistently across a team?” rather than for whoever happens to be good at prompting.</p>
<p><strong>Engineering managers and CTOs</strong> are the ones who decide whether this is worth hosting. Their questions are about review cycles, maintainability, onboarding time, and whether AI adoption is actually improving delivery rather than shifting effort from writing to reviewing.</p>
<p><strong>New team members</strong> may get the most immediate benefit, since a context layer is essentially a queryable version of what experienced colleagues already carry around.</p>
<p>Regulated and security-sensitive organisations are a <em>potential future</em> audience with demanding prerequisites — not a current one. Enforced access isolation, privacy-aware ingestion, and production hardening are prerequisites for that setting, and PACE does not meet them today. No compliance posture is claimed.</p>
<h3 id="when-you-don%E2%80%99t-need-this" tabindex="-1"><a class="header-anchor" href="#when-you-don%E2%80%99t-need-this"><span>When you don’t need this</span></a></h3>
<p>A context service has real setup and maintenance costs: hosting it, ingesting repositories, keeping decisions and constraints current, connecting clients. Skip it if you’re working on a small or short-lived project, a codebase straightforward enough that reading it answers most questions, or a team whose documentation and existing assistant workflows already work. “We wrote a good instructions file and it’s enough” is a legitimate resting place, and probably the right one for a lot of teams.</p>
<h2 id="three-situations%2C-honestly-framed" tabindex="-1"><a class="header-anchor" href="#three-situations%2C-honestly-framed"><span>Three situations, honestly framed</span></a></h2>
<p><strong>Crossing a service boundary.</strong> A developer adds a feature that needs data owned by another service. What matters: the recorded decision that established the boundary, any constraint on how the services communicate, the existing access path, and whether anyone changed that area recently. PACE can bring those into view while the change is being designed, and can produce a snapshot showing what informed it. What it can’t do: confirm the constraint is still current if nobody updated it, or judge whether this case warrants an exception. That’s an architect’s call, and it stays one.</p>
<p><strong>Onboarding into a codebase with history.</strong> A new engineer meets a module whose structure looks strange. What matters: the decision that produced it, the constraint that keeps it that way, and the surrounding code relationships. PACE can surface the reasoning alongside the code so it’s discoverable rather than tribal. The limits are honest ones — if the reasoning was never recorded, no context layer conjures it, and inferred explanations of <em>why</em> code looks the way it does are guesses that should be labelled as such.</p>
<p><strong>Planning a refactor.</strong> Before committing, you want to know what depends on the target, where coverage is thin, and what’s changed recently. PACE can assemble dependency relationships, coverage information, and change activity into one bounded picture, which is genuinely useful for sequencing work. But derived risk signals are heuristics, code analysis fidelity varies by language, and no context layer tells you whether the refactor is a good idea. Tests and review still decide that.</p>
<h2 id="what-this-cannot-promise" tabindex="-1"><a class="header-anchor" href="#what-this-cannot-promise"><span>What this cannot promise</span></a></h2>
<p>A short, blunt list, because the failure mode of posts like this is a limitations section that reads like modesty theatre.</p>
<ul>
<li><strong>Better context does not guarantee correct code.</strong> It improves the odds that generated code fits the system. It doesn’t make the model right.</li>
<li><strong>Stale or conflicting knowledge degrades guidance.</strong> If your decision records are wrong, PACE will faithfully surface wrong decisions. Freshness and conflict resolution are ongoing engineering problems here, not solved ones.</li>
<li><strong>Architecture checks and risk signals are heuristic.</strong> They surface concerns worth a human look. They don’t enforce correctness, and they will produce both false positives and misses.</li>
<li><strong>Human review and testing remain necessary.</strong> Nothing about this shifts responsibility for a change away from the engineer making it and the reviewer approving it.</li>
<li><strong>Ingesting a repository creates obligations.</strong> Source code contains secrets, personal data, and material that shouldn’t leave a boundary. Content-level privacy controls and enforced project-level access isolation are prerequisites for sensitive use, and they are not complete in PACE today. I’ll say that plainly rather than publish details.</li>
<li><strong>It costs effort to run.</strong> A service to host, evidence to keep current, guidance to maintain. That’s a real ongoing tax, and if the knowledge burden isn’t large it won’t pay for itself.</li>
</ul>
<p>And a few things PACE explicitly does <em>not</em> do, since adjacent products sometimes claim them: it is not a universal transparent proxy in front of your existing AI tools, it does not enforce organisation-wide policy, it does not provide automatic memory across tools, it does not coordinate multi-agent work, and it does not prevent conflicting concurrent changes.</p>
<h2 id="how-you%E2%80%99d-know-whether-it%E2%80%99s-working" tabindex="-1"><a class="header-anchor" href="#how-you%E2%80%99d-know-whether-it%E2%80%99s-working"><span>How you’d know whether it’s working</span></a></h2>
<p>If you were evaluating this idea — PACE or anything like it — these are the questions I’d want answered. They’re evaluation criteria, not results I can report.</p>
<ul>
<li>Does the guidance actually reference relevant project decisions, or generic best practice dressed up as project knowledge?</li>
<li>Do reviewers spend less time restating conventions the team already agreed on?</li>
<li>Can a new developer find the reasoning behind a surprising piece of code without asking a person?</li>
<li>Are architectural concerns surfaced early enough that acting on them is cheap?</li>
<li>Is the assembled context inspectable — can you see what informed a suggestion and judge whether it was the right material?</li>
<li>Does the benefit exceed the cost of running the service and keeping its knowledge current?</li>
</ul>
<p>That last one is the real test, and it’s the one most likely to come back negative for a given team. I’d rather that be answered honestly than assumed.</p>
<h2 id="back-to-the-pull-request" tabindex="-1"><a class="header-anchor" href="#back-to-the-pull-request"><span>Back to the pull request</span></a></h2>
<p>Return to the billing service. The version of that afternoon I’m building toward isn’t one where the assistant is smarter. It’s one where the constraint on direct database access, the existence of the retry helper, and the record of the rejected approach are available at the moment the change is being written — because the project holds them, not because the developer remembered to paste them in.</p>
<p>The change still needs review. The tests still need to pass. The engineer still owns it. What changes is that the review is about the substance of the feature rather than about re-teaching the system’s history for the fourth time this quarter, and the explanation the developer gave last time doesn’t have to be given again.</p>
<p>PACE is one attempt at that, with a working core and a good deal still unbuilt. Whether the approach is right is an open question, and I’d rather hold it open than close it with a claim I can’t support.</p>
<p>So here’s the question I’d genuinely like other people’s answers to: <strong>what do you find yourself explaining to your AI tools over and over — the constraint, the convention, the decision that keeps getting rediscovered — and what would it take for your project to hold that instead of you?</strong></p>
]]></content:encoded>
    </item>
    <item>
      <title>Studiocaster: A Labour of Love, Built Around the Listener</title>
      <link>https://ikoonman.io/blog/studiocaster-labour-of-love/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/studiocaster-labour-of-love/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>How a personal project to tame live listener messages grew into a working tool that charity broadcasters now rely on.</description>
      <category>studiocaster</category>
      <category>radio</category>
      <category>broadcasting</category>
      <category>side-projects</category>
      <content:encoded><![CDATA[<p>It’s twenty past seven on a Saturday evening. The presenter has a request show to run, a guest arriving at half past, and a link to write before the next song ends. Meanwhile the messages are coming in: a text to the studio number asking for a song for someone’s mum, a WhatsApp from a listener who has just finished a night shift, a Facebook comment with a dedication in it, and one message that definitely should not be read out on air. The producer has three tabs open and a phone face-up on the desk, and is trying to hold all of it in their head at once.</p>
<p>Nothing in that scene is dramatic. That’s rather the point. It’s just a small team doing several jobs at once, and the audience’s contributions — the whole reason the show exists — becoming one more thing to keep track of.</p>
<p>Studiocaster started as my attempt to make that moment less fraught. It began as a labour of love, it’s still very much an active project, and it’s now used by charities. That last part is what changed how I think about it.</p>
<h2 id="the-problem%2C-in-plain-terms" tabindex="-1"><a class="header-anchor" href="#the-problem%2C-in-plain-terms"><span>The problem, in plain terms</span></a></h2>
<p>Listener messages arrive through whatever channel the listener happens to prefer. Some people text. Some use WhatsApp. Some reply to a post on social media. From the station’s side, each of those is a separate inbox with its own notifications, its own layout, and its own way of showing you what’s new.</p>
<p>That fragmentation costs more than it looks like it should. Requests and dedications get missed simply because nobody was looking at the right window when they arrived. A genuinely good contribution scrolls past during a track. And every message needs a moment of judgement before it goes anywhere near a microphone — is this appropriate, is it from someone who’s been abusive before, is it what it appears to be? Making that call well is hard when the raw material is scattered across four applications and one personal phone.</p>
<p>Bringing it all into one place doesn’t make the work disappear. It makes the work visible, which turns out to be most of the battle.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;SMS&quot;] --&gt; E[&quot;One live stream&quot;]
    B[&quot;WhatsApp&quot;] --&gt; E
    C[&quot;Social channels&quot;] --&gt; E
    D[&quot;Other messaging apps&quot;] --&gt; E
    E --&gt; F[&quot;Screen and organise&quot;]
    F --&gt; G[&quot;Choose what goes on air&quot;]
</pre>
<p>The diagram makes a simple claim: channels should be inputs, not separate workflows. Where a message came from is a technical detail. What matters to the team is the single editorial decision between <em>message received</em> and <em>microphone live</em>.</p>
<h2 id="which-channels-it-takes-messages-from" tabindex="-1"><a class="header-anchor" href="#which-channels-it-takes-messages-from"><span>Which channels it takes messages from</span></a></h2>
<p>Being specific about this matters, because “supports social media” can mean almost anything. Studiocaster’s ingestion is built around per-provider adapters, so each source has its own small piece of code that knows how to authenticate that provider’s webhook and pull the useful fields out of its payload.</p>
<p>The channels with adapters in the platform today are:</p>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Providers</th>
</tr>
</thead>
<tbody>
<tr>
<td>SMS</td>
<td>Twilio, Nexmo/Vonage, Esendex</td>
</tr>
<tr>
<td>SMS, on site</td>
<td>A GSM module on a Raspberry Pi, for stations keeping an existing SIM</td>
</tr>
<tr>
<td>WhatsApp</td>
<td>Meta Cloud API, Twilio WhatsApp</td>
</tr>
<tr>
<td>Facebook</td>
<td>Page messages</td>
</tr>
<tr>
<td>Instagram</td>
<td>Direct messages</td>
</tr>
<tr>
<td>Telegram</td>
<td>Bot messages</td>
</tr>
<tr>
<td>Signal</td>
<td>Incoming messages</td>
</tr>
<tr>
<td>Twitter/X</td>
<td>Mentions, via polling</td>
</tr>
</tbody>
</table>
<p>Two honest caveats. The Twitter/X path is poll-based and depends on which API tier a station has access to — the adapter exists, but I’d treat it as unfinished rather than something to rely on this Saturday. And a handful of other providers are designed for but not built: YouTube live chat and comments is the one I most want, alongside Discord, Slack, TikTok and LinkedIn. They’re all gated on API access and approval rather than on the adapter itself, which is the easy part.</p>
<p>The reason the list can grow that way is that adapters do very little. A provider adapter authenticates the request, extracts the sender, the text, a timestamp and an ID, and hands over a normalised message. It is explicitly not allowed to implement banning, filtering, keyword matching or competitions. Every channel goes through exactly the same processing afterwards, which means a new source can’t quietly behave differently from the others.</p>
<h2 id="the-part-with-hardware-in-it" tabindex="-1"><a class="header-anchor" href="#the-part-with-hardware-in-it"><span>The part with hardware in it</span></a></h2>
<p>Not every message arrives over an API. Some stations have a studio number that’s been on posters, car stickers, the back of a mug and the bottom of every jingle for a decade, and it lives in a SIM card. They are, reasonably, not willing to give it up in order to move to a hosted provider. The number <em>is</em> part of the relationship with the audience; asking listeners to learn a new one costs more than the tidiness is worth.</p>
<p>So for those stations the ingestion point is a physical one: a GSM module attached to a Raspberry Pi, sitting in the building with the original SIM in it. Texts arrive exactly as they always have. The Pi reads them off the module, stores them locally, and pushes them up to the cloud, where they join the same pipeline as everything else — same normalisation, same moderation, same dashboard. From the producer’s side of the desk there is nothing to distinguish a message that came through a hosted SMS provider from one that came through a device on a shelf in the back room.</p>
<p>Storing on the Pi first is the same reasoning as keeping the raw webhook payload: the local copy is what survives a broken internet connection, and the backlog goes up when the link returns.</p>
<p>One of those units has been running at a charity for more than eight years and is still going. Unattended — nobody there gives it a thought, which is the correct amount of thought to give it. I can reach it remotely for the occasional bit of maintenance, and occasionally do. That’s the whole of it: eight years of Saturday evenings on a board that costs less than a decent microphone.</p>
<h2 id="what-studiocaster-actually-does" tabindex="-1"><a class="header-anchor" href="#what-studiocaster-actually-does"><span>What Studiocaster actually does</span></a></h2>
<p>At its centre is a live dashboard. Messages appear as they arrive, in real time, from every connected channel at once. From there the useful part is what you can do with them.</p>
<p>You can filter and label, so a request show can pull up requests and leave the rest for later. You can favourite the ones worth keeping, and hold back or block senders who’ve caused problems before, so that judgement doesn’t have to be made afresh every week. Messages can be claimed or assigned, which sounds like a small thing until you’ve had two people in a small team both reply to the same listener — or neither of them.</p>
<p>It handles competitions, where entries arrive by keyword and need collecting, tracking and drawing without someone maintaining a spreadsheet during a live show. And it lets you search back through what’s come in before, which matters more than I expected: stations get asked to find a message from last Tuesday surprisingly often.</p>
<p>What it isn’t, deliberately, is a replacement for your scheduling or playout system. Studiocaster is about the audience side of the desk. The music software can carry on doing what it does well.</p>
<h2 id="what-happens-to-a-message" tabindex="-1"><a class="header-anchor" href="#what-happens-to-a-message"><span>What happens to a message</span></a></h2>
<p>Between a listener pressing send and their text appearing on the studio screen, a fair amount happens — and most of it is the work a producer would otherwise be doing by hand.</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Message arrives at the webhook&quot;] --&gt; B[&quot;Acknowledge immediately, store the raw event&quot;]
    B --&gt; C[&quot;Queue for processing&quot;]
    C --&gt; D[&quot;Normalise into a common shape&quot;]
    D --&gt; E[&quot;Identify the sender, or create them&quot;]
    E --&gt; F[&quot;Apply moderation: bans, blacklists, expletive filter&quot;]
    F --&gt; G[&quot;Flag priority senders and first-time contributors&quot;]
    G --&gt; H[&quot;Match keywords and competition entries&quot;]
    H --&gt; I[&quot;Save message, sender and conversation together&quot;]
    I --&gt; J[&quot;Push to the live dashboard&quot;]
</pre>
<p>A few of those steps are worth pulling out.</p>
<p>The webhook accepts and acknowledges the message straight away, before doing any real work, and keeps a copy of the raw payload. That matters because providers give up and retry if you’re slow, and because when something does go wrong you want the original to replay rather than a lost message. Duplicates are caught by the provider’s own message ID, so a retried delivery doesn’t turn into a second entry on screen.</p>
<p>The actual processing then happens on a queue, off the request path. That’s where the useful automation lives: the sender is matched to their history, bans and blacklists are applied, the expletive filter runs — keeping both the original and the censored version — regular contributors are marked as priority, first-timers are flagged as first-timers, keywords are matched, and competition entries are recognised and linked to the right competition. All of it is written in a single transaction, so a message and its entry either both exist or neither does.</p>
<p>One detail I think is right and would defend: a message from a banned sender is still stored. It’s just marked as not for display. Deleting it would throw away the moderation history, and a station occasionally needs to show what was received, not only what was aired.</p>
<p>Finally the message is pushed to the dashboard over a live connection, so it appears without anyone refreshing anything. The dashboard also reconciles against the database behind the scenes, on the assumption that a live connection is an acceleration rather than a guarantee — the sort of belt-and-braces you want in something running during a broadcast.</p>
<h2 id="who-it%E2%80%99s-for" tabindex="-1"><a class="header-anchor" href="#who-it%E2%80%99s-for"><span>Who it’s for</span></a></h2>
<p>The people I picture using it are presenters, producers and station operators — the ones actually in the room while the programme is going out.</p>
<p>Community and charity broadcasters fit particularly well, and that’s borne out by the fact that charities are the confirmed users today. In those stations the roles blur: the person presenting may also be the person screening messages, and the person screening messages may also be on the rota to lock up afterwards. A shared workspace helps precisely because responsibilities move around. If everything lives in one place with a visible state, a volunteer picking up a shift can see where things stand rather than having to be briefed.</p>
<p>Beyond that there are other kinds of stations it could suit — student radio, hospital radio, small commercial outfits, anyone running phone-ins or request shows. I’d rather describe those as plausible than claim them. And I’d rather not suggest that every charity needs broadcast software; most don’t. The ones that broadcast do.</p>
<p>Listeners benefit indirectly, and I think that’s the honest framing. Nobody sending a dedication cares what software the station runs. They care whether their message gets noticed.</p>
<h2 id="why-it-still-matters-to-me" tabindex="-1"><a class="header-anchor" href="#why-it-still-matters-to-me"><span>Why it still matters to me</span></a></h2>
<p>There’s a particular shift that happens when a personal project acquires actual users. Before that, the project belongs to you: you build what interests you, you break things when you feel like it, and the only person inconvenienced is you at two in the morning.</p>
<p>Afterwards, someone is depending on it during a live broadcast. That’s a genuine responsibility, and it changes the work in ways I didn’t fully anticipate. Reliability stops being an abstract virtue. Small friction that I’d learned to work around becomes something I have to actually fix, because a volunteer meeting it for the first time on a Saturday evening won’t know the workaround.</p>
<p>That responsibility is also what makes it worth continuing. Feedback from people using it in anger has been more useful than any amount of my own speculation about what a station might want. It has repeatedly redirected what I build next.</p>
<p>Some of the work isn’t visible from the outside at all. A good deal of effort has gone into moving from an older application towards a more maintainable modern platform — not because rewriting is fun, but because a tool people rely on needs to be something I can still change safely in five years’ time. That’s an investment in the project’s future rather than a feature anyone will notice.</p>
<p>I want to be careful about what I claim. There’s been meaningful progress, and there’s plenty still in flight. Some capabilities exist in the codebase or on the roadmap without being available to everyone using it today — live polls, for instance, are something I’m working towards rather than something you can switch on. The project is evolving, and I’d rather say so than imply it’s finished.</p>
<h2 id="still-going" tabindex="-1"><a class="header-anchor" href="#still-going"><span>Still going</span></a></h2>
<p>Studiocaster remains what it started as: something I build because I want it to exist, now with the added weight of people who’d notice if it stopped working. I don’t have grand plans to announce. I have a list of things to improve, a handful of stations whose feedback shapes that list, and a fairly clear sense of what the tool is for.</p>
<p>If the result is that a presenter spots a dedication they’d otherwise have missed, and a listener hears their name on the radio — that’s a good enough reason to keep going.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Teamcentric Forge: Documents That Gather Their Own Facts</title>
      <link>https://ikoonman.io/blog/teamcentric-forge-executable-technical-documentation/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/teamcentric-forge-executable-technical-documentation/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>An introduction to Teamcentric Forge, a platform where humans write the explanation and connected systems supply the changing facts at the moment a document is generated.</description>
      <category>documentation</category>
      <category>developer-tools</category>
      <category>platform-engineering</category>
      <category>release-management</category>
      <content:encoded><![CDATA[<p><em>Teamcentric Forge is a platform for executable technical documentation: people write the meaning and context, and connected systems supply the facts when the document is generated.</em></p>
<p>It is late on a Thursday and an engineer has to produce a release handover before the change window opens. The explanatory part is the easy part — they know what changed, which behaviour the on-call team should watch, and which config flag is new. The tedious part is everything else. Which commit is actually deployed? Are the health endpoints returning what they should right now? Which artifacts came out of this build, and are they the ones referenced in the ticket? So the engineer opens four browser tabs and a terminal, copies values into the document by hand, and hopes nothing shifts between now and the handover call.</p>
<p>Two days later somebody asks whether the document still reflects reality. Nobody can answer with confidence, because the document is a photograph with no timestamp and no record of where the picture was taken.</p>
<h2 id="the-problem-is-not-writing.-it-is-transcription." tabindex="-1"><a class="header-anchor" href="#the-problem-is-not-writing.-it-is-transcription."><span>The problem is not writing. It is transcription.</span></a></h2>
<p>Technical documents rarely go stale because the prose was wrong. They go stale because the facts wrapped inside the prose were copied out of systems that kept moving. Release references, service health results, artifact details, file contents — all of it drifts while the surrounding explanation remains perfectly useful.</p>
<p>The work this creates is familiar and quietly expensive:</p>
<ul>
<li><strong>Repeated checking.</strong> Before every review, somebody re-verifies the same handful of values by hand.</li>
<li><strong>Copy-and-paste updates.</strong> Fixing a stale document means visiting each source system again and pasting the new value in the right place.</li>
<li><strong>Inconsistent reports.</strong> Two engineers documenting the same release pull different fields, in different formats, from different places.</li>
<li><strong>Uncertainty.</strong> A reader cannot tell whether a value was true when it was written, true last week, or never checked at all.</li>
</ul>
<p>Teams usually respond by writing less documentation, or by accepting that some of it is decorative. Neither is a good outcome when the document is meant to serve as evidence for a review, or as the first thing an on-call engineer reads at 3 a.m.</p>
<h2 id="what-%E2%80%9Cexecutable-documentation%E2%80%9D-means-here" tabindex="-1"><a class="header-anchor" href="#what-%E2%80%9Cexecutable-documentation%E2%80%9D-means-here"><span>What “executable documentation” means here</span></a></h2>
<p>Forge source documents are written in Markdown, the way any technical document would be. What makes them executable is that selected sections behave as reusable instructions for gathering and presenting facts, rather than as static text. Instead of pasting the current commit reference into a sentence, the author writes the sentence and marks the place where that reference should be retrieved.</p>
<p>When the document is generated, those sections are carried out. The narrative stays exactly as the author wrote it; the factual parts are filled in from the systems that hold the answers. Alongside the readable output, Forge produces provenance information so a reader can see where each generated fact came from.</p>
<p>The division of labour is the whole idea: <strong>people write the meaning and context, and connected systems supply the changing facts.</strong></p>
<pre class="mermaid">
flowchart LR
    A[&quot;Human-written context&quot;] --&gt; C[&quot;Generate document&quot;]
    B[&quot;Facts from source systems&quot;] --&gt; C
    C --&gt; D[&quot;Readable snapshot&quot;]
    C --&gt; E[&quot;Provenance record&quot;]
</pre>
<p>The diagram above is the shape of every Forge document. Two inputs meet at generation time — the explanation a person wrote, and the facts retrieved from source systems — and two things come out: a document a human can read, and a record of where its facts came from. The snapshot is what you circulate; the provenance is what you consult when someone asks how a value got there.</p>
<p>The documented integrations today are deliberately unglamorous categories: <strong>HTTP endpoints</strong>, <strong>Git repositories</strong>, and <strong>local files</strong>. Between them they cover much of the release-handover problem — service checks, source-control references, and information about files produced by a build.</p>
<p>It is worth being clear about what Forge is not. It is not an AI writing tool. It does not draft your explanation, summarise your release, or generate prose on your behalf. The writing is yours.</p>
<h2 id="from-authoring-to-generation" tabindex="-1"><a class="header-anchor" href="#from-authoring-to-generation"><span>From authoring to generation</span></a></h2>
<p>Forge separates the work of composing a document from the act of running it.</p>
<pre class="mermaid">
flowchart LR
    subgraph Authoring[&quot;Authoring and publishing&quot;]
        A[&quot;Compose document&quot;] --&gt; B[&quot;Publish version&quot;]
        R[&quot;Reusable building blocks&quot;] --&gt; A
    end
    subgraph Execution[&quot;User or team-controlled environment&quot;]
        C[&quot;Generate published document&quot;] --&gt; D[&quot;Document snapshot and provenance&quot;]
        S[&quot;Accessible source systems&quot;] --&gt; C
    end
    B --&gt; C
</pre>
<p>Reading left to right: authoring and publishing happen in the browser, and generation happens elsewhere — on an engineer’s machine or in an automation environment the team controls. The single arrow between the two halves is a <em>published version</em>. That is the only thing generation consumes.</p>
<p>In sequence:</p>
<ol>
<li><strong>An author writes a document</strong> in Markdown and includes sections that retrieve facts rather than restating them.</li>
<li><strong>Teams organise documents and reusable building blocks in shared libraries</strong>, so the same fact-gathering section can be reused across many documents instead of being reinvented.</li>
<li><strong>Authors deliberately publish a version</strong> when it is ready to be generated. Drafts stay drafts.</li>
<li><strong>A user generates the document</strong> with the command-line tool, either locally or in a team-controlled automation environment.</li>
<li><strong>Forge retrieves the relevant facts and produces a readable snapshot</strong> together with provenance information.</li>
</ol>
<p>Generated output stays in the environment where generation ran, by default — including when the source document came from the shared library. Authoring is collaborative; execution is local to whoever ran it.</p>
<p>One property matters more than any other: <strong>a snapshot reflects the moment it was generated.</strong> It is not a live dashboard and does not keep itself current. If you want a current picture, you generate again.</p>
<h2 id="why-the-design-choices-earn-their-keep" tabindex="-1"><a class="header-anchor" href="#why-the-design-choices-earn-their-keep"><span>Why the design choices earn their keep</span></a></h2>
<table>
<thead>
<tr>
<th>Design choice</th>
<th>What it gives you</th>
</tr>
</thead>
<tbody>
<tr>
<td>Human narrative alongside generated facts</td>
<td>The context, caveats and intent survive; only the volatile values are machine-supplied</td>
</tr>
<tr>
<td>Facts retrieved at generation time</td>
<td>Far less manual transcription, and less opportunity to paste the wrong value</td>
</tr>
<tr>
<td>Reusable, versioned building blocks</td>
<td>Recurring documentation gets standardised instead of re-derived per author</td>
</tr>
<tr>
<td>Deliberate publishing</td>
<td>A clear line between work in progress and source approved for generation</td>
</tr>
<tr>
<td>Versioning of the document definition</td>
<td>The definition is stable; changing source systems can still yield different factual results</td>
</tr>
<tr>
<td>Provenance records</td>
<td>Traceability — a reader can see where a value came from, which is not the same as a guarantee that the upstream source was correct</td>
</tr>
<tr>
<td>Generation in the user’s environment</td>
<td>Documents can draw on resources reachable from that environment</td>
</tr>
<tr>
<td>Explicit failure handling</td>
<td>Depending on configuration, generation can stop outright or visibly flag missing or stale information rather than silently producing a confident-looking blank</td>
</tr>
</tbody>
</table>
<p>That last row deserves emphasis. The failure mode to avoid is not an error message; it is a document that looks complete and is not.</p>
<h2 id="where-this-fits" tabindex="-1"><a class="header-anchor" href="#where-this-fits"><span>Where this fits</span></a></h2>
<ul>
<li><strong>Developers preparing release handovers</strong> — explanatory notes, a source-control reference, endpoint health results, and artifact information in one generated document rather than five tabs.</li>
<li><strong>Platform and DevOps teams documenting operational state</strong> — runbooks and environment overviews whose factual sections are gathered rather than remembered.</li>
<li><strong>Technical writers maintaining explanations wrapped around changing technical facts</strong> — the prose stays owned by the writer, and the values that keep invalidating it stop being their problem.</li>
<li><strong>Teams collecting traceable technical evidence for review</strong> — a snapshot plus provenance, generated at a known moment, in an environment the team controls.</li>
</ul>
<p>These are applications the approach suits, not claims about existing customers or certifications.</p>
<h2 id="the-honest-caveats" tabindex="-1"><a class="header-anchor" href="#the-honest-caveats"><span>The honest caveats</span></a></h2>
<p>Forge moves the effort of gathering facts; it does not abolish the conditions that make facts gatherable. Source systems have to be available and reachable from wherever generation runs, access permissions have to permit the retrieval, and the output is bounded by the quality of the sources: a document faithfully reporting a misconfigured health endpoint is faithful, not correct. Provenance helps a reader trace a value to its origin — it does not vouch for that origin.</p>
<p>Nor is a snapshot a reproducibility guarantee. Versioning stabilises what the document <em>asks for</em>; if the systems answering have changed, the answers change with them, so two generations of the same version can legitimately differ.</p>
<h2 id="back-to-thursday-afternoon" tabindex="-1"><a class="header-anchor" href="#back-to-thursday-afternoon"><span>Back to Thursday afternoon</span></a></h2>
<p>The engineer still has to write the handover, and they should — nobody else knows why this release matters, what to watch, or which flag is new. What they no longer have to do is act as a courier between five systems and a Markdown file, then defend the freshness of numbers they typed by hand.</p>
<p>They write the explanation. Forge gathers the facts for that particular snapshot, records where each one came from, and stamps it with the moment it was true.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Three Layers of Organisational Source of Truth in Software Projects</title>
      <link>https://ikoonman.io/blog/organisational-source-of-truth-software-projects/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/organisational-source-of-truth-software-projects/</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <description>Software projects need more than code as a source of truth: they also need authoritative project context and organisational governance.</description>
      <category>software-development</category>
      <category>software-architecture</category>
      <category>governance</category>
      <category>documentation</category>
      <category>source-of-truth</category>
      <content:encoded><![CDATA[<p>Software teams often talk about having a <strong>single source of truth</strong>. In practice, a software project rarely has just one.</p>
<p>The source code may tell us exactly what the system does today, but it does not necessarily tell us what the system <strong>should</strong> do, why certain decisions were made, or whether those decisions comply with the wider organisation’s rules.</p>
<p>A more useful model is to think of software development as operating within <strong>three layers of organisational truth</strong>:</p>
<ol>
<li><strong>Code source of truth</strong> — what the system actually does.</li>
<li><strong>Project bible</strong> — what the project intends, assumes, and has decided.</li>
<li><strong>Organisation governance</strong> — what the organisation permits, requires, and standardises.</li>
</ol>
<p>These layers overlap, but they answer fundamentally different questions.</p>
<pre class="mermaid">
flowchart TB
    G[&quot;Organisation Governance&lt;br/&gt;What are we allowed and required to do?&quot;]
    P[&quot;Project Bible&lt;br/&gt;What should this project do and why?&quot;]
    C[&quot;Code Source of Truth&lt;br/&gt;What does the system actually do?&quot;]

    G --&gt; P
    P --&gt; C

    C -. &quot;Implementation feedback&quot; .-&gt; P
    P -. &quot;Project feedback&quot; .-&gt; G
</pre>
<p>The important point is that truth flows downward as constraints and intent, while information also needs to flow upward when implementation exposes assumptions, problems, or new requirements.</p>
<h2 id="1.-code-source-of-truth" tabindex="-1"><a class="header-anchor" href="#1.-code-source-of-truth"><span>1. Code Source of Truth</span></a></h2>
<p>At the lowest and most concrete level is the codebase.</p>
<p>The repository contains the executable reality of the system:</p>
<ul>
<li>application code;</li>
<li>configuration;</li>
<li>database migrations;</li>
<li>infrastructure definitions;</li>
<li>API contracts;</li>
<li>automated tests;</li>
<li>dependency definitions;</li>
<li>build and deployment configuration.</li>
</ul>
<p>If the documentation says that an API endpoint accepts one parameter but the deployed application accepts another, the running code ultimately determines what actually happens.</p>
<p>In that sense, <strong>code is the source of truth for implementation</strong>.</p>
<p>But this phrase is sometimes stretched too far.</p>
<p>Code is generally very good at answering:</p>
<blockquote>
<p>What does the system currently do?</p>
</blockquote>
<p>It is much less reliable at answering:</p>
<blockquote>
<p>Why does it do this?</p>
</blockquote>
<p>Consider a seemingly arbitrary rule:</p>
<pre class="code-block"><code class="hljs language-typescript"><span class="hljs-keyword">if</span> (failedAttempts &gt;= <span class="hljs-number">5</span>) {
    <span class="hljs-title function_">lockAccount</span>();
}
</code></pre>
<p>The code clearly tells us that five failed attempts result in an account lock.</p>
<p>It does not tell us whether five attempts were chosen because of:</p>
<ul>
<li>a security policy;</li>
<li>a regulatory requirement;</li>
<li>a product decision;</li>
<li>a historical incident;</li>
<li>a temporary workaround;</li>
<li>or simply a developer’s judgement several years ago.</li>
</ul>
<p>The implementation is authoritative about behaviour, but not necessarily about <strong>intent</strong>.</p>
<p>This becomes especially important when someone — human or AI — changes the code.</p>
<p>A developer might reasonably look at that logic and decide that ten attempts would create a better user experience. From a purely technical perspective, the change may be perfectly valid.</p>
<p>From an organisational perspective, it could be completely wrong.</p>
<h2 id="2.-the-project-bible" tabindex="-1"><a class="header-anchor" href="#2.-the-project-bible"><span>2. The Project Bible</span></a></h2>
<p>Between organisational policy and source code sits a layer that many projects have informally but few maintain deliberately.</p>
<p>I think of it as the <strong>project bible</strong>.</p>
<p>This is not simply conventional technical documentation. It is the authoritative body of knowledge describing what the project is supposed to be.</p>
<p>It may include:</p>
<ul>
<li>architecture principles;</li>
<li>domain terminology;</li>
<li>functional requirements;</li>
<li>important business rules;</li>
<li>design decisions;</li>
<li>architectural decision records;</li>
<li>integration contracts;</li>
<li>security assumptions;</li>
<li>data ownership;</li>
<li>expected system behaviour;</li>
<li>constraints and non-goals;</li>
<li>accepted technical debt;</li>
<li>deployment assumptions;</li>
<li>conventions specific to the project.</li>
</ul>
<p>For example, imagine an organisation building a multi-tenant SaaS platform.</p>
<p>The code may currently contain:</p>
<pre class="code-block"><code class="hljs language-text">tenant_id
</code></pre>
<p>throughout its database queries.</p>
<p>The project bible should contain the more important rule behind that implementation:</p>
<blockquote>
<p>All tenant-owned data must be isolated by tenant at every persistence and service boundary.</p>
</blockquote>
<p>That statement survives implementation changes.</p>
<p>The team could move from PostgreSQL row filtering to separate schemas, separate databases, or some future storage architecture. The source code would change substantially, but the <strong>project truth</strong> would remain: tenant isolation is a fundamental system requirement.</p>
<p>This is what makes the project bible different from generated documentation.</p>
<p>Generated documentation can describe the system.</p>
<p>A project bible helps define the system.</p>
<h3 id="it-also-records-why" tabindex="-1"><a class="header-anchor" href="#it-also-records-why"><span>It also records why</span></a></h3>
<p>Software projects accumulate decisions that are almost invisible when looking only at the finished code.</p>
<p>Perhaps the team evaluated three message brokers and deliberately chose one.</p>
<p>Perhaps a particular service is separate because it has a very different security boundary.</p>
<p>Perhaps an apparently inefficient database structure exists because historical records are legally required to remain immutable.</p>
<p>Without project-level context, future developers can mistake deliberate design decisions for mistakes.</p>
<p>Worse, they may successfully “fix” them.</p>
<h2 id="3.-organisation-governance" tabindex="-1"><a class="header-anchor" href="#3.-organisation-governance"><span>3. Organisation Governance</span></a></h2>
<p>Above individual projects sits a third source of truth: <strong>the organisation itself</strong>.</p>
<p>An engineering team does not operate in isolation.</p>
<p>The organisation may have rules covering:</p>
<ul>
<li>approved technologies;</li>
<li>cybersecurity;</li>
<li>authentication;</li>
<li>encryption;</li>
<li>data retention;</li>
<li>personally identifiable information;</li>
<li>logging and auditing;</li>
<li>regulatory compliance;</li>
<li>infrastructure providers;</li>
<li>software licensing;</li>
<li>dependency management;</li>
<li>accessibility;</li>
<li>coding standards;</li>
<li>deployment processes;</li>
<li>disaster recovery;</li>
<li>observability;</li>
<li>source-control practices;</li>
<li>change management;</li>
<li>AI usage;</li>
<li>architecture standards.</li>
</ul>
<p>These rules may apply to dozens or hundreds of projects.</p>
<p>A project team should therefore not duplicate every organisational rule into its own documentation. Instead, the project should inherit applicable governance and document where project-specific decisions refine it.</p>
<p>For example:</p>
<pre class="code-block"><code class="hljs language-text">Organisation rule:
Customer secrets must never be stored unencrypted.

Project rule:
Integration credentials are stored using the organisation&#x27;s approved
secrets-management platform.

Implementation:
The application retrieves credentials through the secrets service at runtime.
</code></pre>
<p>These are three different statements at three different levels of authority.</p>
<p>They are related, but they are not interchangeable.</p>
<h2 id="the-three-layers-together" tabindex="-1"><a class="header-anchor" href="#the-three-layers-together"><span>The Three Layers Together</span></a></h2>
<p>A useful way of thinking about the relationship is:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Primary question</th>
<th>Typical content</th>
</tr>
</thead>
<tbody>
<tr>
<td>Organisation governance</td>
<td><strong>What must or must not we do?</strong></td>
<td>Policy, compliance, standards, approved technologies</td>
</tr>
<tr>
<td>Project bible</td>
<td><strong>What should this system do, and why?</strong></td>
<td>Architecture, requirements, decisions, constraints</td>
</tr>
<tr>
<td>Code source of truth</td>
<td><strong>What does the system actually do?</strong></td>
<td>Code, configuration, infrastructure, migrations, tests</td>
</tr>
</tbody>
</table>
<p>A healthy project should be able to trace important behaviour through all three layers.</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Governance&lt;br/&gt;Customer data must be encrypted&quot;]
    B[&quot;Project Bible&lt;br/&gt;Customer records use approved encryption strategy&quot;]
    C[&quot;Code&lt;br/&gt;Encryption implementation and configuration&quot;]
    D[&quot;Tests&lt;br/&gt;Verify encrypted storage&quot;]

    A --&gt; B
    B --&gt; C
    C --&gt; D
</pre>
<p>This traceability becomes particularly valuable when something changes.</p>
<h2 id="the-real-problem-is-divergence" tabindex="-1"><a class="header-anchor" href="#the-real-problem-is-divergence"><span>The Real Problem Is Divergence</span></a></h2>
<p>The biggest danger is not missing documentation.</p>
<p>It is <strong>contradictory truth</strong>.</p>
<p>Suppose organisational governance states:</p>
<blockquote>
<p>Production services must use the organisation’s central identity provider.</p>
</blockquote>
<p>The project’s architecture documentation agrees.</p>
<p>But six months later, a developer introduces a local authentication mechanism while building a new administration service.</p>
<p>Now the organisation contains three versions of reality:</p>
<pre class="mermaid">
flowchart TB
    G[&quot;Governance&lt;br/&gt;Use central identity provider&quot;]
    P[&quot;Project Bible&lt;br/&gt;Authentication uses central identity provider&quot;]
    C[&quot;Code&lt;br/&gt;New admin service uses local authentication&quot;]

    G --&gt;|&quot;aligned&quot;| P
    P --&gt;|&quot;DIVERGENCE&quot;| C
</pre>
<p>Nothing may fail technically.</p>
<p>The application compiles.</p>
<p>The tests pass.</p>
<p>CI is green.</p>
<p>The feature works perfectly.</p>
<p>And the implementation is still wrong.</p>
<p>This is one reason conventional code review alone cannot guarantee organisational correctness.</p>
<h2 id="source-of-truth-is-also-about-authority" tabindex="-1"><a class="header-anchor" href="#source-of-truth-is-also-about-authority"><span>Source of Truth Is Also About Authority</span></a></h2>
<p>Once multiple sources of truth exist, another question becomes important:</p>
<p><strong>Which source wins when they disagree?</strong></p>
<p>There should be a hierarchy.</p>
<p>As a general principle:</p>
<pre class="code-block"><code class="hljs language-text">Organisation governance
        ↓
Project requirements and architecture
        ↓
Implementation
</code></pre>
<p>If code contradicts a documented project requirement, either the code needs changing or the requirement needs formally revising.</p>
<p>If the project bible contradicts organisational governance, either the project must change or an explicit exception needs to be approved.</p>
<p>What should not happen is silent divergence.</p>
<p>That distinction matters.</p>
<p>A source of truth is not authoritative because somebody wrote it in Markdown.</p>
<p>It is authoritative because the organisation has agreed that <strong>this is where a particular category of decision is defined</strong>.</p>
<h2 id="changes-need-to-move-in-both-directions" tabindex="-1"><a class="header-anchor" href="#changes-need-to-move-in-both-directions"><span>Changes Need to Move in Both Directions</span></a></h2>
<p>The hierarchy does not mean information only travels downward.</p>
<p>Sometimes implementation teaches us something.</p>
<p>A team may discover that a project requirement is technically impossible, unnecessarily expensive, insecure, or simply based on an incorrect assumption.</p>
<p>The correct response is not to quietly make the code behave differently.</p>
<p>Instead:</p>
<pre class="mermaid">
flowchart LR
    A[&quot;Requirement&quot;]
    B[&quot;Implementation&quot;]
    C[&quot;Problem discovered&quot;]
    D[&quot;Review decision&quot;]
    E[&quot;Update Project Bible&quot;]
    F[&quot;Update Code&quot;]

    A --&gt; B
    B --&gt; C
    C --&gt; D
    D --&gt; E
    E --&gt; F
</pre>
<p>The source of truth itself changes first — or as part of the same controlled change.</p>
<p>The implementation then remains aligned with it.</p>
<p>The same principle applies between a project and organisational governance. If enough projects discover that an organisational rule no longer makes sense, perhaps the governance itself needs revision.</p>
<p>Truth should be controlled, but it should not be fossilised.</p>
<h2 id="ai-makes-this-more-important" tabindex="-1"><a class="header-anchor" href="#ai-makes-this-more-important"><span>AI Makes This More Important</span></a></h2>
<p>AI-assisted software development makes this distinction considerably more important.</p>
<p>An AI coding agent can inspect a repository and become remarkably good at understanding the <strong>local implementation</strong>.</p>
<p>But the repository may not contain the information required to understand the organisation.</p>
<p>The agent may see:</p>
<pre class="code-block"><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">saveDocument</span>(<span class="hljs-params"><span class="hljs-attr">document</span>: <span class="hljs-title class_">Document</span></span>) {
    <span class="hljs-keyword">return</span> repository.<span class="hljs-title function_">save</span>(<span class="hljs-variable language_">document</span>);
}
</code></pre>
<p>and produce a technically excellent enhancement.</p>
<p>What it may not know is:</p>
<ul>
<li>documents have a seven-year retention requirement;</li>
<li>deletion must be soft deletion;</li>
<li>audit records must be immutable;</li>
<li>a tenant can only access documents belonging to that tenant;</li>
<li>certain document categories cannot leave a particular jurisdiction.</li>
</ul>
<p>None of those rules can safely be inferred from programming language syntax.</p>
<p>An AI agent therefore needs access to more than code if it is expected to make decisions rather than simply implement tightly specified instructions.</p>
<pre class="mermaid">
flowchart TB
    AI[&quot;Developer / AI Coding Agent&quot;]

    G[&quot;Organisation Governance&quot;]
    P[&quot;Project Bible&quot;]
    C[&quot;Codebase&quot;]

    G --&gt; AI
    P --&gt; AI
    C --&gt; AI

    AI --&gt; R[&quot;Proposed Change&quot;]
    R --&gt; V[&quot;Validate against all three sources&quot;]
</pre>
<p>The more autonomous development tooling becomes, the more important <strong>context governance</strong> becomes.</p>
<p>Otherwise we risk producing code faster than ever while simultaneously increasing the rate at which systems drift away from their intended architecture and organisational rules.</p>
<h2 id="documentation-alone-does-not-solve-it" tabindex="-1"><a class="header-anchor" href="#documentation-alone-does-not-solve-it"><span>Documentation Alone Does Not Solve It</span></a></h2>
<p>There is another uncomfortable problem.</p>
<p>Most organisations already have plenty of documentation.</p>
<p>The problem is often discovering:</p>
<ul>
<li>which document is authoritative;</li>
<li>whether it is current;</li>
<li>whether another document supersedes it;</li>
<li>who owns it;</li>
<li>which projects it applies to;</li>
<li>which rules are mandatory;</li>
<li>whether the implementation still complies with it.</li>
</ul>
<p>A folder containing 2,000 pages of Confluence documentation is not necessarily a source of truth.</p>
<p>It may simply be a large collection of information.</p>
<p>For something to function as organisational truth, it needs characteristics such as:</p>
<ul>
<li>clear ownership;</li>
<li>defined authority;</li>
<li>versioning;</li>
<li>scope;</li>
<li>review;</li>
<li>traceability;</li>
<li>controlled change;</li>
<li>discoverability.</li>
</ul>
<p>Otherwise developers are forced to decide which pieces of documentation they believe.</p>
<p>And an AI agent faces exactly the same problem, only much faster.</p>
<h2 id="source-of-truth-validation-should-become-part-of-development" tabindex="-1"><a class="header-anchor" href="#source-of-truth-validation-should-become-part-of-development"><span>Source-of-Truth Validation Should Become Part of Development</span></a></h2>
<p>This suggests that source-of-truth validation belongs inside the software development lifecycle rather than outside it.</p>
<p>A simplified workflow might look like this:</p>
<pre class="mermaid">
flowchart TD
    A[&quot;Specification / Task&quot;]
    B[&quot;Check Organisation Governance&quot;]
    C[&quot;Check Project Bible&quot;]
    D[&quot;Implement Change&quot;]
    E[&quot;Code Review&quot;]
    F[&quot;Source-of-Truth Validation&quot;]
    G[&quot;Testing&quot;]
    H[&quot;Update Documentation if Required&quot;]
    I[&quot;Commit / Deploy&quot;]

    A --&gt; B
    B --&gt; C
    C --&gt; D
    D --&gt; E
    E --&gt; F
    F --&gt; G
    G --&gt; H
    H --&gt; I

    F --&gt;|&quot;Conflict found&quot;| C
</pre>
<p>That validation does not necessarily need to be a bureaucratic manual process.</p>
<p>Some rules can be automated.</p>
<p>A system could detect that:</p>
<ul>
<li>a prohibited dependency has been introduced;</li>
<li>an API no longer matches its contract;</li>
<li>a database entity violates a tenant-isolation rule;</li>
<li>a new infrastructure resource uses an unapproved provider;</li>
<li>implementation has changed while its associated architectural decision has not;</li>
<li>code contradicts a project-level requirement.</li>
</ul>
<p>Other decisions still require human judgement.</p>
<p>The objective is not to turn governance into a giant set of linting rules.</p>
<p>It is to make organisational intent <strong>visible at the point where software is being changed</strong>.</p>
<h2 id="not-one-source-of-truth%2C-but-a-chain-of-truth" tabindex="-1"><a class="header-anchor" href="#not-one-source-of-truth%2C-but-a-chain-of-truth"><span>Not One Source of Truth, but a Chain of Truth</span></a></h2>
<p>The phrase “single source of truth” remains useful, provided we apply it to the right scope.</p>
<p>There can be a single authoritative source for organisational policy.</p>
<p>There can be an authoritative project knowledge base.</p>
<p>There can be an authoritative source repository.</p>
<p>But trying to collapse all three into one thing creates a different problem.</p>
<p>Code should not become the organisation’s policy manual.</p>
<p>Corporate governance should not describe individual implementation details.</p>
<p>And the project bible should not attempt to reproduce every line of code.</p>
<p>A better model is a <strong>chain of truth</strong>:</p>
<pre class="mermaid">
flowchart TB
    O[&quot;ORGANISATION&lt;br/&gt;Principles, policies, standards and constraints&quot;]
    P[&quot;PROJECT&lt;br/&gt;Requirements, architecture, decisions and intent&quot;]
    C[&quot;CODE&lt;br/&gt;Actual implementation&quot;]
    R[&quot;RUNTIME&lt;br/&gt;Actual behaviour in production&quot;]

    O --&gt;|&quot;governs&quot;| P
    P --&gt;|&quot;defines&quot;| C
    C --&gt;|&quot;produces&quot;| R

    R -. &quot;observations&quot; .-&gt; C
    C -. &quot;changes &amp; discoveries&quot; .-&gt; P
    P -. &quot;exceptions &amp; lessons&quot; .-&gt; O
</pre>
<p>Each layer has a different responsibility.</p>
<p>Each can change.</p>
<p>Each needs ownership.</p>
<p>And the connections between them matter as much as the individual repositories of information.</p>
<p>When those connections are maintained, a developer can understand not only <strong>what the code does</strong>, but <strong>why it exists and what boundaries it must respect</strong>.</p>
<p>That becomes increasingly important as software development moves from humans writing every line themselves toward humans, AI agents, automated tooling, and organisational systems collectively producing software.</p>
<p>The challenge is no longer simply keeping the code correct.</p>
<p>It is keeping the code aligned with the project, and keeping the project aligned with the organisation.</p>
]]></content:encoded>
    </item>
    <item>
      <title>AI-assisted development is changing the workflow</title>
      <link>https://ikoonman.io/blog/ai-assisted-development/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/ai-assisted-development/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
      <description>AI is changing more than the act of writing code — it is reshaping the process around it.</description>
      <category>AI</category>
      <category>Software Development</category>
      <category>Workflow</category>
      <content:encoded><![CDATA[<h1>AI-assisted development is changing the workflow</h1>
<p>AI is changing more than the act of writing code.</p>
<p>It is changing the surrounding development process as well. The specification
becomes the artefact that matters, review becomes the bottleneck, and the loop
between an idea and something running gets very short.</p>
<h2 id="example-diagram" tabindex="-1"><a class="header-anchor" href="#example-diagram"><span>Example diagram</span></a></h2>
<pre class="mermaid">
flowchart LR
    A[Specification] --&gt; B[Development]
    B --&gt; C[Review]
    C --&gt; D[Test]
    D --&gt; E[Refine]
    E --&gt; A
</pre>
<h2 id="code-still-needs-to-be-read" tabindex="-1"><a class="header-anchor" href="#code-still-needs-to-be-read"><span>Code still needs to be read</span></a></h2>
<pre class="code-block"><code class="hljs language-javascript"><span class="hljs-keyword">const</span> posts = files
  .<span class="hljs-title function_">map</span>(parseFrontmatter)
  .<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">post</span>) =&gt;</span> !post.<span class="hljs-property">draft</span>)
  .<span class="hljs-title function_">sort</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> b.<span class="hljs-property">date</span> - a.<span class="hljs-property">date</span>);
</code></pre>
<p>The volume of generated code goes up, so the value of code that is obvious on
first reading goes up with it.</p>
<h2 id="custom-html" tabindex="-1"><a class="header-anchor" href="#custom-html"><span>Custom HTML</span></a></h2>
<div style="padding:20px;border:1px solid #ccc;border-radius:8px;background:#fff;">
  <strong>This block uses inline HTML and CSS.</strong>
  Markdown is the normal authoring format; raw HTML is the escape hatch when
  exact layout control is needed.
</div>
]]></content:encoded>
    </item>
    <item>
      <title>Modern SaaS Is an Ecosystem, Not Just an Application</title>
      <link>https://ikoonman.io/blog/modern-saas-is-an-ecosystem-not-an-app/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/modern-saas-is-an-ecosystem-not-an-app/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
      <description>A modern SaaS product is surrounded by a large ecosystem of security, delivery, data, operational, support and AI capabilities that make the application itself only one part of the whole platform.</description>
      <category>saas</category>
      <category>software-architecture</category>
      <category>cloud</category>
      <category>devops</category>
      <category>observability</category>
      <category>security</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p>A web application can look deceptively simple from the outside: a browser talks to an API, the API talks to a database, and somewhere in the middle the useful work happens. That model is still technically true, but for a serious modern SaaS platform it is nowhere near complete.</p>
<p>Once security, identity, realtime communication, asynchronous processing, CI/CD, observability, customer support, analytics, external services, multiple hosting models and now AI are taken into account, the “application” becomes only one component in a much larger technology ecosystem.</p>
<!-- Asset:
The accompanying architecture diagram is available as eco.png.
Save/copy it to: public/images/eco.png
Source attachment: [eco.png](sandbox:/mnt/data/eco.png)
-->
<p><img src="/images/eco.png" alt="Modern Enterprise SaaS Architecture showing the application, infrastructure, operational services, hosting choices and AI augmentation layers"></p>
<p>The diagram above is an attempt to represent that ecosystem as a complete reference architecture. It is deliberately broad: not every SaaS product needs every component, but almost every mature SaaS platform will eventually encounter most of these architectural concerns in one form or another.</p>
<h2 id="the-application-is-only-one-layer" tabindex="-1"><a class="header-anchor" href="#the-application-is-only-one-layer"><span>The application is only one layer</span></a></h2>
<p>At the centre is the part we traditionally think of as the application:</p>
<ul>
<li>the front-end UI;</li>
<li>backend services and APIs;</li>
<li>business logic;</li>
<li>product modules;</li>
<li>tenant management;</li>
<li>configuration and entitlements;</li>
<li>administration interfaces.</li>
</ul>
<p>That is only <strong>Layer 7</strong> in the reference model.</p>
<p>Before a request even reaches that application it may already have passed through DNS, a CDN, DDoS protection, a WAF, bot protection, a load balancer, TLS termination, identity services, JWT validation, API rate limiting, request-schema validation and a reverse proxy.</p>
<p>After the application performs its work, it may interact with databases, caches, queues, event streams, object storage, external APIs, analytics systems, email providers, observability platforms and support systems.</p>
<p>Meanwhile, an entirely separate delivery system is responsible for getting the code into production safely.</p>
<p>A simplified view looks something like this:</p>
<pre class="mermaid">
flowchart LR
    U[&quot;Users &amp; Clients&quot;] --&gt; E[&quot;Edge &amp; Network Security&quot;]
    E --&gt; I[&quot;Identity &amp; Access&quot;]
    I --&gt; A[&quot;API Security&quot;]
    A --&gt; C[&quot;Client / API Communication&quot;]
    C --&gt; W[&quot;Web Server / Reverse Proxy&quot;]
    W --&gt; APP[&quot;Application Services&quot;]

    APP --&gt; D[&quot;Data&quot;]
    APP --&gt; M[&quot;Messaging &amp; Async&quot;]
    APP --&gt; R[&quot;Realtime Services&quot;]
    APP --&gt; X[&quot;External Integrations&quot;]

    CI[&quot;CI/CD &amp; Release Engineering&quot;] --&gt; APP

    APP --&gt; O[&quot;Observability &amp; Incident Management&quot;]
    APP --&gt; P[&quot;Ancillary Operational Services&quot;]

    G[&quot;Governance &amp; Compliance&quot;] -.-&gt; APP
    G -.-&gt; D
    G -.-&gt; CI
    G -.-&gt; O
</pre>
<p>The important point is not the exact placement of every box. It is that <strong>the application sits inside an operating system of services around it</strong>.</p>
<h2 id="sixteen-architectural-layers" tabindex="-1"><a class="header-anchor" href="#sixteen-architectural-layers"><span>Sixteen architectural layers</span></a></h2>
<p>The reference model breaks the ecosystem into sixteen main areas.</p>
<h3 id="1.-users-and-client-access" tabindex="-1"><a class="header-anchor" href="#1.-users-and-client-access"><span>1. Users and client access</span></a></h3>
<p>Modern SaaS rarely means only “a website.”</p>
<p>Clients may include:</p>
<ul>
<li>web browsers;</li>
<li>iOS and Android applications;</li>
<li>desktop applications;</li>
<li>command-line tools;</li>
<li>developer and DevOps tooling;</li>
<li>API and SDK consumers;</li>
<li>tenant administrators;</li>
<li>B2B partners and resellers.</li>
</ul>
<p>Those clients may themselves be implemented using React, Next.js, Angular, Vue, Swift, Kotlin, Flutter, React Native, Electron, Tauri or other technologies.</p>
<p>Immediately, one product can have several distinct client architectures.</p>
<h3 id="2.-edge-and-network-security" tabindex="-1"><a class="header-anchor" href="#2.-edge-and-network-security"><span>2. Edge and network security</span></a></h3>
<p>Before application code sees a request, an edge layer may handle:</p>
<ul>
<li>DNS;</li>
<li>CDN delivery;</li>
<li>WAF rules;</li>
<li>DDoS protection;</li>
<li>bot mitigation;</li>
<li>traffic filtering;</li>
<li>load balancing;</li>
<li>TLS certificates;</li>
<li>geographic and IP restrictions.</li>
</ul>
<p>Cloudflare, AWS, Azure, Google Cloud, Fastly and Akamai can provide overlapping parts of this layer.</p>
<p>This is one of the first places where modern architecture becomes less about writing code and more about <strong>composing capabilities</strong>.</p>
<h3 id="3.-identity-and-access-management" tabindex="-1"><a class="header-anchor" href="#3.-identity-and-access-management"><span>3. Identity and access management</span></a></h3>
<p>Authentication has grown far beyond a login form.</p>
<p>A serious SaaS platform may need:</p>
<ul>
<li>SSO;</li>
<li>SAML;</li>
<li>OpenID Connect;</li>
<li>OAuth;</li>
<li>MFA;</li>
<li>WebAuthn;</li>
<li>JWT access tokens;</li>
<li>refresh tokens;</li>
<li>token rotation and revocation;</li>
<li>SCIM provisioning;</li>
<li>RBAC;</li>
<li>fine-grained permissions;</li>
<li>session management;</li>
<li>access auditing.</li>
</ul>
<p>An enterprise customer may consider SSO, SCIM, RBAC and audit logs basic requirements rather than optional extras.</p>
<h3 id="4.-api-security-and-request-processing" tabindex="-1"><a class="header-anchor" href="#4.-api-security-and-request-processing"><span>4. API security and request processing</span></a></h3>
<p>An authenticated request is not automatically a safe request.</p>
<p>The API layer may need to perform:</p>
<ul>
<li>endpoint-specific rate limiting;</li>
<li>JWT validation;</li>
<li>JSON schema validation;</li>
<li>required and allowed field checking;</li>
<li>numeric and string range checking;</li>
<li>query/path/header validation;</li>
<li>payload-size restrictions;</li>
<li>MIME-type validation;</li>
<li>upload controls;</li>
<li>injection protection;</li>
<li>SSRF protection;</li>
<li>idempotency;</li>
<li>replay protection;</li>
<li>correlation IDs;</li>
<li>safe response handling.</li>
</ul>
<p>This layer can involve both application code and dedicated API-security products such as Cloudflare API Shield, Kong, 42Crunch, Wallarm, Salt Security or similar platforms.</p>
<h3 id="5.-client-to-api-communication" tabindex="-1"><a class="header-anchor" href="#5.-client-to-api-communication"><span>5. Client-to-API communication</span></a></h3>
<p>REST is only one communication mechanism.</p>
<p>Applications increasingly combine:</p>
<ul>
<li>REST;</li>
<li>GraphQL;</li>
<li>WebSockets;</li>
<li>Server-Sent Events;</li>
<li>gRPC;</li>
<li>gRPC-Web;</li>
<li>webhooks;</li>
<li>occasionally WebRTC or long polling.</li>
</ul>
<p>A normal request/response API, a live collaboration session and an AI response being streamed token by token have very different communication characteristics.</p>
<h3 id="6.-web-servers-and-reverse-proxies" tabindex="-1"><a class="header-anchor" href="#6.-web-servers-and-reverse-proxies"><span>6. Web servers and reverse proxies</span></a></h3>
<p>There is often another layer between the network and application runtime:</p>
<ul>
<li>NGINX;</li>
<li>Apache;</li>
<li>Caddy;</li>
<li>Traefik;</li>
<li>IIS;</li>
<li>Envoy.</li>
</ul>
<p>These can deal with routing, caching, compression, TLS termination, static content and application hand-off.</p>
<p>Behind them may sit Node.js, Kestrel, Spring, Tomcat, Gunicorn, Uvicorn, Go, PHP-FPM or another runtime.</p>
<p>The once-simple phrase “web server” now covers several quite different responsibilities.</p>
<h3 id="7.-the-application-itself" tabindex="-1"><a class="header-anchor" href="#7.-the-application-itself"><span>7. The application itself</span></a></h3>
<p>Only now do we reach the product’s actual business services.</p>
<p>This may contain:</p>
<ul>
<li>front-end applications;</li>
<li>backend services;</li>
<li>BFFs for web or mobile;</li>
<li>domain services;</li>
<li>tenant management;</li>
<li>administration;</li>
<li>feature flags;</li>
<li>configuration;</li>
<li>subscription entitlements;</li>
<li>notifications;</li>
<li>business rules.</li>
</ul>
<p>This is the part users normally perceive as “the product.”</p>
<p>Everything else exists partly to make this layer secure, reliable, scalable, supportable and commercially viable.</p>
<h3 id="8.-realtime-communication" tabindex="-1"><a class="header-anchor" href="#8.-realtime-communication"><span>8. Realtime communication</span></a></h3>
<p>Realtime features introduce another architectural category.</p>
<p>Examples include:</p>
<ul>
<li>WebSocket gateways;</li>
<li>SSE streams;</li>
<li>user presence;</li>
<li>collaborative editing;</li>
<li>live notifications;</li>
<li>AI streaming;</li>
<li>job-progress updates;</li>
<li>cross-node realtime distribution.</li>
</ul>
<p>Once an application runs on multiple server instances, realtime messaging must usually be distributed through something such as Redis, NATS or Kafka rather than relying on one process’s memory.</p>
<h3 id="9.-messaging%2C-events-and-asynchronous-processing" tabindex="-1"><a class="header-anchor" href="#9.-messaging%2C-events-and-asynchronous-processing"><span>9. Messaging, events and asynchronous processing</span></a></h3>
<p>Some work should not happen inside an HTTP request.</p>
<p>Modern platforms often rely on:</p>
<ul>
<li>RabbitMQ;</li>
<li>SQS;</li>
<li>Azure Service Bus;</li>
<li>Kafka;</li>
<li>Redpanda;</li>
<li>Kinesis;</li>
<li>NATS;</li>
<li>EventBridge;</li>
<li>BullMQ;</li>
<li>Celery;</li>
<li>Sidekiq;</li>
<li>worker processes;</li>
<li>schedulers;</li>
<li>dead-letter queues.</li>
</ul>
<p>Then there are the less glamorous but essential concerns:</p>
<ul>
<li>retries;</li>
<li>backoff;</li>
<li>duplicate handling;</li>
<li>idempotent consumers;</li>
<li>poison messages;</li>
<li>event-version compatibility.</li>
</ul>
<p>Asynchronous architecture solves one class of problems while introducing an entirely new class of operational complexity.</p>
<h2 id="10.-the-data-layer-is-no-longer-%E2%80%9Cthe-database%E2%80%9D" tabindex="-1"><a class="header-anchor" href="#10.-the-data-layer-is-no-longer-%E2%80%9Cthe-database%E2%80%9D"><span>10. The data layer is no longer “the database”</span></a></h2>
<p>A modern data layer may contain several specialised stores:</p>
<ul>
<li>PostgreSQL or MySQL for relational data;</li>
<li>MongoDB or DynamoDB for document/NoSQL workloads;</li>
<li>Redis for caching;</li>
<li>S3 or equivalent object storage;</li>
<li>OpenSearch or Elasticsearch for search;</li>
<li>Snowflake, BigQuery or Redshift for analytics;</li>
<li>vector databases such as pgvector, Pinecone, Weaviate, Qdrant or Milvus;</li>
<li>backup and archival systems.</li>
</ul>
<p>Data lifecycle then adds:</p>
<ul>
<li>retention;</li>
<li>deletion;</li>
<li>residency;</li>
<li>legal hold;</li>
<li>export;</li>
<li>encryption;</li>
<li>tenant isolation.</li>
</ul>
<p>AI adds another reason to think about data architecture carefully because embeddings, semantic search and RAG knowledge stores create additional representations of information that also need governance.</p>
<h2 id="11.-ci%2Fcd-and-release-engineering" tabindex="-1"><a class="header-anchor" href="#11.-ci%2Fcd-and-release-engineering"><span>11. CI/CD and release engineering</span></a></h2>
<p>The development process itself has become part of the production architecture.</p>
<p>Source repositories may live in:</p>
<ul>
<li>GitHub;</li>
<li>GitLab;</li>
<li>Bitbucket;</li>
<li>Azure Repos.</li>
</ul>
<p>From there a release can pass through:</p>
<ul>
<li>build pipelines;</li>
<li>automated tests;</li>
<li>dependency scanning;</li>
<li>secret scanning;</li>
<li>SAST and DAST;</li>
<li>container scanning;</li>
<li>artifact registries;</li>
<li>deployment pipelines;</li>
<li>DEV, TEST, STAGING and PROD environments;</li>
<li>infrastructure as code;</li>
<li>database migrations;</li>
<li>smoke tests;</li>
<li>release gates;</li>
<li>blue/green deployments;</li>
<li>canary releases;</li>
<li>rollback;</li>
<li>release notes.</li>
</ul>
<p>A modern deployment is not ideally “build something and copy it to a server.” It is a controlled progression of an immutable change through a chain of validation and increasingly production-like environments.</p>
<h2 id="12.-external-integrations" tabindex="-1"><a class="header-anchor" href="#12.-external-integrations"><span>12. External integrations</span></a></h2>
<p>Most SaaS products depend on other SaaS products.</p>
<p>Typical examples include:</p>
<ul>
<li>Stripe for payments;</li>
<li>Resend, SendGrid, Postmark, Mailgun or SES for email;</li>
<li>Auth0, Okta or Entra for identity;</li>
<li>Salesforce or HubSpot for CRM;</li>
<li>GitHub, GitLab, Jira and Slack;</li>
<li>OpenAI, Anthropic or other AI providers;</li>
<li>incoming and outgoing webhooks.</li>
</ul>
<p>This creates a significant architectural reality: <strong>part of your application’s behaviour lives outside your application</strong>.</p>
<p>A provider outage, API change, rate limit or authentication problem can become your outage even while every service you operate is healthy.</p>
<h2 id="13.-ancillary-platform-and-operational-services" tabindex="-1"><a class="header-anchor" href="#13.-ancillary-platform-and-operational-services"><span>13. Ancillary platform and operational services</span></a></h2>
<p>This is one of the easiest parts of a SaaS architecture to overlook because these tools often do not participate directly in normal application requests.</p>
<p>They nevertheless form a large part of the operating ecosystem.</p>
<h3 id="email-and-notifications" tabindex="-1"><a class="header-anchor" href="#email-and-notifications"><span>Email and notifications</span></a></h3>
<p>Examples include:</p>
<ul>
<li>Resend;</li>
<li>SendGrid;</li>
<li>Mailgun;</li>
<li>Postmark;</li>
<li>Amazon SES;</li>
<li>Twilio;</li>
<li>Firebase Cloud Messaging.</li>
</ul>
<p>Without them, features such as account verification, password recovery, notifications and operational alerts may stop working.</p>
<h3 id="engineering-and-operations-communication" tabindex="-1"><a class="header-anchor" href="#engineering-and-operations-communication"><span>Engineering and operations communication</span></a></h3>
<p>Teams may rely on:</p>
<ul>
<li>Slack;</li>
<li>Microsoft Teams;</li>
<li>Discord;</li>
<li>Mattermost;</li>
<li>Google Chat;</li>
<li>webhook-driven channels.</li>
</ul>
<p>These can become the nervous system connecting development, deployment, support and NOC operations.</p>
<h3 id="incident-and-on-call-systems" tabindex="-1"><a class="header-anchor" href="#incident-and-on-call-systems"><span>Incident and on-call systems</span></a></h3>
<p>Examples include:</p>
<ul>
<li>PagerDuty;</li>
<li>Opsgenie;</li>
<li>Better Stack;</li>
<li>Splunk On-Call;</li>
<li>Jira incident workflows.</li>
</ul>
<p>Monitoring only tells you something is wrong. Somebody still has to be told, respond, coordinate and eventually close the incident.</p>
<h3 id="error-tracking-and-application-performance" tabindex="-1"><a class="header-anchor" href="#error-tracking-and-application-performance"><span>Error tracking and application performance</span></a></h3>
<p>Platforms such as:</p>
<ul>
<li>Sentry;</li>
<li>Rollbar;</li>
<li>Bugsnag;</li>
<li>Datadog;</li>
<li>New Relic</li>
</ul>
<p>provide visibility into what the application is actually doing in production.</p>
<h3 id="product-analytics" tabindex="-1"><a class="header-anchor" href="#product-analytics"><span>Product analytics</span></a></h3>
<p>Services such as:</p>
<ul>
<li>PostHog;</li>
<li>Amplitude;</li>
<li>Mixpanel;</li>
<li>Heap;</li>
<li>Google Analytics</li>
</ul>
<p>answer a very different question:</p>
<blockquote>
<p>Is the system working technically?</p>
</blockquote>
<p>becomes:</p>
<blockquote>
<p>Are people actually using it the way we expected?</p>
</blockquote>
<h3 id="ux-and-session-analytics" tabindex="-1"><a class="header-anchor" href="#ux-and-session-analytics"><span>UX and session analytics</span></a></h3>
<p>Tools such as:</p>
<ul>
<li>Hotjar;</li>
<li>FullStory;</li>
<li>Microsoft Clarity</li>
</ul>
<p>can expose usability problems that logs and metrics will never reveal.</p>
<p>A technically healthy application can still be a terrible user experience.</p>
<h3 id="customer-support-and-feedback" tabindex="-1"><a class="header-anchor" href="#customer-support-and-feedback"><span>Customer support and feedback</span></a></h3>
<p>A production ecosystem may additionally contain:</p>
<ul>
<li>Intercom;</li>
<li>Zendesk;</li>
<li>Freshdesk;</li>
<li>Crisp;</li>
<li>Help Scout;</li>
<li>Canny;</li>
<li>Productboard;</li>
<li>UserVoice.</li>
</ul>
<p>Support, feedback and product-development systems form another loop around the application:</p>
<pre class="mermaid">
flowchart LR
    P[&quot;Product&quot;] --&gt; U[&quot;User&quot;]
    U --&gt; A[&quot;Analytics / Session Data&quot;]
    U --&gt; S[&quot;Support / Feedback&quot;]

    A --&gt; T[&quot;Product Team&quot;]
    S --&gt; T

    T --&gt; B[&quot;Backlog / Roadmap&quot;]
    B --&gt; D[&quot;Development&quot;]
    D --&gt; P
</pre>
<p>The product is therefore not merely deployed and left running. It lives inside a continuous feedback system.</p>
<h2 id="14.-observability-and-incident-management" tabindex="-1"><a class="header-anchor" href="#14.-observability-and-incident-management"><span>14. Observability and incident management</span></a></h2>
<p>Observability itself deserves a separate architecture layer.</p>
<p>A mature stack may combine:</p>
<ul>
<li>Sentry for errors;</li>
<li>Prometheus for metrics;</li>
<li>Grafana for dashboards;</li>
<li>ELK, Loki or Splunk for logs;</li>
<li>OpenTelemetry for traces;</li>
<li>uptime and synthetic monitoring;</li>
<li>alerting;</li>
<li>incident management;</li>
<li>public status pages;</li>
<li>SLO and SLA measurement.</li>
</ul>
<p>The challenge is not merely collecting telemetry.</p>
<p>It is correlating it.</p>
<p>A single user failure might involve:</p>
<ul>
<li>an API trace;</li>
<li>three microservices;</li>
<li>a queue;</li>
<li>a Redis lookup;</li>
<li>a database query;</li>
<li>an external payment API;</li>
<li>six log streams;</li>
<li>two metrics alerts.</li>
</ul>
<p>More observability data does not automatically mean more observability.</p>
<h2 id="15.-business-and-customer-systems" tabindex="-1"><a class="header-anchor" href="#15.-business-and-customer-systems"><span>15. Business and customer systems</span></a></h2>
<p>Commercial SaaS architecture also contains systems that have little to do with serving an HTTP request but are essential to operating the business:</p>
<ul>
<li>subscriptions;</li>
<li>plans;</li>
<li>entitlements;</li>
<li>usage metering;</li>
<li>invoicing;</li>
<li>tax;</li>
<li>dunning;</li>
<li>CRM;</li>
<li>ticketing;</li>
<li>knowledge bases;</li>
<li>customer success;</li>
<li>onboarding;</li>
<li>feedback;</li>
<li>business analytics.</li>
</ul>
<p>This is where software architecture and business architecture begin to overlap.</p>
<p>For example, a subscription tier may ultimately influence an API request:</p>
<pre class="code-block"><code class="hljs language-text">Authenticated user
    ↓
Tenant
    ↓
Subscription
    ↓
Entitlements
    ↓
Feature / quota
    ↓
Application behaviour
</code></pre>
<p>A pricing decision can therefore become an architectural concern.</p>
<h2 id="16.-governance-and-compliance" tabindex="-1"><a class="header-anchor" href="#16.-governance-and-compliance"><span>16. Governance and compliance</span></a></h2>
<p>Finally, all of the previous layers sit inside governance constraints.</p>
<p>These may include:</p>
<ul>
<li>audit logging;</li>
<li>GDPR;</li>
<li>SOC 2;</li>
<li>ISO 27001;</li>
<li>PCI-DSS;</li>
<li>HIPAA where relevant;</li>
<li>data residency;</li>
<li>retention;</li>
<li>encryption;</li>
<li>secrets management;</li>
<li>KMS;</li>
<li>least privilege;</li>
<li>access reviews;</li>
<li>vulnerability management;</li>
<li>disaster recovery;</li>
<li>RPO and RTO;</li>
<li>privacy;</li>
<li>subprocessors;</li>
<li>incident response.</li>
</ul>
<p>Importantly, compliance is not a single box that can be added at the end.</p>
<p>Its requirements flow through the entire architecture.</p>
<h2 id="cloud-makes-complexity-disappear-%E2%80%94-but-only-operationally" tabindex="-1"><a class="header-anchor" href="#cloud-makes-complexity-disappear-%E2%80%94-but-only-operationally"><span>Cloud makes complexity disappear — but only operationally</span></a></h2>
<p>One useful aspect of the architecture is that the same logical capability can be implemented in very different ways.</p>
<p>A queue could be:</p>
<ul>
<li>AWS SQS;</li>
<li>Azure Service Bus;</li>
<li>Google Pub/Sub;</li>
<li>RabbitMQ;</li>
<li>NATS.</li>
</ul>
<p>Object storage could be:</p>
<ul>
<li>S3;</li>
<li>Azure Blob Storage;</li>
<li>Google Cloud Storage;</li>
<li>MinIO;</li>
<li>Ceph.</li>
</ul>
<p>A managed cloud provider may hide database replication, hardware failure, backups or load-balancer maintenance behind a service boundary.</p>
<p>A self-hosted system does not eliminate those concerns. It simply transfers responsibility for them back to the organisation.</p>
<p>This is why the architecture should be thought of in terms of <strong>capabilities first, products second</strong>.</p>
<h2 id="self-hosting-changes-the-responsibility-boundary" tabindex="-1"><a class="header-anchor" href="#self-hosting-changes-the-responsibility-boundary"><span>Self-hosting changes the responsibility boundary</span></a></h2>
<p>If an application runs on Linux, Windows Server or private infrastructure, a team may additionally own:</p>
<ul>
<li>OS patching;</li>
<li>host firewalls;</li>
<li>runtime updates;</li>
<li>certificates;</li>
<li>storage;</li>
<li>backup;</li>
<li>failover;</li>
<li>monitoring;</li>
<li>capacity;</li>
<li>network configuration;</li>
<li>vulnerability remediation.</li>
</ul>
<p>A managed PostgreSQL service and a PostgreSQL server running on a VM may provide similar database functionality to the application, but they represent very different operational responsibilities.</p>
<p>This distinction matters when judging the complexity of a system.</p>
<h2 id="then-ai-arrives-at-every-layer" tabindex="-1"><a class="header-anchor" href="#then-ai-arrives-at-every-layer"><span>Then AI arrives at every layer</span></a></h2>
<p>AI is unusual because it is not simply another isolated architecture component.</p>
<p>It can potentially augment almost every existing layer.</p>
<p>Examples include:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Possible AI use</th>
</tr>
</thead>
<tbody>
<tr>
<td>User experience</td>
<td>Chat, copilots, natural-language interfaces</td>
</tr>
<tr>
<td>Edge security</td>
<td>Bot and traffic anomaly detection</td>
</tr>
<tr>
<td>Identity</td>
<td>Risk-based authentication</td>
</tr>
<tr>
<td>API security</td>
<td>Abuse and sequence anomaly detection</td>
</tr>
<tr>
<td>Application</td>
<td>RAG, agents, summarisation, recommendation</td>
</tr>
<tr>
<td>Async processing</td>
<td>Intelligent job prioritisation</td>
</tr>
<tr>
<td>Data</td>
<td>Embeddings, semantic search, classification</td>
</tr>
<tr>
<td>CI/CD</td>
<td>Pipeline generation, test diagnosis, release-risk analysis</td>
</tr>
<tr>
<td>Operations</td>
<td>Alert correlation, incident summaries, root-cause assistance</td>
</tr>
<tr>
<td>Support</td>
<td>Ticket triage and RAG assistants</td>
</tr>
<tr>
<td>Product analytics</td>
<td>Behaviour analysis and anomaly discovery</td>
</tr>
<tr>
<td>Governance</td>
<td>Policy checks, model governance and audit assistance</td>
</tr>
</tbody>
</table>
<p>And then AI itself introduces another operating concern:</p>
<ul>
<li>model selection;</li>
<li>prompt management;</li>
<li>model gateways;</li>
<li>vector stores;</li>
<li>evaluations;</li>
<li>guardrails;</li>
<li>token usage;</li>
<li>inference costs;</li>
<li>model monitoring;</li>
<li>model versioning;</li>
<li>agent permissions;</li>
<li>human approval gates.</li>
</ul>
<p>So AI does not simplify the architecture automatically. In many cases it adds another cross-cutting architectural dimension.</p>
<h2 id="agentic-systems-make-boundaries-more-important" tabindex="-1"><a class="header-anchor" href="#agentic-systems-make-boundaries-more-important"><span>Agentic systems make boundaries more important</span></a></h2>
<p>A conventional AI assistant may produce text.</p>
<p>An agent can potentially <strong>do something</strong>.</p>
<p>That changes the risk profile dramatically.</p>
<p>An agent involved in deployment, for example, may need:</p>
<ul>
<li>credentials;</li>
<li>API access;</li>
<li>infrastructure visibility;</li>
<li>permission to modify a system;</li>
<li>monitoring feedback;</li>
<li>rollback ability.</li>
</ul>
<p>A useful agentic deployment model therefore looks less like:</p>
<pre class="code-block"><code class="hljs language-text">AI → Production
</code></pre>
<p>and more like:</p>
<pre class="code-block"><code class="hljs language-text">AI recommendation
    ↓
Policy evaluation
    ↓
Approval gate
    ↓
Restricted action
    ↓
Verification
    ↓
Audit trail
    ↓
Rollback if required
</code></pre>
<p>The smarter the automation becomes, the more important identity, observability, permissions and governance become around it.</p>
<h2 id="the-real-complexity-is-interaction" tabindex="-1"><a class="header-anchor" href="#the-real-complexity-is-interaction"><span>The real complexity is interaction</span></a></h2>
<p>A diagram with this many boxes can make the problem appear to be the <strong>number of technologies</strong>.</p>
<p>That is not quite the hardest part.</p>
<p>The harder problem is the number of interactions between them.</p>
<p>Consider something as ordinary as signing up a new customer.</p>
<p>A single workflow might involve:</p>
<ol>
<li>Cloudflare accepting the request.</li>
<li>A WAF allowing it.</li>
<li>The API gateway routing it.</li>
<li>Request validation accepting the payload.</li>
<li>The application creating the user.</li>
<li>The database storing the account.</li>
<li>A tenant being provisioned.</li>
<li>An identity token being created.</li>
<li>A background job being queued.</li>
<li>Resend or SES sending verification mail.</li>
<li>PostHog recording the signup event.</li>
<li>Sentry recording an error if anything fails.</li>
<li>Prometheus incrementing a metric.</li>
<li>Grafana displaying that metric.</li>
<li>Slack receiving an operational alert if failure rates rise.</li>
<li>A support platform receiving a ticket if the customer cannot proceed.</li>
</ol>
<p>Nothing about that workflow is individually extraordinary.</p>
<p>The complexity emerges from having to make <strong>all of it behave as one system</strong>.</p>
<h2 id="vendor-sprawl-creates-its-own-architecture" tabindex="-1"><a class="header-anchor" href="#vendor-sprawl-creates-its-own-architecture"><span>Vendor sprawl creates its own architecture</span></a></h2>
<p>There is also a point where every solved problem creates another external dependency.</p>
<p>A team can easily end up with:</p>
<ul>
<li>Cloudflare;</li>
<li>AWS;</li>
<li>GitHub;</li>
<li>Sentry;</li>
<li>Grafana;</li>
<li>Prometheus;</li>
<li>PostHog;</li>
<li>Hotjar;</li>
<li>Resend;</li>
<li>Stripe;</li>
<li>Slack;</li>
<li>PagerDuty;</li>
<li>Zendesk;</li>
<li>Auth0;</li>
<li>OpenAI.</li>
</ul>
<p>Each service may be an excellent choice.</p>
<p>Collectively they create questions around:</p>
<ul>
<li>authentication;</li>
<li>secrets;</li>
<li>billing;</li>
<li>data residency;</li>
<li>access control;</li>
<li>vendor outages;</li>
<li>API limits;</li>
<li>ownership;</li>
<li>auditability;</li>
<li>configuration;</li>
<li>offboarding;</li>
<li>cost.</li>
</ul>
<p>That is why adding a SaaS service is still an architectural decision even when it takes five minutes to install its SDK.</p>
<h2 id="mature-architecture-is-not-the-architecture-with-the-most-boxes" tabindex="-1"><a class="header-anchor" href="#mature-architecture-is-not-the-architecture-with-the-most-boxes"><span>Mature architecture is not the architecture with the most boxes</span></a></h2>
<p>The reference diagram should not be interpreted as a checklist where every empty box is a failure.</p>
<p>A small application may have absolutely no need for Kafka, Kubernetes, SCIM, a vector database or a multi-region failover architecture.</p>
<p>Adding them “because enterprise systems have them” can make a platform worse.</p>
<p>Architecture should instead answer:</p>
<blockquote>
<p>What capability does this system actually require, and what is the simplest reliable way of providing it?</p>
</blockquote>
<p>Sometimes the correct answer is Kafka.</p>
<p>Sometimes it is RabbitMQ.</p>
<p>Sometimes it is a Redis-backed queue.</p>
<p>Sometimes it is simply: <strong>we do not need asynchronous messaging yet.</strong></p>
<p>The same applies across the diagram.</p>
<h2 id="the-modern-architect%E2%80%99s-job-is-increasingly-one-of-composition" tabindex="-1"><a class="header-anchor" href="#the-modern-architect%E2%80%99s-job-is-increasingly-one-of-composition"><span>The modern architect’s job is increasingly one of composition</span></a></h2>
<p>There was a time when a large part of software architecture meant deciding how application code itself should be structured.</p>
<p>That remains important, but a modern SaaS architect must increasingly reason about a wider system:</p>
<ul>
<li>which capabilities belong inside the application;</li>
<li>which belong at the edge;</li>
<li>which should be managed cloud services;</li>
<li>which should be external SaaS providers;</li>
<li>which should be self-hosted;</li>
<li>how identity propagates between them;</li>
<li>how failures propagate between them;</li>
<li>where data is allowed to travel;</li>
<li>how the whole system is observed;</li>
<li>how it is deployed;</li>
<li>how it is supported;</li>
<li>where AI can safely participate.</li>
</ul>
<p>The architecture is no longer merely the codebase.</p>
<p>It is the <strong>ecosystem of technologies, services, policies, people and operational processes required to keep that code useful in production</strong>.</p>
<p>And that is perhaps the most striking thing about looking at the complete diagram: the little box marked <strong>Application Layer</strong> is important, but it is surrounded by an enormous amount of machinery whose job is to make that application dependable enough for somebody else to trust.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A blog that is only a folder of Markdown files</title>
      <link>https://ikoonman.io/blog/hello-ikoonman/</link>
      <guid isPermaLink="true">https://ikoonman.io/blog/hello-ikoonman/</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
      <description>The whole publishing model is one folder, a few templates, and one build script.</description>
      <category>Architecture</category>
      <category>Systems</category>
      <content:encoded><![CDATA[<h1>A blog that is only a folder of Markdown files</h1>
<p>There is no database here, no CMS, and no admin interface. Publishing works like
this:</p>
<ol>
<li>Add a Markdown file to <code>blog/</code>.</li>
<li>Run <code>npm run build</code>.</li>
<li>Serve <code>dist/</code>.</li>
</ol>
<p>The filename becomes the URL. <code>blog/hello-ikoonman.md</code> is published at
<code>/blog/hello-ikoonman/</code>, unless the frontmatter overrides the slug.</p>
<h2 id="why-keep-it-this-small" tabindex="-1"><a class="header-anchor" href="#why-keep-it-this-small"><span>Why keep it this small</span></a></h2>
<p>Every feature added to a publishing tool is a feature that has to keep working
for as long as the writing does. Markdown files outlive engines, so the engine
should be replaceable in an afternoon.</p>
<blockquote>
<p>The blog exists to publish ideas, not to become another software platform.</p>
</blockquote>
<h2 id="what-the-build-does" tabindex="-1"><a class="header-anchor" href="#what-the-build-does"><span>What the build does</span></a></h2>
<pre class="mermaid">
flowchart TD
    Scan[Scan blog/] --&gt; Parse[Parse frontmatter]
    Parse --&gt; Skip[Skip drafts]
    Skip --&gt; Render[Markdown to HTML]
    Render --&gt; Pages[Write post pages]
    Pages --&gt; Index[Build index and feed]
    Index --&gt; Assets[Copy public assets]
</pre>
<p>Images live in <code>public/</code> and are referenced from a post with a normal absolute
path, so nothing about them is special either.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
