<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ahsan Ahmad</title>
    <description>The latest articles on DEV Community by Ahsan Ahmad (@ahsanahmad).</description>
    <link>https://dev.to/ahsanahmad</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1913043%2F78037f56-9461-4d0f-b5e7-6678d696891f.jpg</url>
      <title>DEV Community: Ahsan Ahmad</title>
      <link>https://dev.to/ahsanahmad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahsanahmad"/>
    <language>en</language>
    <item>
      <title>Grounded AI: making an LLM draft a project charter from real data — without hallucinating</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:47:06 +0000</pubDate>
      <link>https://dev.to/vanillapm/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating-4mgl</link>
      <guid>https://dev.to/vanillapm/grounded-ai-making-an-llm-draft-a-project-charter-from-real-data-without-hallucinating-4mgl</guid>
      <description>&lt;p&gt;"Add AI" is the easiest line item on any 2026 roadmap and the easiest way to ship something worse than nothing. Wire an LLM to a "Generate charter" button, feed it the project name, and it will happily produce a beautifully-worded project charter full of objectives, success criteria and stakeholders that &lt;strong&gt;do not exist&lt;/strong&gt;. In a governed project-management tool, a confident invention is worse than a blank field — someone might &lt;em&gt;believe&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;I wanted the generate button anyway, because there's a real version of it. Here's how I built a charter generator that drafts genuinely useful prose from the user's &lt;em&gt;actual&lt;/em&gt; data, and refuses to make things up — in Django, bring-your-own-key, no framework magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things this is not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Not autopopulate.&lt;/strong&gt; I already had a deterministic autopopulate: copy the sponsor from the project record, the dates from the schedule, and so on. That's great for &lt;em&gt;fields&lt;/em&gt;, but a charter is mostly &lt;em&gt;prose&lt;/em&gt; — a scope statement, an objectives section, a rationale — synthesised from the pre-project inputs (the business case, the benefits plan, the agreements). You can't &lt;code&gt;str()&lt;/code&gt; your way to a scope statement. Autopopulate structurally can't do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not a free-form LLM.&lt;/strong&gt; The other extreme — "here's the project name, write me a charter" — is exactly the hallucination machine. The model has no idea what your business case says, so it invents a plausible one.&lt;/p&gt;

&lt;p&gt;The useful thing lives in between: &lt;strong&gt;grounded synthesis.&lt;/strong&gt; Read the real input documents, and ask the model to &lt;em&gt;compose&lt;/em&gt; — using only what's actually there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: ground in the real inputs, not the model's imagination
&lt;/h2&gt;

&lt;p&gt;The whole trick is that the model's only source of truth is text &lt;em&gt;you&lt;/em&gt; supply from the user's own documents — never its training data.&lt;/p&gt;

&lt;p&gt;In VanillaPM every document type declares its &lt;strong&gt;input documents&lt;/strong&gt; (its ITTO prerequisites — the things that must exist before it). A charter's inputs are the business case, the benefits management plan, the agreements. So "grounding" is concrete: go read &lt;em&gt;those&lt;/em&gt; documents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DOC_CHAR_CAP&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8_000&lt;/span&gt;    &lt;span class="c1"&gt;# per input document
&lt;/span&gt;&lt;span class="n"&gt;TOTAL_CONTEXT_CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt;  &lt;span class="c1"&gt;# the whole grounding context
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;input_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(type, title, text) for each declared prerequisite that exists and has
    readable text — each capped so one long doc can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t dominate the context.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prereq&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;prerequisite_types&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# from the dependency graph
&lt;/span&gt;        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;latest_of_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prereq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;document_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# walk the stored rich-text → plain text
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;prereq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;DOC_CHAR_CAP&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two boring-but-important details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap per document &lt;em&gt;and&lt;/em&gt; in total.&lt;/strong&gt; One 200-page attachment shouldn't crowd out the other inputs (or blow your context window / bill). A per-doc cap plus a total cap keeps the grounding balanced and bounded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ground in &lt;em&gt;authored&lt;/em&gt; text, not the whole DB.&lt;/strong&gt; &lt;code&gt;document_text()&lt;/code&gt; walks the document's stored rich-text tree and pulls the prose the human actually wrote — not a JSON dump of every model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The grounding context is then just: the deterministic structured facts (reused from autopopulate) &lt;strong&gt;+&lt;/strong&gt; these input-document excerpts, under a big honest header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the system prompt does the anti-hallucination work
&lt;/h2&gt;

&lt;p&gt;This is the part people skip. The model will fabricate &lt;em&gt;unless you make refusing the easier path.&lt;/em&gt; Three rules, and the second one is the whole ballgame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You draft sections of a PMBOK-aligned project document.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. Ground every section ONLY in the provided project data and input &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   documents below. They are your only source of truth.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2. If the data a section needs is missing, write ONE short professional &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   sentence stating what input is still needed — do NOT fabricate.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. Return JSON only: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;section_key&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;prose&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}]}. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use only the section keys given. No markdown headings, no preamble.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule 2 is the difference between a demo and a tool. Giving the model an &lt;strong&gt;acceptable way to say "I don't have this"&lt;/strong&gt; — a short "needs input: the benefits plan doesn't state a target ROI" placeholder — means it takes that exit instead of inventing a number. You're not just &lt;em&gt;asking&lt;/em&gt; it not to hallucinate; you're handing it a better move than hallucinating.&lt;/p&gt;

&lt;p&gt;The user turn wires the sections to draft onto the grounding context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;USER_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Draft these sections of the project&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s {doc_label}. Use each key exactly:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{targets}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=== PROJECT DATA AND INPUT DOCUMENTS (your only source of truth) ===&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{context}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Structured JSON out, fixed keys in.&lt;/strong&gt; The model returns &lt;code&gt;{"sections":[{"key", "body"}]}&lt;/code&gt; and may only use keys I gave it. That does two jobs: parsing is trivial and deterministic, and the model can't wander off and invent a "Section 12: Executive Bonus Plan." It fills the blanks I asked for, or it says it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: only touch the blanks, never the real data
&lt;/h2&gt;

&lt;p&gt;A charter is a mix of &lt;em&gt;authored&lt;/em&gt; prose sections and &lt;em&gt;live-data&lt;/em&gt; sections (a stakeholder table, a risk snapshot) that are generated from real records. The AI must never touch the latter — those aren't opinions to draft, they're facts to render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;target_sections&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content_json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overwrite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content_json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;section_has_datablock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# a live register/table snapshot
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;                           &lt;span class="c1"&gt;# generated from real data — hands off
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;section_has_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;overwrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;                           &lt;span class="c1"&gt;# non-destructive: only fill empty sections
&lt;/span&gt;        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the fill is &lt;strong&gt;non-destructive&lt;/strong&gt; (it never overwrites what a human wrote unless they ask) and it &lt;strong&gt;skips live-data sections entirely&lt;/strong&gt;. The model works on empty &lt;em&gt;prose&lt;/em&gt; sections and nothing else. Bonus: because the section keys/titles come from the document itself, the exact same code drafts a communications plan or a risk-management plan — it generalised for free.&lt;/p&gt;

&lt;p&gt;And the output lands in &lt;strong&gt;draft&lt;/strong&gt;, for a human to review and approve. The AI proposes; the PM decides. It's never a fact until a person signs off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: bring-your-own-key, and fail safe
&lt;/h2&gt;

&lt;p&gt;No forced AI markup, and no crash when AI isn't configured. The path resolves BYOK-first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_ai_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;byok_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                     &lt;span class="c1"&gt;# user's own key
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;byok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;user_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;MODEL_BYOK&lt;/span&gt;     &lt;span class="c1"&gt;#   → free through us
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;managed_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                  &lt;span class="c1"&gt;# metered credits
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;managed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;server_key&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;MODEL_MANAGED&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                           &lt;span class="c1"&gt;# feature cleanly disabled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the SDK import is &lt;strong&gt;guarded&lt;/strong&gt; — a server without the &lt;code&gt;anthropic&lt;/code&gt; package (or a user without a key) degrades to a disabled button, never an exception:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
    &lt;span class="n"&gt;_AVAILABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_AVAILABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the module holds a key; the caller passes the user's decrypted key in per request. (Load tests swap in an offline stub so they never hit the real API — cost and rate limits stay off the test path.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What grounding buys you — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Grounding isn't a hallucination &lt;em&gt;cure&lt;/em&gt;; it's a hallucination &lt;em&gt;budget&lt;/em&gt;. What it reliably buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model composes from &lt;strong&gt;your&lt;/strong&gt; business case, so the objectives are &lt;em&gt;your&lt;/em&gt; objectives.&lt;/li&gt;
&lt;li&gt;Missing inputs surface as honest "needs input" notes — which doubles as a checklist of what to go write.&lt;/li&gt;
&lt;li&gt;Structured, key-scoped output means no invented sections and trivial parsing.&lt;/li&gt;
&lt;li&gt;Non-destructive, draft-only, human-approved: the AI can't silently rewrite a governed document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it doesn't do: it won't fix a vague business case (garbage in, grounded-garbage out), and a determined model can still mis-synthesise. So it's a &lt;em&gt;draft&lt;/em&gt; generator with a human gate, not an autopilot — which is exactly what a governed document deserves.&lt;/p&gt;

&lt;p&gt;The pattern generalises well beyond charters: &lt;strong&gt;read the real inputs, cap and label them as the only source of truth, give the model an honest "I don't know," constrain the output shape, and never let it touch data it should only render.&lt;/strong&gt; That's most of the distance between "we added AI" and AI you can actually put near real work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building a free, full-lifecycle PM platform in the open — this generator ships in it (BYOK). If the build-in-public engineering is your thing, the rest of the series is here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>django</category>
      <category>llm</category>
    </item>
    <item>
      <title>Keeping Alpine.js + Django templates sane in a large app</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Mon, 07 Sep 2026 10:33:22 +0000</pubDate>
      <link>https://dev.to/vanillapm/keeping-alpinejs-django-templates-sane-in-a-large-app-3424</link>
      <guid>https://dev.to/vanillapm/keeping-alpinejs-django-templates-sane-in-a-large-app-3424</guid>
      <description>&lt;p&gt;I built a big app the "boring" way: &lt;strong&gt;server-rendered Django templates + Alpine.js for interactivity, no SPA.&lt;/strong&gt; ~50 Django apps, a lot of screens, one developer. Alpine is the perfect amount of JavaScript for this — until the app gets large and you start hitting its sharp edges.&lt;/p&gt;

&lt;p&gt;Here are the patterns that kept it maintainable, and the gotchas that cost me real hours. All of it is stuff I wish someone had told me at line 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: islands, not an app
&lt;/h2&gt;

&lt;p&gt;The trap with Alpine in a big project is treating it like a mini-SPA — global stores everywhere, components reaching into each other. Don't. Treat each interactive region as an &lt;strong&gt;island&lt;/strong&gt;: a self-contained Alpine component hydrating one chunk of server-rendered HTML. The server owns the data and the page; Alpine owns &lt;em&gt;this widget's&lt;/em&gt; behaviour.&lt;/p&gt;

&lt;p&gt;Two rules fall out of that, and they're the difference between "fine at 10 screens" and "fine at 200."&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: register components — stop writing logic inline
&lt;/h2&gt;

&lt;p&gt;Inline &lt;code&gt;x-data="{ ... }"&lt;/code&gt; is lovely for a toggle. For anything real, it's a liability — and it has a &lt;em&gt;nasty&lt;/em&gt; failure mode.&lt;/p&gt;

&lt;p&gt;Put a literal double-quote inside an inline expression and you silently close the HTML attribute early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ Alpine sees x-data="{ label: "  — the rest becomes broken markup --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ label: "&lt;/span&gt;&lt;span class="na"&gt;Save&lt;/span&gt;&lt;span class="err"&gt;",&lt;/span&gt; &lt;span class="na"&gt;open:&lt;/span&gt; &lt;span class="na"&gt;false&lt;/span&gt; &lt;span class="err"&gt;}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component doesn't throw. It just… doesn't initialize, and depending on where the parser gives up, your expression can &lt;strong&gt;dump into the page as visible text&lt;/strong&gt;. Worse, it often &lt;em&gt;renders fine in your quick manual check&lt;/em&gt; but breaks somewhere else — and it's invisible to a lot of smoke tests because it's an HTML-parse issue, not a JS error.&lt;/p&gt;

&lt;p&gt;The fix isn't "remember to use single quotes." It's: &lt;strong&gt;anything beyond a line or two becomes a registered component.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// static/js/components.js&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alpine:init&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;projectBoard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// real quotes, real editor, real linting&lt;/span&gt;
    &lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"projectBoard"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; … &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the logic lives in a &lt;code&gt;.js&lt;/code&gt; file your editor and linter understand, the template stays declarative, and the quoting footgun is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: never put a "rich" JS instance on reactive state
&lt;/h2&gt;

&lt;p&gt;This one cost me the most time, so I'll be specific. Alpine makes your state reactive by wrapping it in a &lt;strong&gt;deep Proxy&lt;/strong&gt; (via &lt;code&gt;@vue/reactivity&lt;/code&gt;). That's great for plain data. It's &lt;em&gt;poison&lt;/em&gt; for objects that do their own identity bookkeeping internally — a &lt;strong&gt;TipTap/ProseMirror editor&lt;/strong&gt;, a map instance, a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; controller, a WebSocket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ ProseMirror starts throwing "Applying a mismatched transaction"&lt;/span&gt;
&lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// now a Proxy&lt;/span&gt;
&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The editor stores references to its own nodes/state and compares them by identity. Once Alpine has proxied it, &lt;code&gt;this.editor&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; the object the editor thinks it is, and its internal &lt;code&gt;===&lt;/code&gt; checks fail in baffling ways.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;keep non-plain instances off reactive state entirely.&lt;/strong&gt; A closure is cleanest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ the editor lives in closure scope — never proxied&lt;/span&gt;
&lt;span class="nx"&gt;Alpine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Editor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toggleBold&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it &lt;em&gt;must&lt;/em&gt; live on &lt;code&gt;this&lt;/code&gt;, mark it so the reactivity engine skips it (&lt;code&gt;obj.__v_skip = true&lt;/code&gt; before assigning, i.e. &lt;code&gt;markRaw&lt;/code&gt;). But closure scope is simpler and I reach for it every time. Rule of thumb: &lt;strong&gt;only plain, serializable data goes on &lt;code&gt;x-data&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: &lt;code&gt;x-data&lt;/code&gt; initializers &lt;em&gt;snapshot&lt;/em&gt; — use &lt;code&gt;init&lt;/code&gt;/&lt;code&gt;x-effect&lt;/code&gt; for async
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;x-data&lt;/code&gt; runs once, eagerly, and takes whatever the expression evaluates to &lt;em&gt;right then&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ `stats` is the Promise fetchStats() returned, not the resolved data --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ stats: fetchStats() }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see &lt;code&gt;[object Promise]&lt;/code&gt; or stale/empty state and chase it for an hour. Load &lt;em&gt;then&lt;/em&gt; assign:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ stats: null, async init() { this.stats = await fetchStats() } }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;template&lt;/span&gt; &lt;span class="na"&gt;x-if=&lt;/span&gt;&lt;span class="s"&gt;"stats"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape bit me with theme switching: reading a value once at init captures it forever. If something needs to &lt;em&gt;react&lt;/em&gt; to a change (a store value, a media query, a fetched result), it belongs in &lt;code&gt;x-effect&lt;/code&gt; or an explicit assignment — not in the initializer expression.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: pass server data &lt;em&gt;as data&lt;/em&gt;, not string interpolation
&lt;/h2&gt;

&lt;p&gt;The tempting thing is to jam Django context straight into an Alpine expression: &lt;code&gt;x-data="{ items: {{ items }} }"&lt;/code&gt;. It works until a value contains a quote or a newline, and then you're back in Rule 1's parser hell. Use Django's &lt;code&gt;json_script&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jinja"&gt;&lt;code&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;items&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nf"&gt;json_script&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"board-data"&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;x-data=&lt;/span&gt;&lt;span class="s"&gt;"{ items: JSON.parse(document.getElementById('board-data').textContent) }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server renders JSON safely into a &lt;code&gt;&amp;lt;script type="application/json"&amp;gt;&lt;/code&gt;; Alpine reads it. Clean separation: &lt;strong&gt;Django owns the data, the template just carries it, Alpine consumes it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The small Django-template traps
&lt;/h2&gt;

&lt;p&gt;Two that bit me more than once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;{# … #}&lt;/code&gt; is single-line only.&lt;/strong&gt; A comment that wraps across lines renders as &lt;em&gt;literal visible text&lt;/em&gt; — and if it's sitting next to an Alpine attribute, chaos. Use &lt;code&gt;{% comment %} … {% endcomment %}&lt;/code&gt; for anything multi-line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared islands, single-sourced.&lt;/strong&gt; The nav bar, the sidebar, the top bar — build each as &lt;em&gt;one&lt;/em&gt; island partial you include everywhere, not a per-page fork. The day I stopped copy-pasting the nav component was the day the footer stopped mysteriously drifting between pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What "sane" ended up meaning
&lt;/h2&gt;

&lt;p&gt;Nothing exotic — just discipline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Islands, not a SPA.&lt;/strong&gt; Server owns data + page; Alpine owns a widget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register non-trivial components&lt;/strong&gt; in a JS file; inline only for toggles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain data only on &lt;code&gt;x-data&lt;/code&gt;&lt;/strong&gt; — rich instances live in closures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async loads via &lt;code&gt;init&lt;/code&gt;/&lt;code&gt;x-effect&lt;/code&gt;&lt;/strong&gt;, never the initializer expression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server → client via &lt;code&gt;json_script&lt;/code&gt;&lt;/strong&gt;, never string interpolation.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;browser smoke run&lt;/strong&gt; as a release gate, because the worst Alpine bugs are HTML-parse issues that pass unit tests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alpine scaled to a genuinely large app for me — but only once I stopped treating it like React-lite and started treating it like sprinkles on server-rendered HTML.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these while building a free, full-lifecycle PM platform in the open — if the build-in-public stuff is your thing, the rest of the series is here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>alpinejs</category>
    </item>
    <item>
      <title>Why I made my project-management SaaS 100% free (and how I plan to survive)</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:04:34 +0000</pubDate>
      <link>https://dev.to/vanillapm/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive-4ge1</link>
      <guid>https://dev.to/vanillapm/why-i-made-my-project-management-saas-100-free-and-how-i-plan-to-survive-4ge1</guid>
      <description>&lt;p&gt;In my last post, I &lt;a href="https://dev.to/vanillapm"&gt;wrote up what it cost to build VanillaPM&lt;/a&gt; — a full PMBOK project-management platform — solo in three months. The reaction was kind. But almost every reply circled the same question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's genuinely free? &lt;em&gt;How are you going to survive?&lt;/em&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair question. Here's the honest answer — including the part where I admit I don't have it all figured out.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what "free" actually means here
&lt;/h2&gt;

&lt;p&gt;Not a free trial. Not free-for-3-users-then-a-wall. The &lt;strong&gt;entire core is free&lt;/strong&gt; — the full PMBOK 8th-edition lifecycle, charter to closeout, WBS, Gantt, Earned Value, risk registers, a knowledge base, sprints. No seat limits. No paywall on the thing itself. New accounts even open a fully-built sample project so you can use everything on real data before you create anything.&lt;/p&gt;

&lt;p&gt;I did that on purpose, and it wasn't a growth hack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why free — the real reason
&lt;/h2&gt;

&lt;p&gt;The tools that genuinely &lt;em&gt;teach&lt;/em&gt; you project management are locked behind enterprise pricing. The ones you can actually afford don't teach you anything — they're glorified to-do lists. So the people who most need to &lt;em&gt;learn&lt;/em&gt; how to run a project — a student, a freelancer, a first-time founder, a small NGO running on grants — are the exact people priced out of the tools that would teach them.&lt;/p&gt;

&lt;p&gt;I wanted to close that gap. Free removes the friction for precisely the people who need it most. That's the whole point, and a paywall on the core would defeat it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger bet
&lt;/h2&gt;

&lt;p&gt;Here's the part that reframes the "how will you survive" question: &lt;strong&gt;VanillaPM isn't "a free PM tool." It's the first module of a teaching business suite.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Project management is the front door because it's where most people first hit the wall. But finance, HR, payroll and org design are already being built on the same spine — the idea being that a small org could eventually run its &lt;em&gt;whole&lt;/em&gt; operation, and learn how to, in one place. Free PM is distribution and trust. The moat is the teaching and the breadth that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  So… how do I plan to survive?
&lt;/h2&gt;

&lt;p&gt;Honestly? I have &lt;em&gt;directions&lt;/em&gt;, not a locked plan — and I'd rather tell you that than pretend I have a tidy spreadsheet.&lt;/p&gt;

&lt;p&gt;What I'm &lt;strong&gt;considering&lt;/strong&gt; (none decided):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed AI, not manual AI.&lt;/strong&gt; Bring-your-own-key AI is free. But hosting the AI — no key, no setup, it just works — is a real cost I could reasonably charge for. Convenience, not capability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth for organisations.&lt;/strong&gt; The ERM finance-to-payroll suite, org-scoped access control at scale, multi-entity separation — the stuff a &lt;em&gt;company&lt;/em&gt; needs, not a student. That's where willingness-to-pay actually lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted / support / SLA tiers&lt;/strong&gt; for teams who want someone on the hook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The teaching layer&lt;/strong&gt; — deeper courses, certifications-prep, guided tracks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I will &lt;strong&gt;never&lt;/strong&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paywall the core PM lifecycle. It stays free.&lt;/li&gt;
&lt;li&gt;Seat-limit the free tier into uselessness.&lt;/li&gt;
&lt;li&gt;Sell your data. Ever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle I keep coming back to: &lt;strong&gt;money should come from depth and convenience, not from holding your project hostage.&lt;/strong&gt; The free core is a promise, not a funnel with a trapdoor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest risk
&lt;/h2&gt;

&lt;p&gt;This might not work. "Free core, charge for depth" is a real model (it's how a lot of good software gets distributed), but plenty of founders have gone broke being generous. I know that.&lt;/p&gt;

&lt;p&gt;I'm building in public partly &lt;em&gt;because&lt;/em&gt; I don't have it fully solved — the feedback is the point. Free-first is a deliberate bet: distribution and trust now, revenue later, from the people and orgs who get enough value that paying feels obvious rather than extracted.&lt;/p&gt;

&lt;p&gt;Ask me again in a year and I'll show you the numbers — the good and the bad. I've been sharing the flops too (my first ad: $3, some clicks, zero signups), and I'm not going to stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you've built or used a free-core product: &lt;strong&gt;how would &lt;em&gt;you&lt;/em&gt; monetise this without betraying the "free" promise?&lt;/strong&gt; Genuinely asking — the comments on posts like this have already changed my thinking more than any strategy deck.&lt;/p&gt;

&lt;p&gt;And if you just want to poke at a free, full-lifecycle PM platform (no signup wall — there's a sample project waiting): it's at &lt;strong&gt;&lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;vanillapm.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>saas</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I built a full PMBOK project-management platform solo in 3 months — here's what it cost</title>
      <dc:creator>Ahsan Ahmad</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:40:59 +0000</pubDate>
      <link>https://dev.to/vanillapm/i-built-a-full-pmbok-project-management-platform-solo-in-3-months-heres-what-it-cost-16bd</link>
      <guid>https://dev.to/vanillapm/i-built-a-full-pmbok-project-management-platform-solo-in-3-months-heres-what-it-cost-16bd</guid>
      <description>&lt;p&gt;Three months ago, VanillaPM was an empty Django project. Today it runs the entire &lt;strong&gt;PMBOK 8th-edition&lt;/strong&gt; project lifecycle — Initiating through Closing — with a work breakdown structure, critical-path scheduling, Earned Value, risk and assumption registers, a governed knowledge base, sprints, an ERM finance-to-payroll suite, and a bring-your-own-key AI assistant. It's &lt;strong&gt;live&lt;/strong&gt;, it has &lt;strong&gt;over 2,000 automated tests&lt;/strong&gt;, and it's completely &lt;strong&gt;free&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm a developer of ~13 years who spent the last few leading delivery, and I wanted to genuinely &lt;em&gt;learn&lt;/em&gt; formal project management — not from slides, but by building the tool I wished existed. This is the honest breakdown: what it cost in money, where the real leverage came from, and a few lessons that surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS — 12 months, prepaid&lt;/td&gt;
&lt;td&gt;$203.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business email — 48 months&lt;/td&gt;
&lt;td&gt;$228.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI credits (the build phase)&lt;/td&gt;
&lt;td&gt;$150.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One-time, to a shipped 1.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$582.84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recurring during the build&lt;/td&gt;
&lt;td&gt;~$46 / month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total to a shipped, tested, deployed 1.0: &lt;strong&gt;under $700.&lt;/strong&gt; No team, no seed round, no office — a $17/month VPS, a mailbox, and some AI credits.&lt;/p&gt;

&lt;p&gt;The line item that matters most is the smallest: that &lt;strong&gt;$150 of AI credits&lt;/strong&gt; is the one that changed what "solo" even means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lever: AI as a force multiplier, not an autocomplete
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't that AI wrote code. It's that it let &lt;em&gt;one person hold the whole system in their head at team throughput.&lt;/em&gt; Scaffolding a new Django app, writing the tests alongside the feature, doing the third tedious refactor of the day, rubber-ducking an architecture decision, and — genuinely — teaching me the PMBOK concepts &lt;em&gt;while I built the features that implement them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is not magic, and it doesn't replace judgment. You still architect. You still decide. You still verify everything, because the model is confidently wrong often enough that trusting it blindly would sink you. But the throughput multiplier is real, and the shape of "what a solo developer can ship" has changed.&lt;/p&gt;

&lt;p&gt;Some concrete numbers from the three months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;58 Django apps&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~84,000 lines of Python&lt;/strong&gt; (excluding migrations)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;320 migrations&lt;/strong&gt; of schema evolution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,000+ tests&lt;/strong&gt;, behind a CI gate that blocks any merge on a failing test or a coverage drop&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every task got its own branch, ran tests + lint + a &lt;code&gt;makemigrations --check&lt;/code&gt; on every change, and merged with &lt;code&gt;--no-ff&lt;/code&gt;. That discipline is the thing AI &lt;em&gt;didn't&lt;/em&gt; replace — it amplified it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four lessons that surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Tests are what make solo + AI safe.&lt;/strong&gt; Those 2,000 tests aren't a vanity metric — they're the safety net that lets you refactor fearlessly and trust an AI-assisted change you didn't hand-write line by line. Without them, the speed becomes a liability. With them, it compounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Building the PM tool taught me PM better than any course.&lt;/strong&gt; I ran the build of VanillaPM &lt;em&gt;as a VanillaPM project&lt;/em&gt; — a real charter, a WBS, sprints, formal change control on my own scope. Dogfooding turned abstract PMBOK concepts into muscle memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The boring infrastructure decisions compound.&lt;/strong&gt; Postgres end-to-end, object storage for files, a fail-closed search-indexing gate, automated backups, a health-checked deploy. None of it is exciting. All of it is why the thing stays up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Scope is the enemy, and formal change control on your &lt;em&gt;own&lt;/em&gt; project keeps you honest.&lt;/strong&gt; The number of "small" features I talked myself out of via my own change process is the reason 1.0 shipped at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's free
&lt;/h2&gt;

&lt;p&gt;The tools that actually teach you project management are locked behind enterprise pricing; the ones you can afford don't teach you anything. I wanted to close that gap — a place where a student, a freelancer, or a small NGO can run a &lt;em&gt;real&lt;/em&gt; project (and learn how) without a paywall. New accounts even drop straight into a fully-built sample project — a 220-room hotel build with a real WBS, schedule, budget and risk register — so you can explore everything on real data before creating anything of your own.&lt;/p&gt;

&lt;p&gt;There's also a free &lt;strong&gt;Learn PMP&lt;/strong&gt; course and an &lt;strong&gt;ITTO memory game&lt;/strong&gt; for exam prep, in seven languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to look
&lt;/h2&gt;

&lt;p&gt;It's at &lt;strong&gt;&lt;a href="https://vanillapm.com" rel="noopener noreferrer"&gt;vanillapm.com&lt;/a&gt;&lt;/strong&gt; — free, no trial, no seat limits. I'm building the rest in public and I'll keep sharing the numbers, including the unflattering ones (my first paid ad: $3, a handful of clicks, zero signups — but that's another post).&lt;/p&gt;

&lt;p&gt;If you're a developer curious about the solo-plus-AI workflow, or a PM tired of enterprise pricing, I'd genuinely love your feedback in the comments. What would you have built differently?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>django</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
