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

  1. Load the doc, or start a fresh draft.
  2. Auto-declare any template variable the draft omitted, appended after the declared ones as { type: 'string', required: true }.
  3. Hash {system, template, variables, model}; push a new version only if it differs from the latest.
  4. 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
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"]