Build structured LLM prompts wrapped in HTML tags.
Define tags with typed inputs, cross-reference them with autocomplete, and copy a clean rendered prompt in one click.
Note
Every line of code and documentation in this repository was generated by AI.
Hand-writing <role>...</role> and <context>...</context> blocks for Claude or ChatGPT prompts gets repetitive. promptly turns prompt scaffolding into a typed, reusable form: define tags once, fill them in, copy the result.
Modern LLMs — Claude in particular — parse XML-tagged prompts more reliably than free-form text. Wrapping each section in its own tag lets the model unambiguously separate role, data, instructions, and examples, and lets you reference one section from another without paraphrasing.
"XML tags help Claude parse complex prompts unambiguously, especially when your prompt mixes instructions, context, examples, and variable inputs. Wrapping each type of content in its own tag (e.g.
<instructions>,<context>,<input>) reduces misinterpretation." — Anthropic, Prompt engineering — Structure prompts with XML tags
The same idea shows up in general prompt-engineering advice: using clear delimiters between instruction and context measurably improves output, with XML tags being the most legible option for nested structure (Prompt Engineering Guide — General tips for designing prompts).
promptly bakes this practice into the editor: every field is a tag, every tag has a typed input, and the rendered output is well-formed XML you can copy or round-trip via Import / Export.
- Quickstart
- Cookbook (worked examples)
- Concepts
- Validation rules
- Output
- Library: prompts and templates
- XML import / export
- Storage and privacy
- Stack
- Layout
- Run locally
- Deploy
- License
- Open https://jathavaan.github.io/promptly/.
- Click + Tag in the Builder. Pick an input type, give it an ID (e.g.
context). - In any text field, type
<to open the autocomplete and reference another tag's ID — the rendered prompt expands<other-tag>literally where you typed it. - Open the Preview panel to see the rendered XML; click Copy to put it on the clipboard.
- Open the Library to save the current prompt or save it as a template (preserves field text for reuse).
For end-to-end examples that map Builder state to rendered output — few-shot prompts, structured-output schemas, references, templates, XML round-trip, A/B testing — see docs/EXAMPLES.md.
A tag is one named field that becomes one XML element in the output. Each tag has:
- An id (the XML element name, e.g.
context) that is globally unique across the whole prompt, including inside groups at any nesting depth — see Tag ID rules. - A type (
text,checkbox,list,example, orgroup). - A value matching the type.
- Optional flags:
pinned,disabled,static, plus free-formnotes.
| Type | Editor | Renders as |
|---|---|---|
text |
Multiline text area with < autocomplete for references |
<id>...text...</id> |
checkbox |
Toggle switch | <id>true</id> or <id>false</id> |
list |
Repeatable rows; pick a list style (see below) | <id> containing items in the chosen style |
example |
Repeatable { input, output } pairs |
<id><example><input>…</input><output>…</output></example>…</id> |
group |
Container; drag other tags inside | <id> containing nested tag elements |
List styles (listStyle):
| Style | Item rendered as |
|---|---|
unordered (default) |
- item |
ordered |
1. item |
checked |
[x] item or [ ] item |
xml |
<childName id="N">item</childName> — childName (default item) must be a valid XML name |
Inside any text, list item, or example field, type < to open the reference popper and pick another tag. Internally the reference is stored as {{ref:<uuid>}}; on render it expands to the literal <id> of the target tag (not its value). This lets you write prose that names another tag inline:
"Use the format described in
<schema>and the examples in<few-shot>."
If the referenced tag is disabled, the reference renders as empty. If it's deleted, the raw {{ref:uuid}} is left in place and the tag is flagged with a warning.
A group tag has no value of its own — it just nests other tags. Groups can nest arbitrarily deep. Drag-reordering inside the Builder lets you create a group by dropping one tag onto another. Cycles are prevented (you can't drop a tag into its own descendant).
- pinned — tag renders at the end of the prompt body, after non-pinned tags. Useful for closing instructions like a "respond in JSON" block.
- disabled — tag is excluded from the rendered prompt entirely. References to a disabled tag render empty. Disabling a
grouphides its entire subtree from the output (children are not promoted to the parent). - static — tag is hidden from the Builder list by default (toggle visibility with the lock icon in the Tags panel header, or in Settings → "Show static in builder"). Static tags still render normally and are preserved by templates. Use for boilerplate you rarely edit.
Validation runs continuously over Builder state and surfaces issues per tag (icons on the tag card with tooltips). Issues are split into errors and warnings.
Tag IDs must be valid XML element names (a safe ASCII subset):
^[A-Za-z_][A-Za-z0-9._-]*$
- Must start with a letter or underscore.
- May contain letters, digits,
.,-,_. - Are case-sensitive in the rendered XML (
Fooandfooare two different elements). - May not start with
xml(case-insensitive — reserved by the XML spec). - Must be globally unique across the entire prompt — including inside groups, at any nesting depth. Duplicates in a nested group still count as duplicates with a tag at the root.
| Condition | Message |
|---|---|
| Empty | ID is required. |
| Starts with digit / symbol | ID must start with a letter or underscore. |
Starts with xml / XML / Xml … |
ID cannot start with "xml" (reserved). |
| Contains other characters | ID may only contain letters, digits, ".", "-", "_". |
| Two tags share the same valid ID (anywhere in the tree) | Duplicate ID "{id}". |
For list tags with listStyle: xml, the child element name (listChildName) must satisfy the same XML-name rule, otherwise: List child element name is not a valid XML name.
The Settings panel emits three fixed elements at top level: <role>, <directive> (for Think step by step), and <critique> (for Self-critique). You can technically create a tag with one of these IDs — the validator will not block it — but you should avoid them:
- In clean render mode, the output will contain two same-named elements when the corresponding setting is enabled.
- On XML import, top-level elements named
role,directive, orcritiquewithout ap:typeattribute are interpreted as Settings, not as tags, and will not round-trip back into a tag.
If you need a "role"-shaped tag, use the Settings → role field, or pick a different ID like assistant_role / persona.
The Builder auto-resolves ID conflicts in two cases:
- Add preset tag — if a preset's default ID already exists, the new tag gets a numeric suffix (
task→task2→task3…). - Duplicate tag — same suffixing on the duplicated tag.
- Drop a tag onto a non-group tag — the Builder wraps both in a new group whose ID defaults to
group, also auto-suffixed.
Manually editing an ID does not auto-suffix; you'll just see the duplicate error until you fix one of them.
Tag IDs map directly to XML element names in the rendered prompt. Because references expand to a literal <target-id> token, every element name in the output should appear at most once — otherwise a <foo> reference in another field is ambiguous (the LLM cannot tell which <foo> you meant).
The validator enforces this for Tag IDs (see Tag ID rules). It does not catch element-name duplication produced by certain input types, which is currently the user's responsibility:
- The
exampleinput type emits one<example>block per pair, each containing a<input>and an<output>. Three example pairs produce three<example>siblings, three<input>siblings, and three<output>siblings inside the parent — none of these can be referenced unambiguously, and they collide with anytext/group/listtag elsewhere in the prompt that happens to share the nameexample,input, oroutput. Avoid this type whenever the prompt uses references to its content; prefer one Tag per example, or alisttag (see the cookbook). - The
xml-style list also emits siblings with the same element name (listChildName). It disambiguates them with an auto-generatedid="N"attribute, so it is referenceable as a sequence (the parent<schema>is the unique anchor, individual items are addressed by index in prose). Treat the children as not individually referenceable — refer to the parent.
Rule of thumb: if you want a section to be referenceable from elsewhere, give every meaningful element its own unique Tag ID. Use repeating-children types (example, xml-style list) only inside a section whose children you do not need to reference by name.
| Level | Examples | Effect |
|---|---|---|
| Error (red) | Invalid ID, duplicate ID, invalid list child name | Surfaced on the tag card; you can still copy/export, but the output may be malformed XML |
| Warning (amber) | Reference to a missing tag, empty value | Informational only; doesn't block anything |
| Target state | Rendered as |
|---|---|
| Exists, enabled | Literal <target-id> (the tag name, not its value) |
| Exists, disabled | Empty string |
| Deleted | Raw {{ref:uuid}} left in text + warning on the tag |
| Self-reference | Allowed and ignored (no warning, no expansion loop) |
References only appear in the autocomplete for tags whose IDs are currently valid.
A tag is flagged with Empty value. when:
text—textValueis whitespace only.list— every item's text is whitespace only.example— every pair has bothinputandoutputwhitespace only.group— has no children.
Empty tags still render (as a self-closing-style <id></id>) so you can keep placeholders.
Both modes render the same XML structure; they differ in metadata:
- clean — the prompt body only. Used for the Preview pane and the Copy button. No promptly-specific attributes, no namespace.
- promptly — adds
xmlns:p="urn:promptly"andp:*attributes (p:type,p:pinned,p:disabled,p:static,p:notes,p:listStyle,p:listChildName). Used by Export XML so the file round-trips losslessly through Import XML.
Text content containing <, >, or & is wrapped in <![CDATA[...]]> in promptly mode (so the file remains valid XML); clean mode keeps it raw so <id> references render literally for the target LLM.
Settings live alongside tags and render at fixed positions:
- role (string) — emitted at the top of
<prompt>as<role>...</role>(or<p:role>in promptly mode). Empty role is omitted. - think step by step (bool) — appends
<directive>Think step by step.</directive>at the end. - self-critique (bool) — appends
<critique>After your answer, critique it. List 3 things that might be wrong, weak, or missing.</critique>. - copy mode —
raw(XML only) ormarkdown(XML wrapped in a fenced```xmlblock).
Body order: non-pinned tags in Builder order → pinned tags → directive → critique.
The Library tab saves the current { tags, settings } state as a PromptlyFile (version: 1) under the promptly:library localStorage key. Two kinds:
- prompt — a full snapshot, including all field values. Reload to resume editing.
- template — same shape, but intended for reuse: load it as a starting point and fill in the blanks. Static tags preserve their text across template loads.
Items can be renamed, duplicated, or removed. There is no cloud sync.
Export (Export XML button) produces a <prompt xmlns:p="urn:promptly"> document with all p:* metadata, suitable for round-trip.
Import (Import XML button) accepts any XML whose root element is <prompt>. It tolerates files without promptly metadata:
- Tag IDs come from each element's
localName. - Tag type comes from
p:typeif present; otherwise inferred:- No children + body is
true/false→checkbox. - No children →
text. - All children are
<example>→example. - Any other children →
group.
- No children + body is
- For
list, the style is read fromp:listStyleif present, otherwise sniffed from text content (1.→ ordered,[x]/[ ]→ checked,-/*→ unordered) or, if the element has named child elements, treated asxmlstyle. - References saved as
{{ref:<id>}}are remapped to fresh{{ref:<uuid>}}so internal links survive the import.
Failures throw ImportError with a short message: Could not parse file as XML. or Root element must be <prompt>.
Everything lives in localStorage under the promptly: prefix:
| Key | Holds |
|---|---|
promptly:draft |
Current Builder state — { tags, settings }. Auto-saved on edit (debounced 300 ms). |
promptly:library |
Saved prompts and templates (the Library tab). |
promptly:tutorial-seen |
Flag for the first-visit walkthrough. |
No backend, no telemetry, no network calls beyond loading the static site itself. The persistence layer is forward-compatible: older tag shapes from earlier versions are migrated on load (missing fields filled with defaults), so you should not lose work across upgrades.
Clearing site data wipes all prompts and library entries — export anything you want to keep first.
| Layer | Tech |
|---|---|
| UI | React 19 · TypeScript 6 · Vite 8 |
| Styling | MUI 9 (@mui/material + styled) · Emotion |
| State | Redux Toolkit · localStorage (promptly: prefix) |
| Tooling | ESLint flat config · Prettier · npm |
| Deploy | GitHub Actions → GitHub Pages |
promptly/
├── client/ # React + Vite app — all active work lives here
│ └── src/
│ ├── app/ # store, persistence, root layout
│ ├── components/ # shared dumb UI (Button, …)
│ ├── features/
│ │ ├── tags/ # tag model, slice, Builder UI, validation
│ │ ├── settings/ # role + directive toggles
│ │ ├── preview/ # render.ts (clean / promptly modes)
│ │ ├── io/ # importXml / exportXml
│ │ ├── library/ # saved prompts & templates
│ │ └── tutorial/ # first-visit walkthrough
│ ├── hooks/ # shared use*.ts
│ ├── theme/ # MUI theme (light only)
│ └── utils/ # xmlName, xmlEscape, clipboard, …
└── server/ # placeholder for future .NET backend (out of scope)
The client is feature-based: src/features/<feature>/ owns its slice, components, and hooks. See client/CLAUDE.md for code conventions.
git clone https://github.com/jathavaan/promptly.git
cd promptly/client
npm ci
npm run dev| Command | What it does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
tsc -b && vite build |
npm run lint |
ESLint over the workspace |
npm run format |
Prettier write |
npm run preview |
Preview the production build |
Push to main triggers .github/workflows/deploy.yml, which builds client/ and publishes client/dist to GitHub Pages. vite.config.ts sets base: '/promptly/' so assets resolve under the repo path.