"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 do not exist. In a governed project-management tool, a confident invention is worse than a blank field — someone might believe it.
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 actual data, and refuses to make things up — in Django, bring-your-own-key, no framework magic.
Not autopopulate. 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 fields, but a charter is mostly prose — 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 str() your way to a scope statement. Autopopulate structurally can't do it.
Not a free-form LLM. 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.
The useful thing lives in between: grounded synthesis. Read the real input documents, and ask the model to compose — using only what's actually there.
The whole trick is that the model's only source of truth is text you supply from the user's own documents — never its training data.
In VanillaPM every document type declares its input documents (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 those documents.
DOC_CHAR_CAP = 8_000 # per input document
TOTAL_CONTEXT_CAP = 40_000 # the whole grounding context
def input_documents(project, doc_type):
"""(type, title, text) for each declared prerequisite that exists and has
readable text — each capped so one long doc can't dominate the context."""
out = []
for prereq in prerequisite_types(project, doc_type): # from the dependency graph
doc = latest_of_type(project, prereq)
text = document_text(doc) # walk the stored rich-text → plain text
if text:
out.append((prereq, doc.title, text[:DOC_CHAR_CAP]))
return out
Two boring-but-important details:
document_text() walks the document's stored rich-text tree and pulls the prose the human actually wrote — not a JSON dump of every model.
The grounding context is then just: the deterministic structured facts (reused from autopopulate) + these input-document excerpts, under a big honest header.
This is the part people skip. The model will fabricate unless you make refusing the easier path. Three rules, and the second one is the whole ballgame:
SYSTEM_PROMPT = (
"You draft sections of a PMBOK-aligned project document.\n"
"1. Ground every section ONLY in the provided project data and input "
" documents below. They are your only source of truth.\n"
"2. If the data a section needs is missing, write ONE short professional "
" sentence stating what input is still needed — do NOT fabricate.\n"
"3. Return JSON only: "
'{"sections":[{"key":"<section_key>","body":"<prose>"}]}. '
"Use only the section keys given. No markdown headings, no preamble."
)
Rule 2 is the difference between a demo and a tool. Giving the model an acceptable way to say "I don't have this" — 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 asking it not to hallucinate; you're handing it a better move than hallucinating.
The user turn wires the sections to draft onto the grounding context:
USER_PROMPT = (
"Draft these sections of the project's {doc_label}. Use each key exactly:\n\n"
"{targets}\n\n"
"=== PROJECT DATA AND INPUT DOCUMENTS (your only source of truth) ===\n"
"{context}"
)
Structured JSON out, fixed keys in. The model returns {"sections":[{"key", "body"}]} 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.
A charter is a mix of authored prose sections and live-data 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.
def target_sections(content_json, overwrite=False):
for node in content_json.get("content", []):
if section_has_datablock(node): # a live register/table snapshot
continue # generated from real data — hands off
if section_has_text(node) and not overwrite:
continue # non-destructive: only fill empty sections
yield node["attrs"]["key"], node["attrs"]["title"]
So the fill is non-destructive (it never overwrites what a human wrote unless they ask) and it skips live-data sections entirely. The model works on empty prose 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.
And the output lands in draft, for a human to review and approve. The AI proposes; the PM decides. It's never a fact until a person signs off.
No forced AI markup, and no crash when AI isn't configured. The path resolves BYOK-first:
def resolve_ai_path(user, org):
if byok_available(user, org): # user's own key
return "byok", user_key(user), MODEL_BYOK # → free through us
if managed_available(user, org): # metered credits
return "managed", server_key(), MODEL_MANAGED
return None, None, None # feature cleanly disabled
And the SDK import is guarded — a server without the anthropic package (or a user without a key) degrades to a disabled button, never an exception:
try:
import anthropic
_AVAILABLE = True
except ImportError:
anthropic, _AVAILABLE = None, False
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.)
Grounding isn't a hallucination cure; it's a hallucination budget. What it reliably buys:
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 draft generator with a human gate, not an autopilot — which is exactly what a governed document deserves.
The pattern generalises well beyond charters: 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. That's most of the distance between "we added AI" and AI you can actually put near real work.
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.