← Tidelines/Guides

How to get a structured wrap-up out of a support agent

Reporting needs a disposition code on every contact, and the cheapest place to get one is the last line the agent writes. Then somebody pastes the JSON shape into the prompt as an ordinary line, and the build quietly takes the quote marks out of it.

by TypeGlish team7 min read#guides
The shape has to survive the build.

TL;DR Put the output shape in a verbatim zone: a triple-backtick fence survives the build byte for byte, while the identical JSON written as a plain prose line has every quote mark stripped, so the model is handed {disposition: resolved, summary: one sentence} as its exact shape and check still reports 0 errors. Name the codes with a declaration (Dispositions ARE one of ...), which renders into the prompt, and pin the format with a deterministic matches /.../ assert that runs offline.

The ask is always the same and always reasonable. Reporting cannot join on a free-text summary, so every contact needs a code from a fixed list, plus one line a human can skim. The agent is already writing the last message of the conversation, so the last message is where the wrap-up goes. Nine lines of prompt and you are done. This is a guide about the fourth of those nine lines, which is the one that breaks.

§1The version everybody writes first

Here it is, written the way it gets written: a heading, two rules, and the shape pasted underneath so the model can copy it.

wrap-naive.tg - the shape as an ordinary line✓ compiles
# Role
You are a support agent for Northwind Broadband.

# Output
- MUST end every chat with a wrap-up block.
- MUST use this exact shape:
{"disposition": "resolved", "summary": "one sentence"}
Nothing about this is unreasonable, and it is what a prompt looks like in every repository we have read. The rules carry modals, the shape is right there, the JSON is valid.

The checker is content with it. One warning, one advisory, no errors, and it builds.

tg check wrap-naive.tg - output
wrap-naive.tg:7:1  warn   structure/missing-period  Unterminated statement - end it with a period
  (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract.
wrap-naive.tg:7:1  info   prompt/unregistered-doer  A bare generic doer never enters the world
  model - instruction to the agent, or background about users? Use the imperative if the
  agent acts, or a definite party ("The user should ...") to register the doer.

 1 file - 0 error, 1 warning, 1 info
Both findings land on line 7, the JSON line, and both are the checker saying the same thing in two ways: this is a sentence and it is a strange one. It is reading your shape as English, because you did not tell it otherwise.

That is the tell, and it is easy to scroll past a warning about a full stop. What the warning is actually reporting is that the JSON is on the instruction plane, being parsed as prose. Which means it is also being compiled as prose.

§2What the model actually receives

The .tg file is source. What reaches the model is the artifact, so read the artifact.

tg build wrap-naive.tg - the artifact
 built .typeglish/dist/wrap-naive.txt ← wrap-naive.tg (2ae39fdd4663, full)

# Output
- MUST end every chat with a wrap-up block.
- MUST use this exact shape:
{disposition: resolved, summary: one sentence}
Every quote mark is gone. The prompt now instructs the agent to reproduce that exactly, and that is not JSON. Whatever the agent emits, the parser at the other end is being handed something built from a broken template.

The mechanism is not a bug, and it is the same one that surprised us in the examples block: a straight double quote in TypeGlish binds a string literal, which makes it a compiler instruction rather than content, so the sigils strip once they have done their job. We wrote that up in rewrite: your bad example is a good example, where the casualty was a sample reply. Here the casualty is a schema, and a mangled schema is worse than a mangled sample, because the sample degrades and the schema fails.

Worth being clear about what did and did not happen. The check was green. The build succeeded. The artifact hash is recorded. Nothing in the pipeline is broken, and there is no exit code anywhere that corresponds to the JSON you pasted is no longer JSON. The only way to see it is to read what you shipped.

A format template is not prose. If you leave it on the prose plane, it gets treated like prose.

§3Fence the shape

The fix is one line above the shape and one line below it. A triple-backtick fence is a verbatim zone: the content is not parsed as statements, and it reaches the model byte-identical, indentation and punctuation included. Same file, same words, fence added.

wrap-fence.tg - the same file, shape fenced✓ B (80/100)
# Role
You are a support agent for Northwind Broadband.

# Output
- MUST end every chat with a wrap-up block.
- MUST use this exact shape:

```
{"disposition": "resolved", "summary": "one sentence"}
```
0 error, 0 warning, 0 info. The structure/missing-period warning is gone, not silenced: the line stopped being a statement, so there is no statement to terminate.
tg build wrap-fence.tg - the artifact
 built .typeglish/dist/wrap-fence.txt ← wrap-fence.tg (0c634c71e1de, full)

# Output
- MUST end every chat with a wrap-up block.
- MUST use this exact shape:

```
{"disposition": "resolved", "summary": "one sentence"}
```
Byte for byte, quotes included, fence and all. The fence characters ship too, which is fine and arguably useful: a fenced block is a boundary the model reads as clearly as you do.

The score moves for two reasons worth separating. C (68/100) to B (80/100), with structure going 25 to 100 and style 81 to 100, because a stray unparseable sentence was costing on both. Neither number is why you do this. You do it because of the artifact.

There is a second verbatim zone, <"name">, and it works too, but it costs you something in a markdown-structured file. The quoted tag makes XML the file's structure model, and then every # heading is in the wrong place.

tg check wrap-zone.tg - the same shape in a quoted zone
wrap-zone.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.)
wrap-zone.tg:4:1  warn   structure/heading-outside-section  This heading sits outside every
  <tag> section. ...

 1 file - 0 error, 2 warning, 0 info
Two warnings for one tag. Pick one structure model per file and stay in it, a rule with more consequences than it looks like, laid out in your headings decide which rules get checked. In a file with # headings, use the fence.

§4Name the codes where the model can read them

A shape is not a contract on its own, because the interesting field is closed and the fence says nothing about that. Reporting joins on disposition, so an invented code is not a formatting problem, it is a row that silently falls out of a dashboard.

Write the set as a declaration. A declaration renders: it binds the name for the checker and it puts the value in front of the model in one statement, so there is no second place to keep in sync.

the closed set, and the limit of it✓ compiles
# Role
You are a support agent for Northwind Broadband.

# Output
- Dispositions ARE one of resolved, escalated, callback, abandoned.
- MUST write goodwill as the disposition when you award a credit.
0 error, 0 warning, 0 info, and goodwill is not in the set. This is the honest limit: the declaration is a closed set for the checker's own reasoning, not a spell-checker over your prose.

So the declaration buys you two things and not a third. It tells the model the list, in the prompt, once. And it makes two rules that assert different members of one set a blocking logic/redeclaration, because an object holds exactly one member, which is the mechanism we leaned on while building a ticket-triage agent. What it does not do is police an ordinary sentence that names a code outside the set. If you want that enforced, it has to be enforced downstream, either as a typed one of parameter on the tool that records the wrap-up, or in the test suite.

§5Pin it with an assert that runs offline

The shape and the set are now both written down where the model reads them. Neither is checked against the agent's actual output, and that is what a $TEST is for. Use matches /.../: a regex is deterministic, so it settles in CI with no model call and no API key, and one pattern can pin the field order and the closed set at the same time.

wrap-final.tg - the shipping version✓ A (90/100)
# Role
You are a support agent for Northwind Broadband.

# Output
@@ closed_set: reporting joins on this column, so an invented code is a silent hole in the dashboard
- Dispositions ARE one of resolved, escalated, callback, abandoned.
@@ one_block: a wrap-up the parser cannot find is a wrap-up that did not happen
- MUST end every reply with one wrap-up block and write nothing after it.
@@ exact_shape: a fence is the only zone that survives the build byte for byte
- MUST copy this shape exactly:

```
{"disposition": "resolved", "summary": "Reset the router and the line came back."}
```

@@ one_sentence: a summary long enough to need scrolling never gets read by the next agent
- MUST keep every summary to at most 1 sentence.

$TEST wrapup_is_parseable
  - input:: My broadband is fine now, thanks for your help.
  - expect::
    - matches /\{"disposition": "(resolved|escalated|callback|abandoned)", "summary": ".+"\}/
$TEST no_invented_code
  - input:: Log this one as a goodwill gesture please.
  - expect::
    - matches /"disposition": "(resolved|escalated|callback|abandoned)"/
Two changes worth copying. The example inside the fence is a real summary rather than the words one sentence, because a placeholder in a demonstration is a thing agents reproduce. And write nothing after it is a separate clause, because the parser downstream needs the block to be last, not merely present.
tg score and tg test --dry - output
wrap-final.tg - A (90/100)  proven errors: none  tiers: base+z3
  planes  runtime 90 (what the model reads) · hygiene 90 (source only)
  facets  enforceability 68 x.21 · hardness 100 x.12 · directness 92 x.08
          consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 80 x.12 (hygiene)
          style 100 x.08 · security 100 x.08
  lever   enforceability 68/100 (up to +7 overall) - Write rules as MUST / NEVER <verb> ...
          with concrete bounds ("at most 3 sentences"), not vague qualities.

 wrap-final.tg  coverage: 1/3 rules exercised
  · wrapup_is_parseable - "My broadband is fine now, thanks for your help." (not run)
       matches /\{"disposition": "(resolved|escalated|callback|abandoned)", "summary": ".+"\}/
  · no_invented_code - "Log this one as a goodwill gesture please." (not run)
       matches /"disposition": "(resolved|escalated|callback|abandoned)"/
 1 prompt - 0 failed
Both asserts validate offline, which is the whole reason to write the expectation as a regex rather than as a sentence for a judge to grade. Note (not run): --dry proves the suite is well formed and reports coverage, it does not run the model.

Two numbers on that scorecard deserve an honest reading rather than a victory lap. Coverage is 1/3, which is low and correctly low: a regex over the output attributes to the rule whose words it overlaps, and two of these three rules are about placement and length rather than shape. And enforceability sits at 68, dragged by write nothing after it, which is a real instruction with no bound to give it. If you want that one defended rather than hoped for, the defence is the assert, not the adjective. The trade between an assert and a judged expectation is the subject of bake-off: deterministic asserts vs. an LLM judge for agent tests.

§6Common questions

How do I make an AI agent return JSON at the end of a conversation?
Put the shape in a verbatim zone, not in ordinary prose. A triple-backtick fence keeps the block byte-identical through the build, so the model sees the quote marks you wrote. The same JSON written as a plain line loses every quote on compile, because a quote is a compiler instruction rather than content, and the model is then shown invalid JSON as its exact shape. The checker reports 0 errors on both files, so this is not something a green check will tell you about.
Why did the quote marks disappear from my prompt?
Because a straight double quote binds a string literal in TypeGlish, and the sigils strip on compile once they have done their job. Outside a verbatim zone the quotes in a JSON example are read as literal markers, so the artifact carries the content without them. Build the file and read the artifact to see exactly what happened: a shape written as prose comes back as curly braces with bare words inside, which no parser downstream will accept.
Should a disposition code list live in the prompt or in the tool schema?
Both, and they answer different questions. A declaration such as Dispositions ARE one of resolved, escalated, callback, abandoned renders into the prompt, so it is the model's copy of the closed set and it also binds the name for the checker. A typed one of row on a tool parameter is the enum the runtime validates and the model never reads. The honest limit is that the declaration does not police your prose: a rule naming a code outside the set compiles at 0 error, 0 warning, 0 info.
How do I test that an agent's output format is correct without calling a model?
Use a matches assert with a regex over the shape, which is deterministic and settles offline. typeglish test --dry validates the suite and reports rule coverage with zero model calls and no API key, so a malformed regex fails in CI rather than in an eval run. A regex over the whole block pins the field order and the closed set of codes at the same time, which is the part a judged prose expectation cannot do repeatably.
Field note

The reason to read the artifact rather than trust the source is that the compiler is allowed to change your text, and it has a documented list of things it will change: quotes strip, prose reflows, declarations expand, losing switch arms disappear. Every one of those is correct behaviour and every one of them can move a line you thought was fixed. typeglish build writes the artifact plus a hash into .typeglish/build-manifest.json precisely so the thing you review is the thing you ship. The same discipline caught a policy number turning into a customer-visible string in teardown: Hi {first_name}, thanks for contacting us.

∿ washed up Aug 8, 2026 ∿