← Tidelines/Guides

Building an agent-assist drafter, prompt-first

The agent that never talks to a customer. Its output is copy somebody else sends, which changes who every rule is addressed to, and puts two blocking errors in front of you before you reach a single policy decision.

by TypeGlish team9 min read#guides
Two readers. One output.

TL;DR An agent-assist prompt has two readers and one output, and nothing about a section scopes a rule to half of it: two length rules in two XML sections are 4 blocking errors, and the fix is to name the object (a draft, a note) or the unit (in every draft, in every note). Address every rule to You, keep the human agent in a prose block, and the finished nine-rule spec checks at 0 error, 0 warning, 0 info and scores A (97/100).

Every agent this series has built so far talks to a customer. This one does not. It reads a ticket and writes the reply a human agent will read, edit and send, which is the shape most midmarket CX teams actually shipped first, because it is the one the risk register signs off. The brief is short and the failure modes are all new.

Here is the brief, as it arrives from an ops lead, in six sentences.

We want the assistant to draft replies in the queue so agents are not typing from scratch. It should read the ticket and write a reply in our tone. It should suggest a next step. Agents review everything before it goes out, so it must never send anything itself. Keep drafts short, nobody reads a wall of text. If it is a delivery question it should check the order status, and do not let it promise anything we cannot do.

§1The brief, typed straight in

Start where everybody starts: paste the brief into a .tg file, one sentence per line, and see what the compiler makes of it.

draft1.tg - the brief, unedited✓ 0 error
# Role
You are a drafting assistant for the care queue.

# Instructions
- The agent should read the ticket and write a reply in our tone.
- You MUST suggest a next step.
- Everything is reviewed by an agent before it goes out, so you never send anything yourself.
- Keep drafts short - nobody reads a wall of text.
- IF the ticket is about a delivery, check the order status.
- Try not to promise anything we cannot do.
Six lines, one per sentence of the brief. Nothing has been decided yet; this is transcription.
tg check + tg score draft1.tg - output
$ npx typeglish check draft1.tg
draft1.tg:7:1  info   prompt/agentless-passive  The subject is not the doer (passive). If the by-phrase names the doer, put the doer first.
draft1.tg:9:1  info   typeglish/if-then  IF needs a THEN — write IF <condition> THEN <action>.
draft1.tg:9:1  info   prompt/unintroduced-definite  "the order status" retrieves something this document never introduces - a model must guess which status is meant. Introduce it on another line ("You manage a status.") or name it outright.

 1 file — 0 error, 0 warning, 3 info

$ npx typeglish score draft1.tg
draft1.tg — C (73/100)  proven errors: none  tiers: base+z3+spell
  planes  runtime 83 (what the model reads) · hygiene 45 (source only)
  facets  enforceability 58 x.21 · hardness 92 x.12 · directness 81 x.08 · consistency 100 x.17
          structure 89 x.12 (hygiene) · annotation 0 x.12 (hygiene) · style 79 x.08 · security 100 x.08
Three advisories, all real and all cosmetic. enforceability 58 is the honest number: half these lines are descriptions of a workflow rather than instructions with a bound.

Now read the line that produced nothing. The agent should read the ticket and write a reply in our tone. That sentence hands the entire job to a different person, and it is the cleanest line in the file. It is also the sentence the brief author would defend hardest, because in the room where the brief was written, the agent meant the human on the phones.

§2Decide who you is, once

An agent-assist prompt is the first place in a contact centre where the word agent has two referents in one file. The compiler has an opinion about that, and it is a narrow one: rules are indexed by their subject, and the third-person subject is never folded onto the addressee. Two files, one word apart.

tg check subj/ - the same policy, two subjects✗ 2 error
$ npx typeglish check subj
subj/one-person.tg:5:1  error  logic/contradiction  Conflicts with line 5. Logical conflict — "send a reply to a customer." is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).
subj/one-person.tg:6:1  error  logic/contradiction  Conflicts with line 4. Logical conflict — "send a reply to a customer." is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).

 2 files — 2 error, 0 warning, 0 info
program: 2 independent files — no $IMPORT compositions
subj/two-people.tg is You MUST NOT send a reply to a customer beside The agent MUST send a reply to a customer, and it is silent. subj/one-person.tg is the same pair with the second subject changed to You. One word, two errors.

The checker is right to stay out of it, for the reason the subject-drift piece works through: merging every agent-ish noun into you would manufacture conflicts in prompts where a human colleague genuinely has different authority. In agent assist that is not a hypothetical. The human really is allowed to send; the model really is not. So the decision is a design decision and it has to be made once:

  • Every rule the model follows is addressed to You. No exceptions, no formality creep, no the assistant should.
  • Everything the human does is a fact, not a rule. It goes in a prose block (# Context), where it is exempt from rule analysis by design and cannot be mistaken for an instruction the model is meant to carry out.

That single split is what makes the safety line worth writing. NEVER send a reply to a customer only proves anything if every other send rule in the file is about the same actor.

§3One output, two halves, and no scope

The product shape is a draft plus a handover note: the draft is customer copy, the note is one line telling the agent what the model could not settle. Two audiences, one completion. The obvious way to give them different limits is to put each rule next to the thing it governs, which is what sections are for.

scope/sections.tg - a length rule in each section✗ 4 error
<role>
  You are a drafting assistant for a care queue.
</role>

<draft>
  - You MUST write at most 5 sentences.
</draft>

<note>
  - You MUST write at most 1 sentence.
</note>
Two rules, two sections, two obviously different objects. It does not compile.
tg check scope/ - sections, objects, units
$ npx typeglish check scope
scope/sections.tg:6:3  error  logic/quantifier-strength  Conflicts with line 8. One rule, two counts — "write at most 1 sentence." already sits inside "write at most 5 sentences."; the looser bound is dead weight. Keep the tighter line.
scope/sections.tg:6:3  error  logic/action-count  Conflicts with line 8. One action, two counts — "write at most 5 sentences" but "write at most 1 sentence". A directive carries ONE count for "write sentences" — state how many once.
scope/sections.tg:10:3  error  logic/quantifier-strength  Conflicts with line 5. One rule, two counts — "write at most 1 sentence." already sits inside "write at most 5 sentences."; the looser bound is dead weight. Keep the tighter line.
scope/sections.tg:10:3  error  logic/action-count  Conflicts with line 5. One action, two counts — "write at most 5 sentences" but "write at most 1 sentence". A directive carries ONE count for "write sentences" — state how many once.

 3 files — 4 error, 0 warning, 0 info
program: 3 independent files — no $IMPORT compositions
Three files in the directory and only one of them reports. scope/objects.tg writes the same policy as keep a draft to at most 5 sentences and keep a note to at most 1 sentence; scope/units.tg writes it as write at most 5 sentences in every draft and write at most 1 sentence in every note. Both are 0 error, 0 warning, 0 info.
A section is a place to put a rule. It is not a scope the rule is about.

The slot the solver reasons over is built from the action and its object, and write sentences is one action whichever tag it sits under. Every count you write lands in the same slot until something in the sentence says otherwise, and there are exactly two things that do it: the object (a draft, a note) or the unit (in every draft, in every note). This is the same mechanism that decides whether an exception narrows a rule or moves it, seen from the other side: here you want the two rules to stop meeting.

One wrinkle worth knowing before you pick. Naming the object separates the slots, but it leaves the two bounds spelled differently from the third length rule in the file (ask at most 1 question in every draft), and the finished spec written that way draws a warning for it.

tg check - the object-named pair, in the finished file
objnamed.tg:17:1  warn   prompt/count-alignment  Count alignment — "sentence" carries two different count rules (line 24): one names a unit, one does not. Two spellings of one limit read as drift — state ONE bound with ONE unit.

 1 file — 0 error, 1 warning, 0 info
Advisory, not blocking, and worth A (95/100) against A (97/100). The unit spelling is what clears it, so the spec below uses in every draft and in every note throughout and never mixes the two forms.

§4The output template, and the one mark a fence will not protect

A two-part output needs a template the model can copy, which means a verbatim zone. A triple-backtick fence is the usual answer, and it does the job it is famous for: the interior stops being statements, so the bare word DRAFT no longer draws structure/missing-period. It does not do the other job you were assuming.

shape/angle.tg - the placeholder everybody writes first✗ 2 error
# Role
You are a drafting assistant for a care queue.

# Output
```
DRAFT
<the reply>
NOTE
<one sentence for the agent>
```
Inside a fence, in a section whose whole point is that its content is verbatim.
tg check shape/ - fenced, unfenced, and square
$ npx typeglish check shape
shape/angle.tg:7:1  error  structure/unclosed-tag  Tag <the> is opened but never closed — add a </the>.
shape/angle.tg:9:1  error  structure/unclosed-tag  Tag <one> is opened but never closed — add a </one>.
shape/unfenced.tg:5: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.
shape/unfenced.tg:6:1  error  structure/unclosed-tag  Tag <the> is opened but never closed — add a </the>.
shape/unfenced.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.
shape/unfenced.tg:8:1  error  structure/unclosed-tag  Tag <one> is opened but never closed — add a </one>.

 3 files — 4 error, 2 warning, 0 info
program: 3 independent files — no $IMPORT compositions
Read the fence's contribution by subtraction. unfenced.tg is 2 errors and 2 warnings; angle.tg is the same file inside a fence and is 2 errors and 0 warnings. The fence took the prose plane away and left the section skeleton exactly where it was. shape/square.tg, which writes [the reply], is silent.

That is a sound boundary rather than an oversight: sections are the file's structure, and a construct that could hide an unclosed section inside itself would make the extent of every tag unknowable. The consequence for an output template is small and specific. Angle brackets are the one placeholder syntax you cannot use; square brackets are plain prose (bare braces and squares stopped being references), and they survive the build byte for byte.

§5The tool you are deliberately not giving it

The brief says the assistant must never send. Three ways to write that, and they land in three different places.

tools3/prose-prohibition.tg - the tool is imported, the ban is prosewarn
<$CONFIG>
  $IMPORT tool get_order_status, send_reply
</$CONFIG>

# Role
You are a drafting assistant for a care queue.

# Constraints
- NEVER send a reply to a customer.
- WHEN a ticket is about a delivery THEN you MUST call @[get_order_status].
tg check tools3/ - three spellings of one prohibition
$ npx typeglish check tools3
tools3/prose-prohibition.tg:2:3  warn   structure/unused-import  Imported tool "send_reply" is never used.
tools3/undeclared.tg:9:14  error  structure/undefined-tool-inline  @[send_reply] — no $TOOL or $IMPORT tool named "send_reply" in this file. Define it ("$TOOL send_reply") or import it ("$IMPORT tool send_reply") so the pointer has a target.

 3 files — 1 error, 1 warning, 0 info
program: 3 independent files — no $IMPORT compositions
The silent one is tools3/pointed-prohibition.tg, which imports both tools and writes the ban as NEVER call @[send_reply]. A pointer counts as a use, so the warning goes; the pointer also has to resolve, which is why the third file, pointing at a tool nobody imported, is blocking.

The instinct at this point is that a pointer inside a prohibition must be handing the model the schema of the tool it is being told not to touch. Build it and it does not.

dist/pointed-prohibition.txt - what the pointer lowers to
# Role
You are a drafting assistant for a care queue.

# Constraints
- NEVER call send_reply.
- WHEN a ticket is about a delivery THEN you MUST call get_order_status.
An $IMPORT tool is a declaration that the host provides the tool, not a definition of it, so @[send_reply] lowers to the bare name. The artifact reads exactly as intended: one tool named as available, one named as forbidden.

Which settles the design and also settles what the prompt is for. Importing a tool does not grant it and forbidding it does not remove it; the host's tool list decides what the model can reach. The spec below therefore imports only get_order_status, keeps the send prohibition in prose, and treats the absence of an outbound tool as the actual control. The prompt's job is to stop the model claiming it sent something.

§6The finished spec

Nine rules, a fenced output template, one worked example and one test.

drafter.tg - the spec✓ A (97/100)
<$CONFIG>
  $DEFINE word Northwind
  $IMPORT tool get_order_status
</$CONFIG>

# Role
@@ reader: the output is read by a care agent, never by a customer
You are a drafting assistant for the Northwind care queue.

# Context
A care agent reads every draft, edits it, and sends it. The customer never sees your output directly.

# Instructions
@@ lookup: a delivery answer without the order record is a guess
- WHEN a ticket is about a delivery THEN you MUST call @[get_order_status].
@@ draft_length: an agent under handle-time pressure edits short copy and rewrites long copy
- You MUST write at most 5 sentences in every draft.
@@ one_ask: two questions in one reply gets one answer
- You MUST ask at most 1 question in every draft.
@@ note_length: the note is scanned, not read
- You MUST write at most 1 sentence in every note.

# Constraints
@@ no_send: the assistant has no outbound capability and must not claim one
- NEVER send a reply to a customer.
@@ no_date: a date in a draft survives review because it reads like a fact
- NEVER state a delivery date in a draft.
@@ no_refund: a refund offer is a policy decision an agent has to make
- NEVER offer a refund in a draft.

# Output
Write a draft and then a note, in this shape:

```
DRAFT
[the reply, addressed to the customer]
NOTE
[one sentence for the agent]
```

$EXAMPLE late_delivery
  - input:: Ticket 4417. Customer asks where an order is and says it was due Tuesday.
  - good:: DRAFT / Thanks for chasing this up, and sorry for the wait. Your order is with the courier now, and I have asked them to confirm the next attempt. I will come straight back to you. / NOTE / Courier holds the parcel, no scan since Monday.
  - bad:: DRAFT / Sorry about that, your order will arrive on Thursday and I have refunded the delivery charge. / NOTE / Done.

$TEST no_date_in_draft
  - input:: Ticket 4417. Customer asks where an order is and says it was due Tuesday.
  - expect::
    - matches /^(?!.*(Monday|Tuesday|Wednesday|Thursday|Friday|tomorrow)).*$/
    - contains "NOTE"
The two rules earning their place are no_date and no_refund. Both name things a reviewer skimming a plausible draft at 40 seconds a ticket will not catch, because a date and a goodwill gesture both read like the assistant knew something.
tg check + score + test + build drafter.tg - output
$ npx typeglish check drafter.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score drafter.tg
drafter.tg — A (97/100)  proven errors: none  tiers: base+z3+spell
  planes  runtime 100 (what the model reads) · hygiene 87 (source only)
  facets  enforceability 100 x.21 · hardness 100 x.12 · directness 99 x.08 · consistency 100 x.17
          structure 100 x.12 (hygiene) · annotation 73 x.12 (hygiene) · style 100 x.08 · security 100 x.08
  lever   annotation 73/100 (up to +3 overall) — Put a "@@ why" note directly above each statement ("@@ name: why" also names it).

$ npx typeglish test drafter.tg --dry
 drafter.tg  coverage: 1/7 rules exercised
  · no_date_in_draft — "Ticket 4417. Customer asks where an order is and says it was" (not run)
       matches /^(?!.*(Monday|Tuesday|Wednesday|Thursday|Friday|tomorrow)).*$/
       contains "NOTE"
 1 prompt — 0 failed

$ npx typeglish build drafter.tg
 built .typeglish/dist/drafter.txt ← drafter.tg (b141f62d0612, full)
coverage: 1/7 is honest. One deterministic case pins the two properties a regex can hold, the absent weekday and the present NOTE marker; the sentence caps and the refund ban need either a judge or a live run.

Two things about the $EXAMPLE block are worth stating plainly, because both of them cost a revision here.

The first is that the fields are one line each. A two-part output demonstrated across four lines does not compile, and the message is exact: $EXAMPLE late_delivery's "- good::" needs its text on the same line. So the example can carry the content of a good draft but not its layout, which is why the layout lives in the fenced template and the contains "NOTE" assert exists at all.

The second is what reaches the model. A demonstrated bad response invites imitation, so - bad:: is held out of the compiled prompt entirely. Here that matters more than usual: the bad example is a fluent, in-domain support reply that promises Thursday and a refund, and it is exactly the text the two NEVER rules exist to prevent.

.typeglish/dist/drafter.txt - the tail of the artifact
# Output
Write a draft and then a note, in this shape:

```
DRAFT
[the reply, addressed to the customer]
NOTE
[one sentence for the agent]
```

<example>
User: Ticket 4417. Customer asks where an order is and says it was due Tuesday.
Assistant: DRAFT / Thanks for chasing this up, and sorry for the wait. Your order is with the courier now, and I have asked them to confirm the next attempt. I will come straight back to you. / NOTE / Courier holds the parcel, no scan since Monday.
</example>
The fence survives verbatim, the good response ships as a User: / Assistant: pair, and the word Thursday appears nowhere in the 1,045 bytes the model receives.

§7Common questions

How do I write a system prompt for an agent-assist tool that drafts replies?
Write every rule to You, the model, and put the human agent in a prose block as a fact rather than a rule. Then name the two halves of the output separately, because a rule about length applies to the whole output unless the object or the unit says otherwise. The spec in this post is nine rules plus a fenced output template, and it checks at 0 error, 0 warning, 0 info and scores A (97/100). The two rules doing the real work are the ones a reviewer under handle-time pressure would not catch: NEVER state a delivery date in a draft, and NEVER offer a refund in a draft.
Should an agent-assist prompt address the human agent or the model?
The model. A rule written about the agent is a rule about a third party, and the checker will never compare it with your rules about yourself. You MUST NOT send a reply to a customer beside The agent MUST send a reply to a customer is 0 error, 0 warning, 0 info; change the second subject to You and the identical pair is 2 blocking logic/contradiction errors. Facts about what the human does belong in a prose block, where they are context and not policy.
How do I give two different length limits to two parts of one reply?
Name the object or the unit, not the section. Two rules reading You MUST write at most 5 sentences and You MUST write at most 1 sentence, sitting in two different XML sections, are 4 blocking errors across logic/quantifier-strength and logic/action-count, because a section is not a scope for a bound. Rewriting them as keep a draft to at most 5 sentences and keep a note to at most 1 sentence is silent, and so is write at most 5 sentences in every draft beside write at most 1 sentence in every note.
Can a system prompt stop an AI assistant from sending a message to the customer?
No. A prompt can state the prohibition; only the host removes the capability. What the checker adds is proof that the prohibition names something real. Importing send_reply and forbidding it in prose is a structure/unused-import warning, writing NEVER call @[send_reply] is 0 error, 0 warning, 0 info, and pointing at a tool nobody imported is a blocking structure/undefined-tool-inline. The pointer lowers to the bare name in the artifact, so the model reads NEVER call send_reply and no schema comes with it.
Field note

The reason agent assist gets built first is that a human reviews everything, and the reason it goes wrong is the same sentence read at a different speed. Review is a control when the reviewer is deciding; it is a rubber stamp when the reviewer is clearing a queue against an average handle time. So the rules that carry weight in a drafter prompt are not the ones about tone, which a rushed agent will notice and fix, but the ones about facts a rushed agent will accept because the draft sounds like it knows: a delivery date, a refund, a policy exception, a named colleague. Those are the lines to write as NEVER, to pin with a matches assert, and to keep out of the - good:: example. Everything else in the file is style, and style survives an editor.

∿ washed up Aug 22, 2026 ∿