← Tidelines/Best practices

Both prompts were clean. The merge is not.

A re-org folds the billing desk into technical support, and two agent prompts that have each passed for a year become one file by Friday. Nine checks for that week, including the three conflicts nothing will report.

by TypeGlish team10 min read#best-practices
Two desks. One file.

TL;DR Concatenating two agent prompts that each check at 0 error, 0 warning, 0 info is 6 blocking errors and F (62/100); three more conflicts are reported nowhere at all (a second $REQUIRE variable that silently wins on declaration order, two personas that never bind a name, one policy written with two verbs); and the merge that works is routing rather than reconciliation, because the same two rule sets on two arms of $SWITCH ON @{intent} is 0 error where transcluding both unconditionally is 2.

The re-org is announced on a Monday. Billing and technical support are becoming one team called Customer Care, the two queues are collapsing into one, and somewhere in the third slide there is a line about a single agent experience. By Wednesday somebody has asked you how long it takes to merge two prompts, and the honest answer is that the merge is not the work. The merge takes ten seconds. Finding out what the merge did takes the week.

The two prompts below are the live ones. Each has been in production for about a year, each has survived a launch review, and each checks completely clean. That is the interesting starting condition, because it means every finding from here belongs to the merge and not to either desk.

§1Before you merge anything

prompts/billing.tg - live for a year✓ B (85/100)
<$CONFIG>
  $IMPORT tool get_bill
  $REQUIRE variable channel: one of voice, chat
</$CONFIG>

# Role
You are Rowan, a billing agent for Wraysbury Energy.

# Constraints
- You MUST keep every reply to at most 3 sentences.
- You MUST NOT discuss a network outage.
- You MUST refund at most 40 pounds without a manager code.
- You MUST call @[get_bill] before quoting a balance.
$IF @{channel} is equal to voice:
  You MUST read the account number back to the customer.
$ELSE:
  You MUST send the account number as a link.
Two channels, a desk refund cap, a three-sentence brevity bound, and a prohibition on outage talk because outages are the other desk's script.
prompts/technical.tg - live for a year✓ B (86/100)
<$CONFIG>
  $IMPORT tool get_line_status
  $REQUIRE variable channel: one of voice, chat, sms
</$CONFIG>

# Role
You are Casper, a technical support agent for Wraysbury Energy.

# Constraints
- You MUST keep every reply to at most 6 sentences.
- You MUST explain the cause of a network outage.
- You MUST call @[get_line_status] before booking an engineer.
- You MUST call the customer back within 2 hours.
$IF @{channel} is one of chat, sms:
  You MUST send the fault reference as text.
$ELSE:
  You MUST say the fault reference out loud.
Three channels, a six-sentence bound, a two-hour callback promise, and an obligation to explain outages. Every one of those differences is going to matter.

Check 1: baseline both files alone, and write the numbers down. This takes one command and it is the only way the rest of the week has a control group. Whatever the merged file reports, you want to be able to say with certainty that neither input contributed it.

tg check + tg score - the two live prompts
$ npx typeglish check prompts/billing.tg prompts/technical.tg
 2 files — 0 error, 0 warning, 0 info
# exit 0

$ npx typeglish score prompts/billing.tg prompts/technical.tg
prompts/billing.tg — B (85/100)  proven errors: none  tiers: base+z3
prompts/technical.tg — B (86/100)  proven errors: none  tiers: base+z3
Zero of everything, twice. Pin this in the ticket. It is the sentence you will want three days from now when somebody asks whether the billing prompt was always like this.

Check 2: check the directory, not the file. This is the check that changes the shape of the week, because it lets you see the merge before you commit to it. Copy the two files into a scratch directory, add a root that pulls them in with $IMPORT file and transcludes both, then point check at the directory: the program pass composes everything the imports join and reports cross-file findings at their true file and line, blocking exactly as local ones do.

tg check care-flat - a conflict reported across two files
$ npx typeglish check care-flat
care-flat/billing.tg:7:1  error  logic/contradiction  Conflicts with care-flat/technical.tg line 5. Logical conflict — "explain a network outage" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).
care-flat/technical.tg:7:1  error  logic/contradiction  Conflicts with care-flat/billing.tg line 5. Logical conflict — "explain a network outage" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...).

 3 files — 2 error, 0 warning, 0 info
program: 1 root — care-flat/care.tg (3 files)
# exit 1
Two rules that live in two files, in two repositories' worth of history, reported at the lines their authors would recognise. program: 1 root under the tick is the scope statement: this is what the compiler considers one prompt. Note that both files' checks pass individually, which is the same reason a freeze on a file is not a freeze on a prompt.

§2What the concatenation will tell you

Somebody is going to do it anyway, so it is worth knowing what falls out. Two files, one cat, one check.

tg check care.tg - the two prompts concatenated
$ cat prompts/billing.tg prompts/technical.tg > care.tg
$ npx typeglish check care.tg
care.tg:6:1   error  structure/duplicate-section  Conflicts with line 20. Duplicate section "role" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "role") is ambiguous.
care.tg:9:1   error  structure/duplicate-section  Conflicts with line 22. Duplicate section "constraints" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "constraints") is ambiguous.
care.tg:10:1  error  logic/quantifier-strength  Conflicts with line 23. One rule, two counts — "keep every reply to at most 3 sentences." already sits inside "keep every reply to at most 6 sentences." (per reply); the looser bound is dead weight. Keep the tighter line.
care.tg:23:1  error  structure/duplicate-section  Conflicts with line 5. Duplicate section "role" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "role") is ambiguous.
care.tg:26:1  error  structure/duplicate-section  Conflicts with line 7. Duplicate section "constraints" — 2 sections in one scope share this name. Give each a distinct name; the structure (and any reference to "constraints") is ambiguous.
care.tg:27:1  error  logic/quantifier-strength  Conflicts with line 8. One rule, two counts — "keep every reply to at most 3 sentences." already sits inside "keep every reply to at most 6 sentences." (per reply); the looser bound is dead weight. Keep the tighter line.

 1 file — 6 error, 0 warning, 0 info
# exit 1

$ npx typeglish build care.tg
typeglish build: care.tg refused — nothing written

$ npx typeglish score care.tg
care.tg — F (62/100)  proven errors — grade capped at F  tiers: base+z3
  planes  runtime 83 (what the model reads) · hygiene 0 (source only)
Zero and zero in, six errors and an F out, and the build refuses. Two findings, each reported from both sides.

Check 3: rename the sections before you paste them. Four of the six errors are the same one: two # Role headings and two # Constraints headings in one scope. It is a structural error rather than a style note because a section name is an address, and a duplicated address makes every reference to it ambiguous. The same family catches the version teams reach for next, which is renaming one of the pair by half.

tg check - the half-rename, and a colliding test id
# Technical and Technical rules, side by side
nearsec.tg:4:1  warn   structure/near-duplicate-section  Near-duplicate section names "technical" and "technical_rules" — read as two DISTINCT sections (references bind by exact tag). If one is a typo, consolidate; otherwise give each a clearly distinct name. (conflicts with line 5)

# both desks named their first test case wrong_bill
dup.tg:12:1  error  structure/bad-test  duplicate $TEST id "wrong_bill".
dup.tg:12:1  warn   clarity/duplicate  Duplicate rule — "$test wrong_bill" is required in 2 places (also line 9). State it once and reference it.

$ npx typeglish test dup.tg --dry
 dup.tg
  does not compile — structure/bad-test: duplicate $TEST id "wrong_bill".
 1 prompt — 1 failed
A duplicate test id is a blocking error, not a warning, and it takes the whole suite offline with it: test --dry reports 1 failed without running a case. Merge the section names and the test ids in the same pass, because they collide for the same reason.

Check 4: reconcile every bound into one line in one place. The other two errors are the brevity caps, and the message is worth reading closely: One rule, two counts, and the looser bound is named as the dead weight. Both desks were right for their own queue, so there is no mechanical answer here. Somebody has to decide the number, and the value of the diagnostic is that it will not let you ship both while you make up your mind. Do the same sweep by hand for every bound the checker cannot pair up across a merge: refund caps, callback windows, hold times, escalation thresholds.

§3The three it will not tell you

Six errors from a concatenation feels like a thorough report. It is not, and the gap is not a defect in the checker so much as three places where two prompts overlap in ways no prover can see. These three are the manual part of the week.

Check 5: reconcile the $REQUIRE variable domains by hand. Billing declares channel: one of voice, chat. Technical declares channel: one of voice, chat, sms. The merged file declares both, and there is no diagnostic for that at all. The last declaration wins, so the order in which you concatenated the two files decides which channels exist.

the same two lines, in two orders✗ 1 error, then 0
<$CONFIG>
  $REQUIRE variable channel: one of voice, chat
  $REQUIRE variable channel: one of voice, chat, sms
</$CONFIG>

# Role
You are a care agent for Wraysbury Energy.

# Constraints
- You MUST greet the customer by name.
$SWITCH ON @{channel}
  - voice:: Say the fault reference out loud.
  - chat:: Send the fault reference as text.
Wide declaration second: sms is a member of the domain, the switch does not cover it, and the file is 1 blocking error.
narrow-last.tg - the same file, two lines swapped✓ 0 error
<$CONFIG>
  $REQUIRE variable channel: one of voice, chat, sms
  $REQUIRE variable channel: one of voice, chat
</$CONFIG>

# Role
You are a care agent for Wraysbury Energy.

# Constraints
- You MUST greet the customer by name.
$SWITCH ON @{channel}
  - voice:: Say the fault reference out loud.
  - chat:: Send the fault reference as text.
Narrow declaration second: sms is gone from the domain, coverage is proven over two members, and the file is clean.
tg check - both files, then the clean one under --strict
$ npx typeglish check wide-last.tg narrow-last.tg
wide-last.tg:11:1  error  structure/non-exhaustive-switch  @{channel} can be sms, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 2 files — 1 error, 0 warning, 0 info

$ npx typeglish check narrow-last.tg --strict
 1 file — 0 error, 0 warning, 0 info
# exit 0
Same two lines, opposite verdicts, and the passing one passes under --strict too. The technical desk took SMS traffic; the merged prompt now has no SMS handling and nothing anywhere says so. Write the union out by hand, once, in the root, and delete both originals.

Check 6: do not expect the persona check to fire. Two agents with two names is exactly the kind of thing you would hope a merge report leads with. It does, but only if the names were written as possessions in the first place.

persona.tg - two personas, as both prompts actually wrote them✓ 0 error, 0 warning, 0 info
# Role
You are Rowan, a billing agent for Wraysbury Energy.

# Technical role
You are Casper, a technical support agent for Wraysbury Energy.

# Constraints
- You MUST keep every reply to at most 4 sentences.
Completely clean. The apposition is doing it: Rowan, a billing agent for Wraysbury Energy is a value, not a name, so nothing lands in the name slot and there is nothing for a single-valued slot to conflict over.
tg check - the same two personas as possessions
# Your name is Rowan. / Your name is Casper.
$ npx typeglish check persona-named.tg
persona-named.tg:2:1  error  logic/contradiction  Conflicts with line 5. Contradiction — single-valued "you.name" is assigned 2 different values ("rowan", "casper"). Keep one, or hold several with a list ("… is rowan and casper").
persona-named.tg:6:1  error  logic/contradiction  Conflicts with line 2. Contradiction — single-valued "you.name" is assigned 2 different values ("rowan", "casper"). Keep one, or hold several with a list ("… is rowan and casper").

 1 file — 2 error, 0 warning, 0 info
# exit 1
One line each, rewritten as Your name is Rowan. and Your name is Casper., and the merge report leads with the persona after all. Worth doing in both files the week before the merge rather than after it, for the reasons in your agent has two names.

Check 7: read both files for one policy written with two verbs. This is the dangerous one, because it is a genuine behaviour conflict about a customer-facing statement, and the concatenation reports nothing about it. Billing says MUST NOT discuss a network outage. Technical says MUST explain the cause of a network outage. Two policies, one topic, opposite directions.

outage-verbs.tg - the two rules, as written✓ 0 error, 0 warning, 0 info
# Role
You are a care agent for Wraysbury Energy.

# Constraints
- You MUST NOT discuss a network outage.
- You MUST explain the cause of a network outage.
Clean, and it builds.
outage-aligned.tg - one word changed in one of them✗ 2 error
# Role
You are a care agent for Wraysbury Energy.

# Constraints
- You MUST NOT explain a network outage.
- You MUST explain a network outage.
2 blocking errors: Logical conflict — "explain a network outage" is both required and forbidden. discuss and explain are separate keys in the verb ontology, so the first pair never met. Aligning the verbs across the two files before the merge is what turns this class of conflict into a build failure, and it is the same mechanic behind two rules with no words in common.

§4Route, do not reconcile

The instinct when a merge produces conflicts is to negotiate them: get both desks in a room, pick one outage policy, delete the other rule. Sometimes that is right. Usually it is not, because the two rules were never in conflict operationally. They applied to different conversations, and the thing that scoped them was the deployment, not the file. Put the scope in the file and both rules can stay.

Check 8: give each desk an arm, not a paragraph. Three files: the two desks' rules in their own files with distinct section names, and a root that routes on a typed input.

care/care.tg - the root✓ B (86/100)
<$CONFIG>
  $IMPORT file "billing.tg" as billing
  $IMPORT file "technical.tg" as technical
  $IMPORT tool get_bill, get_line_status
  $REQUIRE variable channel: one of voice, chat, sms
  $REQUIRE variable intent: one of billing, technical
</$CONFIG>

# Role
You are Rowan, a care agent for Wraysbury Energy.

# Care rules
@@ brevity: one bound in one place - 4 sentences is the reconciled figure both desks signed
- You MUST keep every reply to at most 4 sentences.
@@ channel: the reference is read aloud on voice and sent as text everywhere else
$IF @{channel} is equal to voice:
  You MUST say the reference number out loud.
$ELSE:
  You MUST send the reference number as text.

$SWITCH ON @{intent}
  - billing::
    @<billing.billing_rules>
  - technical::
    @<technical.technical_rules>

$TEST outage_on_billing
  - input:: My bill is wrong and my broadband is down.
  - expect::
    - at most 4 sentences
The reconciled bound and the shared channel behaviour live at the root, one copy each. The union of the two channel domains is written out once. Each desk keeps its own file, with its own section name, transcluded from its own arm.
care/billing.tg and care/technical.tg - the leaves✓ 0 error, 0 warning, 0 info
<$CONFIG>
  $IMPORT tool get_bill
</$CONFIG>

# Billing rules
@@ outage_scope: outage wording is the technical desk's to own
- You MUST NOT explain a network outage.
@@ cap: the desk refund limit finance signed off in March
- You MUST refund at most 40 pounds without a manager code.
@@ balance: never quote a balance from memory
- You MUST call @[get_bill] before quoting a balance.
The billing leaf, with the outage verb now aligned to the technical desk's so the pair is provable. Its sibling holds MUST explain a network outage under # Technical rules. Both leaves check clean alone, which is the point of keeping them separate: each desk still owns a file it can read.
tg check - the same three files, transcluded two ways
# both sections transcluded unconditionally at the root
$ npx typeglish check care-flat
 3 files — 2 error, 0 warning, 0 info
program: 1 root — care-flat/care.tg (3 files)

# the same two sections, one per arm of $SWITCH ON @{intent}
$ npx typeglish check care
 3 files — 0 error, 0 warning, 0 info
program: 1 root — care/care.tg (3 files)
# exit 0
A controlled pair: identical leaf files, one difference in the root. Two rules that are a proven contradiction when both can apply are 0 error when the compiler can prove only one of them ever ships. That is what a deterministic $SWITCH buys, and it is why the answer to a merge conflict is usually a variable rather than a deletion.

Check 9: hoist the machine plane, build the root, and cut over on the hash. Three practical notes for the day itself, each of which cost us a wrong turn.

Tool declarations do not travel with a transcluded section. A leaf that declares $IMPORT tool get_bill and then uses @[get_bill] is fine alone and is structure/undefined-tool-inline in the program, because the pointer lands in the root and the root never declared it. Declare every tool the program uses at the root as well.

tg build - the root, twice, then unbound
$ npx typeglish build care/care.tg --vars '{"channel":"voice","intent":"billing"}'
 built .typeglish/dist/care/care.txt ← care/care.tg (b285651b3825, full)
# Care rules
- You MUST keep every reply to at most 4 sentences.
You MUST say the reference number out loud.
- You MUST NOT explain a network outage.
- You MUST refund at most 40 pounds without a manager code.
- You MUST call get_bill before quoting a balance.

$ npx typeglish build care/care.tg --vars '{"channel":"chat","intent":"technical"}'
 built .typeglish/dist/care/care.txt ← care/care.tg (ba0c665ed71b, full)

$ npx typeglish build care/care.tg
 built .typeglish/dist/care/care.txt ← care/care.tg (a096b9b929e8, full)
# Care rules
- You MUST keep every reply to at most 4 sentences.
If channel is voice:
  You MUST say the reference number out loud.
Otherwise:
  You MUST send the reference number as text.

If intent is billing:
  - You MUST NOT explain a network outage.
  - You MUST refund at most 40 pounds without a manager code.
  - You MUST call get_bill before quoting a balance.
If intent is technical:
  - You MUST explain a network outage.
  - You MUST call get_line_status before booking an engineer.
  - You MUST call the customer back within 2 hours.
Two bound builds, two hashes, and each artifact contains exactly one desk. The third build is the one to gate on: with intent unbound the routing dissolves back into prose and the model receives both desks, including both sides of the outage policy, at a096b9b929e8 and exit 0. The routed merge is only as good as the value the host sends, so fail the deploy on a template build the way a missing input deserves.

Build the root, not the directory. build on a directory compiles everything that passes and names each refusal, which is the honest behaviour and not what you want here: the leaves are not standalone prompts, so they refuse on the root's input bag and you get ✗ built 1/3 — 2 refused with the one artifact you wanted sitting in the middle of it.

And keep the two old artifacts. The cutover argument you will be asked for is not that the new prompt is good, it is that nothing was lost, and the only form of that argument anybody can check in a meeting is three hashes and a diff of what reached the model.

tg test care/care.tg --dry - the denominator after a merge
$ npx typeglish test care/care.tg --dry
 care/care.tg  coverage: 0/3 rules exercised
  · outage_on_billing — "My bill is wrong and my broadband is down." (not run)
       at most 4 sentences
 1 prompt — 0 failed
0 failed is honest rather than reassuring, because --dry validates the suite offline without sending a case to a model, and 0/3 rules exercised is the number to argue about: the assert mirrors the brevity bound and still attributes to no rule. A merge doubles the rule count and inherits neither desk's test suite intact. Write the cases for the conversations that only exist after the merge, starting with the one above, where a customer arrives with a billing question and a fault in the same sentence.

None of the nine is expensive. Seven are one command, and the two that are not, reconciling the bounds and reconciling the variable domains, are decisions that were always going to need a human. What they add up to is a merge you can describe: two clean baselines, six findings from the concatenation, three found by reading, one routing variable, and three hashes.

§5Common questions

How do I merge two AI agent system prompts into one?
Not by concatenating them. Keep each desk's rules in its own file, add a root file that pulls them in with $IMPORT file and routes between them on a typed input, and check the directory rather than the file: typeglish check care/ runs the program pass and reports cross-file conflicts at their true file and line before a merged file exists. Two live prompts that each check at 0 error, 0 warning, 0 info concatenate to 6 blocking errors and F (62/100), and the routed version of the same content is 0 error, 0 warning, 0 info with program: 1 root, care/care.tg (3 files) printed under the tick.
What conflicts appear when two agent prompts are combined?
Four kinds, in descending order of how loudly they announce themselves. Structural collisions come first: duplicate section names, near-duplicate section names, and a duplicate $TEST id, which is a blocking structure/bad-test. Then reconcilable bounds: two reply-length caps in one file is logic/quantifier-strength, one rule with two counts, and the looser bound is named as dead weight. Then the conflicts nothing reports: a second $REQUIRE variable declaration of the same name silently wins on order, and two personas written as You are Rowan and You are Casper never bind a name slot at all. Last, the policy conflict written with two verbs, which no prover can fold together.
Why does my merged prompt still pass after I combined two conflicting rules?
Most often because the two teams used different verbs for one policy. MUST NOT discuss a network outage beside MUST explain a network outage is 0 error, 0 warning, 0 info, because discuss and explain are separate keys in the verb ontology. Rewrite either line so both use the same head and the pair is 2 blocking logic/contradiction errors reading explain a network outage is both required and forbidden. The pre-merge job is to align the verbs across the two files, not to trust a green check on the joined one.
Should one agent handle two queues, or should they stay separate?
Either works if the merged prompt routes rather than reconciles. Put each desk's rules in its own file and transclude them from two arms of $SWITCH ON @{intent}: the compiler resolves one arm, so the losing desk's rules never reach the model and two rules that provably conflict can both stay written down. The controlled pair is the evidence. Transcluding both sections unconditionally is 2 blocking logic/contradiction errors; the same three files with the two transclusions on two switch arms is 0 error. The condition is that the host actually binds intent, because an unbound build ships both desks as conditional prose and still exits 0.
Field note

The thing that makes a prompt merge feel harder than it is, is that both inputs are correct. Nobody wrote a bug. The billing desk was right that its agents should not speculate about outages, because they had no line data and a wrong guess about an outage generates a second contact. The technical desk was right that explaining the cause of an outage is most of what a technical call is for. Both rules were true statements about a queue, and the queue was the part nobody wrote down, because for a year the queue was the deployment. That is the actual merge task: recovering the scope that was implicit in having two prompts, and writing it into the one prompt as a variable. Everything else on this list is bookkeeping. It is also why the routed version is not a compromise, and why the three artifact hashes are worth more than the six-error report: the report tells you the two prompts disagreed, and the hashes tell you which conversation now gets which answer. Related reading if you are about to do this: the paragraph that lives in three prompts for the composition mechanics, and half your prompt arrives at runtime for what the host is contributing while you are looking at the file.

∿ washed up Aug 31, 2026 ∿