title: "Prompt management" description: "Versioned prompt templates with typed variables, a diff between versions and a playground that runs them." nodes: 17 progress: 88% generated: ohm spec editor#
Prompt management#
Versioned prompt templates with typed variables, a diff between versions and a playground that runs them.
Contents#
☑ Overview#
☑ Scope#
note
packages/core/src/prompts.ts, apps/ui/src/views/PromptView.tsx, and the
prompts/*.prompt.json documents inside a project.
☑ Prompts are content-addressed documents, not strings in code#
decision
A prompt is a project item with a history. A new version appears only when the content hash changes, so editing a description or a tag does not manufacture a version, and an experiment can pin a version number and keep meaning the same thing.
☑ Data model#
☑ PromptDoc#
data-model
prompts/<name>.prompt.json:
PromptDoc { name, description?, tags?, status: 'draft'|'active'|'archived',
versions: PromptVersion[] }
PromptVersion { version, hash, system, template, variables, model, note?, at }
PromptVariable { name, type, required?, default?, options?, description? }
PromptModelParams { profile?, temperature?, maxTokens? }
PromptVarType = 'string' | 'int' | 'float' | 'bool' | 'enum' | 'list' | 'json'.
The hash is the first 16 hex characters of
sha256(JSON.stringify([system, template, variables, model])).
☑ Template syntax and extraction#
requirement
Placeholders are {{name}}, tolerating inner whitespace:
/\{\{\s*([A-Za-z_][\w]*)\s*\}\}/g.
extractVariables scans system first, then template, returning unique names in
first-appearance order. That order is not cosmetic: it decides the order in which
undeclared variables are auto-appended on save.
- node:
prompt-doc
☑ Typed coercion#
requirement
| Type | Rule |
|---|---|
int |
Number then round; non-finite → "<name>: expected an integer" |
float |
Number; non-finite throws |
bool |
true only for true, 'true', 1, '1' |
enum |
string-compared against options; otherwise "…is not one of a, b" |
list |
an array as-is; a string starting with [ is JSON-parsed; otherwise comma-split and trimmed with empties dropped |
json |
JSON.parse |
string |
passthrough |
For substitution, values are stringified: null/undefined → '', arrays joined with
', ', objects JSON.stringifyd.
☑ Behaviour#
☑ Save#
flow
- Load the doc, or start a fresh draft.
- Auto-declare any template variable the draft omitted, appended after the
declared ones as
{ type: 'string', required: true }. - Hash
{system, template, variables, model}; push a new version only if it differs from the latest. - Update description/tags/status regardless, without creating a version.
- node:
versioning
☑ Render treats empty exactly like missing#
requirement
For each name found in system+template: the caller's value wins; undefined or
an empty string falls back to the declared default; still empty and not explicitly
required: false → reported in missing.
Substitution replaces only names that have values, so an unknown placeholder stays literally visible in the output rather than silently blanking — which is what makes a typo obvious in the playground.
run refuses to call a model while anything is missing:
Prompt "x" is missing variables: ….
☑ Diff#
api
diffLines is a full LCS dynamic program over lines, emitting ' ', '-' and
'+' rows, preferring deletions on ties. The UI diffs
system + "\n---\n" + template of the selected version against the current draft.
☑ The view#
component
Editor for system/template/variables, a variable table (name, type, required, default, options, description) that flags rows no longer used by the template, a bulk "add … from the template" action, model profile/temperature/maxTokens, and a playground that renders widgets by type (enum → select, bool → select, otherwise a textarea), drops empty strings before sending, and has Render and Run buttons.
History lists versions, compares any of them with the draft, and restores one into the draft.
☑ Script API#
api
prompts.list(), prompts.render(name, vars, version),
prompts.run(name, vars, opts) — the script form of run returns just the text.
- spec:
02-ohm-language
☑ Review findings#
☐ Hashing is key-order sensitive#
risk
hashPrompt hashes JSON.stringify of objects, so reordering keys inside model
or reordering the variables array produces a spurious new version. Sort keys
before hashing.
- node:
versioning
☐ diffLines is duplicated#
risk
The same algorithm exists in packages/core/src/prompts.ts and in
apps/ui/src/views/shared.tsx, kept in sync by a comment. It is also full
O(n·m) memory, so diffing two very large prompts allocates an (n+1)×(m+1) matrix.
Export one implementation and import it in the UI.
☑ An undeclared placeholder is always "missing"#
note
Because there is no declaration, the required !== false test cannot be satisfied,
so an undeclared {{typo}} always blocks a run until it is declared or removed.
That is arguably the right behaviour; it is worth stating so nobody "fixes" it.
- node:
render
Links between nodes#
graph LR
template_syntax_and_extraction["Template syntax and extraction"] -.-> prompt_doc["PromptDoc"]
save["Save"] -.-> versioning["Prompts are content-addressed documents, not strings in code"]
hashing_is_key_order_sensitive["Hashing is key-order sensitive"] -.-> versioning["Prompts are content-addressed documents, not strings in code"]
an_undeclared_placeholder_is_always_missing["An undeclared placeholder is always "missing""] -.-> render["Render treats empty exactly like missing"]