← Tidelines/Guides

How to structure an agent prompt with XML tags

Every model vendor tells you to delimit your sections with tags, and every one of them stops at the advice. A tag is not decoration: it is a name, a scope, and a decision about the whole file, and it has four ways of going wrong that a prompt review will never catch.

by TypeGlish team8 min read#guides
A tag is a name, not a label.

TL;DR One XML section makes XML the structure model for the entire file, so every leftover # heading becomes a structure/heading-outside-section warning; section bodies are scoped by a two-space indent (structure/bad-indent); a tag name is a name in a scope, so two <escalation> blocks are a blocking structure/duplicate-section; and only the quoted form <"name"> keeps your line breaks, because a plain tag reflows its body into one line in the artifact.

The advice arrives from the vendor docs and it is good advice: give the model explicit boundaries, so it can tell your tone rules from your escalation rules from your worked examples. What the advice never mentions is that a delimiter you invented is now a structural claim, and the file has opinions about structural claims. Here is a prompt in the state most of them reach after two people have touched it.

support.tg - tags added the way the guide said✗ 3 error, 5 warning
# Role
You are a support agent for Kettleman's, a meal-kit subscription service.

<tone>
- MUST keep every reply to at most 3 sentences.
- NEVER use an exclamation mark.
</tone>

<escalation>
- WHEN a customer asks for a manager THEN you MUST transfer the customer to a human agent.
</escalation>

<escalation>
- WHEN a customer mentions a solicitor THEN you MUST transfer the customer to a human agent.
</escalation>

<examples>
Customer: My box never arrived.
Agent: I am sorry about that. I can send a replacement today.
Nineteen lines. Nobody wrote a bad rule. The second <escalation> arrived from Legal in its own pull request, and the missing </examples> is what happens when a block is the last thing in a file.
tg check support.tg - output
$ typeglish check support.tg
support.tg:1:1   warn   structure/heading-outside-section  This heading sits outside every
  <tag> section. One XML section anywhere makes XML the file's structure model, and every
  # heading must then live INSIDE a tag. [...]
support.tg:5:1   warn   structure/bad-indent  Indentation mirrors section nesting: expected
  column 2 (1 level deep), found 0. The indent unit is 2 spaces.
support.tg:6:1   warn   structure/bad-indent  [...]
support.tg:9:1   error  structure/duplicate-section  Conflicts with line 10. Duplicate section
  "escalation" - 2 sections in one scope share this name. Give each a distinct name; the
  structure (and any reference to "escalation") is ambiguous.
support.tg:10:1  warn   structure/bad-indent  [...]
support.tg:13:1  error  structure/duplicate-section  Conflicts with line 7. Duplicate section
  "escalation" - 2 sections in one scope share this name. [...]
support.tg:14:1  warn   structure/bad-indent  [...]
support.tg:17:1  error  structure/unclosed-tag  Tag <examples> is opened but never closed -
  add a </examples>.

 1 file - 3 error, 5 warning, 0 info
Three distinct mistakes, eight findings, exit 1. None of them is about a rule. Every one is about the delimiters, which is the part nobody reviews.

§1One tag decides the whole file

Start here, because it is the decision the other three follow from. A prompt file has exactly one structure model, and the first XML section in it chooses XML for everything. From that point on a # heading is homeless.

mixed.tg - two headings and one tag2 warning
# Role
You are a support agent for Kettleman's.

<tone>
  - MUST keep every reply to at most 3 sentences.
</tone>

# Constraints
- NEVER use an exclamation mark.
0 error, 2 warning, 0 info, one per heading. The <tone> block is fine; the two headings that were fine yesterday are the findings.

The message is worth reading in full once, because it is the whole rule and it names both exits:

tg check mixed.tg - the rule, stated
mixed.tg:1:1  warn  structure/heading-outside-section  This heading sits outside every
  <tag> section. One XML section anywhere makes XML the file's structure model, and every
  # heading must then live INSIDE a tag. Two fixes: move this heading (and its body) into
  a section, or make it a section itself (<name> ... </name>). (<$CONFIG> is control-plane
  - it neither triggers this nor counts as a home.)
The parenthetical matters in practice: your <$CONFIG> block is not an XML section and does not trip this, so a file with a config block and # headings is not secretly in XML mode.

So this is a choice you make once, per file, on purpose. Headings are the lighter option and the right default for a prompt that is mostly rules, and they carry meaning of their own: your headings decide which rules get checked is a whole post on what # Role does to the text underneath it. Reach for tags when a section is a thing you want to name, because the three capabilities that follow are all things you can only do to a named section.

§2The body indents, and the indent is for the compiler

This is the one nobody guesses, and it is the source of most of the noise in a freshly tagged file. XML sections nest, and the indent is how the compiler reads the nesting back, so a section body sits two spaces in from its tag. Write it flat, the way you would write HTML, and every content line reports.

flat.tg - correct XML, wrong indentation2 warning
<role>
You are a support agent for Kettleman's.
</role>

<tone>
- MUST keep every reply to at most 3 sentences.
</tone>
tg check flat.tg - output
$ typeglish check flat.tg
flat.tg:2:1  warn  structure/bad-indent  Indentation mirrors section nesting: expected
  column 2 (1 level deep), found 0. The indent unit is 2 spaces.
flat.tg:6:1  warn  structure/bad-indent  Indentation mirrors section nesting: expected
  column 2 (1 level deep), found 0. The indent unit is 2 spaces.

 1 file - 0 error, 2 warning, 0 info
A warning rather than an error, so it still builds, which is exactly why a tagged prompt accumulates dozens of these before anyone looks. It is a two-space fix per line and the diagnostic tells you the column it wanted.

Then the part that stops it feeling like ceremony for its own sake. Build the correctly indented file and the artifact comes back flush left: the tags are there, the rules are there, and every space you added is gone. You are indenting for the compiler and for the next engineer, and the model reads the same prompt either way. That is the general shape of a TypeGlish source file, and it is the reason the hygiene facets are scored separately from the runtime ones.

§3A tag name is a name

The moment you write <escalation> you have declared a section called escalation, in a scope, and the compiler will hold you to it the way it holds you to any other name. Two of them is not two sections with a shared theme, it is one name bound twice.

tg check - the three structural errors, in one place
support.tg:9:1   error  structure/duplicate-section  Conflicts with line 10. Duplicate section
  "escalation" - 2 sections in one scope share this name. Give each a distinct name; the
  structure (and any reference to "escalation") is ambiguous.

support.tg:17:1  error  structure/unclosed-tag  Tag <examples> is opened but never closed -
  add a </examples>.

stray.tg:4:1     error  structure/stray-close-tag  </tone> has no matching <tone> to close.
All three block the build, and all three are about the same thing: a section whose extent or identity is undefined cannot be compiled, because there is no honest answer to where does this section end or which one did you mean.

The duplicate is the interesting one operationally, because the fix is a conversation rather than an edit. Two <escalation> blocks in a support prompt almost always means two teams shipped an escalation policy and neither read the other's. Merging them into one block hides that. Naming them <escalation_manager> and <escalation_legal> keeps both policies visible and makes the file say which is which.

One caveat while you are naming things, because it is easy to assume a tag gives you a scope it does not: a section is not a scope for a bound. Two length rules in two different tags are still two claims about one action, which building an agent-assist drafter works through in detail, at four blocking errors. Tags scope structure, not arithmetic.

§4Only the quoted tag keeps your line breaks

This is the one that ships a broken prompt rather than a noisy source file, so it is worth the ninety seconds. Everybody puts an output format in a tag. Here are the same three lines in a plain tag and in a quoted one.

wrapup-plain.tg - a template in a plain tag6 warning, 1 info
<role>
  You are a support agent for Kettleman's.
</role>

<wrapup_format>
  disposition: resolved
  summary: one sentence
  next_action: none
</wrapup_format>
tg check and build - the template, reflowed
$ typeglish check wrapup-plain.tg
wrapup-plain.tg:6:3  warn  structure/missing-period  Unterminated statement - end it with a
  period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract.
wrapup-plain.tg:6:3  warn  structure/wrapped-fragment  Reads as a hard-wrapped continuation of
  line 4 - it starts lowercase, classifies as plain prose, and line 4 has no terminator. [...]
  (the same pair again on line 7 and line 8, plus prompt/unregistered-doer)

 1 file - 0 error, 6 warning, 1 info

$ typeglish build wrapup-plain.tg && cat .typeglish/dist/wrapup-plain.txt
 built .typeglish/dist/wrapup-plain.txt ← wrapup-plain.tg (bc6f225667d6, full)
<role>
You are a support agent for Kettleman's.
</role>

<wrapup_format>
disposition: resolved summary: one sentence next_action: none
</wrapup_format>
Read the last line. Your three-field template reached the model as one sentence. The six warnings were the compiler telling you it had read three prose fragments and joined them, and because they are warnings, check exits 0 and nothing downstream complains.

The fix is one character on each end of the name. A quote pair is the literal-zone sigil at section scale: the body is verbatim, the interior is exempt from analysis, and the tag emits bare.

wrapup-lit.tg - the same template, quoted✓ 0 error, 0 warning, 0 info
<role>
  You are a support agent for Kettleman's.
</role>

<"wrapup_format">
  disposition: resolved
  summary: one sentence
  next_action: none
</"wrapup_format">
tg build wrapup-lit.tg - the artifact
$ typeglish build wrapup-lit.tg && cat .typeglish/dist/wrapup-lit.txt
 built .typeglish/dist/wrapup-lit.txt ← wrapup-lit.tg (9cb1e48de056, full)
<role>
You are a support agent for Kettleman's.
</role>

<wrapup_format>
  disposition: resolved
  summary: one sentence
  next_action: none
</wrapup_format>
Three lines in, three lines out, indentation preserved, and the quotes are gone from the tag the model sees. A triple-backtick fence does the same job for a JSON or code payload, which is the route getting a structured wrap-up out of a support agent takes for a disposition object.

One more distinction while you are in here, because the two are easy to conflate. <examples> is exempt from rule analysis: a flat MUST offer a callback against NEVER offer a callback inside it is 0 error, 0 warning, 0 info, where the same two lines inside a <tone> tag are 2 blocking logic/contradiction errors. That exemption is deliberate, because demonstration text is not policy. It is not the same as verbatim, though, so an <examples> block whose layout matters still wants the quoted form.

§5The finished file

All four rules applied to the prompt we started with. Nothing was rewritten: one heading became a section, the bodies moved in two spaces, the duplicate name split into two honest names, the missing close tag came back, and the output template got its quotes.

support-v2.tg - tags that carry their weight✓ A (98/100)
<role>
  @@ role: subscription support, chat only
  You are a support agent for Kettleman's, a meal-kit subscription service.
</role>

<tone>
  @@ brevity: three sentences keeps a chat reply scannable
  - MUST keep every reply to at most 3 sentences.
  @@ punctuation: an exclamation mark reads as sarcasm in a complaint
  - NEVER use an exclamation mark.
</tone>

<escalation_manager>
  @@ manager_ask: an explicit ask for a manager is never negotiated
  - WHEN a customer asks for a manager THEN you MUST transfer the customer to a human agent.
</escalation_manager>

<escalation_legal>
  @@ legal_mention: legal language stops the automated conversation immediately
  - WHEN a customer mentions a solicitor THEN you MUST transfer the customer to a human agent.
</escalation_legal>

<"wrapup_format">
  disposition: resolved
  summary: one sentence
  next_action: none
</"wrapup_format">
0 error, 0 warning, 0 info at A (98/100), with structure 100, annotation 100 and enforceability 93 as the remaining lever. Not one rule changed from the file at the top of the post.

Compressed to something you can run against a prompt you already have:

  • Pick a structure model and finish the job. One tag anywhere means every # heading is a warning, so either convert them all or use none. <$CONFIG> does not count either way.
  • Indent every body two spaces. Per nesting level. The artifact strips it, so this is for the compiler and the next reader.
  • Give every tag a distinct name, and close it. A duplicate name is usually two teams' policies in one file; split the name rather than merging the policy.
  • Quote the name of any block whose layout matters. <"name"> for a template, a fence for JSON. A plain tag will join your lines and warn about it rather than refusing.

§6Common questions

Should I use XML tags or markdown headings in a system prompt?
Pick one and use it for the whole file, because the file already has a rule about it. One XML section anywhere makes XML the structure model, and every remaining # heading is then a structure/heading-outside-section warning: a two-heading file with a single <tone> section is 0 error, 2 warning, 0 info, one warning per orphaned heading. The message states the choice outright, that every # heading must then live INSIDE a tag, and offers both exits, move the heading into a section or make it a section itself. Headings are the lighter option for a prompt that is mostly rules; tags earn their ceremony when a section is a thing you want to name, guard, or keep verbatim.
Why does TypeGlish want my XML section body indented?
Because XML sections nest and the indent is how the compiler reads the nesting back. The finding is structure/bad-indent, and it is precise about what it wanted: expected column 2 (1 level deep), found 0. The indent unit is 2 spaces. A two-section file written flat is 0 error, 2 warning, 0 info, one warning per unindented line, and it still builds. The indentation is a source convention rather than something the model reads: build the file and the artifact comes back with every tag and every rule flush left, so you are indenting for the compiler and for the next engineer, not for the prompt.
Can I have two sections with the same XML tag name in a prompt?
No. Two <escalation> blocks in one file are a blocking structure/duplicate-section error, reported once on each participating tag with each report naming the other, and the message says why: the structure (and any reference to "escalation") is ambiguous. A tag name is a name in a scope, not a label you can reuse, so the fix is to give each one a distinct name (<escalation_manager> and <escalation_legal>) rather than merging two policies that arrived from two teams. The neighbouring structural errors work the same way: an unclosed <examples> is structure/unclosed-tag and a closing tag with nothing above it is structure/stray-close-tag, both blocking, because a section whose extent is undefined cannot be compiled.
How do I keep an output format from being reflowed inside an XML tag?
Quote the tag name. A three-line output template inside a plain <wrapup_format> tag is 0 error, 6 warning, 1 info, three structure/missing-period and three structure/wrapped-fragment, and the built artifact reads disposition: resolved summary: one sentence next_action: none, all three lines joined into one. The identical content inside <"wrapup_format"> is 0 error, 0 warning, 0 info and builds with the three lines and their indentation intact, emitting the tag bare as <wrapup_format>. The quote pair is the literal-zone sigil at section scale: the body is verbatim and exempt from analysis. <examples> is exempt from rule analysis too, so a flat contradiction inside it is silent, but it is not verbatim, so it will still reflow.
Field note

The capability that makes a named section worth the ceremony is the guard: <tone when=@{channel} is equal to voice> includes a whole block only when the condition holds, and the compiler resolves it away before the model reads anything. How to turn parts of your system prompt on and off covers it, including the trap of putting the when= on a # heading, which is inert and blocked with structure/guard-on-heading. That is the one thing a heading genuinely cannot do.

∿ washed up Aug 24, 2026 ∿