Bake-off: the rule in the prompt vs. the rule in the tool description
Every function-calling guide tells you to write a good tool description, and a good tool description is where policy goes to hide. One credit cap, two homes, and a compiler that grades exactly one of them.
TL;DR A policy written inside a $TOOL description still reaches the model, but it leaves the graded plane entirely: the identical cap scores enforceability 75 as a constraint and 50 inside a description, a description holding both NEVER apply a credit above 50 pounds and ALWAYS apply a credit above 50 pounds is 0 error, 0 warning, 0 info under check --strict, and appending the rule that breaks the cap is 2 blocking logic/contradiction errors with the build refused in one file and a higher grade in the other.
The advice is everywhere and it is not wrong: write tool descriptions carefully, because that text is how the model decides whether to call the thing. So the description grows. It starts as applies a goodwill credit to a customer account and by the third sprint it is carrying the approval limit, the two exceptions, and a sentence about what to say if the customer pushes back. It reads well. It sits next to the code it governs. Both teams are happy. Here is what it costs.
§1The contenders
One policy, one tool, one difference. A utility support agent can apply goodwill credits up to fifty pounds. Contender A writes the cap as a constraint. Contender B writes it into the description of the tool that does the applying, which is the version most agent frameworks nudge you toward.
<$CONFIG> $CONFIG modality chat </$CONFIG> $SERVICE crm - base:: https://api.ardent.example.com - headers:: - Authorization:: Bearer @{env.CRM_KEY} $TOOL issue_credit - description:: Applies a goodwill credit to a customer account. - input:: - account_id:: string - amount:: number - request:: POST crm /credits # Role You are a support agent for Ardent Energy. # Constraints - NEVER apply a credit above 50 pounds. - WHEN you confirm a billing error THEN call @[issue_credit].
<$CONFIG> $CONFIG modality chat </$CONFIG> $SERVICE crm - base:: https://api.ardent.example.com - headers:: - Authorization:: Bearer @{env.CRM_KEY} $TOOL issue_credit - description:: Applies a goodwill credit to a customer account. NEVER apply a credit above 50 pounds. - input:: - account_id:: string - amount:: number - request:: POST crm /credits # Role You are a support agent for Ardent Energy. # Constraints - WHEN you confirm a billing error THEN call @[issue_credit].
$ typeglish check A.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info A.tg - B (82/100) proven errors: none tiers: base+z3 planes runtime 93 (what the model reads) · hygiene 50 (source only) facets enforceability 75 x.21 · hardness 100 x.12 · directness 95 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12 style 100 x.08 · security 100 x.08 $ typeglish check B.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info B.tg - C (76/100) proven errors: none tiers: base+z3 planes runtime 85 (what the model reads) · hygiene 50 (source only) facets enforceability 50 x.21 · hardness 100 x.12 · directness 90 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12 style 100 x.08 · security 100 x.08
--strict, and six points apart. One facet does almost all of it: enforceability 75 against 50 at weight x.21, because A has two rules the checker can read as rules and B has one. The NEVER in the description is not a weak rule to the scorer. It is not a rule.Round 1 to A on points, and if that were the whole story it would be a thin post. B has an argument that a scorer cannot see: the description travels with the tool, so the cap sits at the exact moment the model is choosing an amount. That is a real claim about attention, and we will come back to it in §4 with the bundle. First, the round that decides the thing.
§2Round 2: the edit that arrives eight months later
Nobody breaks a prompt on the day they write it. The credit cap breaks when the retention team wins an argument about long outages and somebody appends one line to # Constraints, in the same house style as everything above it, in a pull request that touches nothing else.
<$CONFIG> $CONFIG modality chat </$CONFIG> $SERVICE crm - base:: https://api.ardent.example.com - headers:: - Authorization:: Bearer @{env.CRM_KEY} $TOOL issue_credit - description:: Applies a goodwill credit to a customer account. - input:: - account_id:: string - amount:: number - request:: POST crm /credits # Role You are a support agent for Ardent Energy. # Constraints - NEVER apply a credit above 50 pounds. - WHEN you confirm a billing error THEN call @[issue_credit]. - MUST apply a credit above 50 pounds.
<$CONFIG> $CONFIG modality chat </$CONFIG> $SERVICE crm - base:: https://api.ardent.example.com - headers:: - Authorization:: Bearer @{env.CRM_KEY} $TOOL issue_credit - description:: Applies a goodwill credit to a customer account. NEVER apply a credit above 50 pounds. - input:: - account_id:: string - amount:: number - request:: POST crm /credits # Role You are a support agent for Ardent Energy. # Constraints - WHEN you confirm a billing error THEN call @[issue_credit]. - MUST apply a credit above 50 pounds.
$ typeglish check A3.tg A3.tg:21:1 error logic/contradiction Conflicts with line 19. Logical conflict - "apply a credit above 50 pounds" is both required and forbidden. Keep one, or scope the two rules so they cannot both apply (IF <condition> THEN ...). A3.tg:23:1 error logic/contradiction Conflicts with line 17. Logical conflict - "apply a credit above 50 pounds" is both required and forbidden. ✗ 1 file - 2 error, 0 warning, 0 info # exit 1 A3.tg - F (67/100) proven errors - grade capped at F tiers: base+z3 ✖ L17 logic/contradiction: Conflicts with line 19. ✖ L19 logic/contradiction: Conflicts with line 17. $ typeglish build A3.tg typeglish build: A3.tg refused - nothing written $ typeglish check B3.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info # exit 0 B3.tg - B (82/100) proven errors: none tiers: base+z3 facets enforceability 75 x.21 · hardness 100 x.12 · directness 95 x.08 consistency 100 x.17 · structure 100 x.12 · annotation 0 x.12 $ typeglish build B3.tg ✓ built .typeglish/dist/B3.txt ← B3.tg (17564816aca3, full)
B (82/100) to F (67/100) and refuses to build. B goes from C (76/100) to B (82/100) and ships, because the appended MUST is a well formed rule and it lifted enforceability from 50 to 75. The edit that broke the policy improved the score.That is the round, and it is not close. The two files encode the same policy and behave in opposite directions under the same edit, which is the only property of a prompt spec that matters over a year: not whether it is right today, but whether the next person is told when they break it.
One honest edge before the win is banked, because it is the same edge every proof in this language has. A wins that round only because the appended line was unscoped. Write it the way a careful person would, MUST apply a credit of at least 75 pounds after a long outage, or ALWAYS apply a credit above 50 pounds after a long outage, and A is 0 error, 0 warning, 0 info too. The guarantee is that an unscoped policy reversal blocks the build, not that every policy question is settled by the compiler. You said only, your agent escalated anyway walks the same trap from the other side: the fix text that clears the error is sometimes just a scope phrase over a policy nobody agrees on.
A description cannot contradict anything, which is exactly why it feels safe to put policy there.
§3Round 3: where the boundary actually runs
So how far does the blindness go? It is worth pinning precisely, because the useful mental model is not descriptions are unchecked. Here is a description that argues with itself inside a single line.
$TOOL issue_credit - description:: Applies a credit. NEVER apply a credit above 50 pounds. ALWAYS apply a credit above 50 pounds. - input:: - account_id:: string - request:: GET https://api.ardent.example.com/credits # Role You are a support agent for Ardent Energy. # Constraints - WHEN you confirm a billing error THEN call @[issue_credit].
NEVER lives in MUST lives in check score build prose prose 2 error logic/contradiction F (67/100) refused description prose 0 error, 0 warning, 0 info B (82/100) ok prose description 0 error, 0 warning, 0 info B (82/100) ok description description 0 error, 0 warning, 0 info C (76/100) ok $ typeglish check D1.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info
B (82/100), because in each of them exactly one modal rule is standing in the graded plane. Row 4 has none, so it is the lowest grade in the table and the only file where the contradiction is entirely inside one sentence.Now the other half of the boundary, which is the part that makes the rule of thumb usable: the description is read. Word-level findings walk straight in.
# inside a - description:: field V1.tg:2:37 info prompt/hedging Hedging - "Try to" turns this instruction into a suggestion the model may skip. Delete the hedge, or commit to a modal (MUST / NEVER / SHOULD). V1.tg:2:60 info prompt/vague Vague - "reasonable" names a judgment call without the criterion to judge by, so nothing can check it. V1.tg:2:91 info prompt/declared-later `you` is declared on line 7, after its first use here ✓ 1 file - 0 error, 0 warning, 3 info # the same sentence as a constraint (2 further definite/doer findings elided) V2.tg:5:3 info prompt/hedging Hedging - "Try to" turns this instruction into a suggestion V2.tg:5:26 info prompt/vague Vague - "reasonable" names a judgment call without the criterion ✓ 1 file - 0 error, 0 warning, 4 info # a credential literal, inside a - description:: S2.tg:2:74 error security/leaked-secret Leaked secret - this looks like a real AWS access key id. Remove it and rotate the credential; reference it as a {variable} instead. ✗ 1 file - 1 error, 0 warning, 0 info
prompt/declared-later even reads it in document order. So the description is scanned. It is just never parsed into a statement.That is the line, and it is a clean one. Anything that reads a word reaches into a description: hedges, vague adjectives, secrets. Anything that needs the line to be a rule does not: deontic force, the counting layer, the contradiction prover, and every point of enforceability. Even the character-set pass is on the far side of it, since a typographic dash that is 2 structure/em-dash warnings in prose is silent inside a description.
§4Round 4: the bundle, where B's argument turns out to be true
B has been losing on a technicality that deserves testing, because a scorer measuring the prompt plane is not the same as the model not seeing the text. Build both with the agent bundle and look at what actually goes on the wire.
$ typeglish build A.tg --bundle && cat .typeglish/dist/A.txt ✓ built .typeglish/dist/A.txt ← A.tg (5032279727ba, full) # Role You are a support agent for Ardent Energy. # Constraints - NEVER apply a credit above 50 pounds. - WHEN you confirm a billing error THEN call issue_credit. $ typeglish build B.tg --bundle && cat .typeglish/dist/B.txt ✓ built .typeglish/dist/B.txt ← B.tg (fe3eed5dbf1a, full) # Role You are a support agent for Ardent Energy. # Constraints - WHEN you confirm a billing error THEN call issue_credit. $ cat .typeglish/dist/B.agent.json { "config": { "modality": "chat" }, "tools": [ { "name": "issue_credit", "description": "Applies a goodwill credit to a customer account. NEVER apply a credit above 50 pounds.", "params": [ { "name": "account_id", "required": true, "type": "string" }, { "name": "amount", "required": true, "type": "number" } ], "binding": { "method": "POST", "url": "https://api.ardent.example.com/credits", "service": "crm" } } ], "inputs": [], "defines": [] }
So the trade is honest and worth naming: putting a policy in a description buys proximity to the call site and pays for it with every proof, every facet, and the build gate. Buying proximity is a defensible thing to want. Paying for it silently is not, which is what makes this worth a post rather than a lint rule.
There is a third plausible home, and it is the one that looks strongest of the three, so check it before you lean on it. The type grammar takes refinements, so the cap can be written into the parameter itself.
# source - input:: - account_id:: string - amount:: number between 1 and 50 $ typeglish check shipped.tg --strict ✓ 1 file - 0 error, 0 warning, 0 info $ typeglish build shipped.tg --bundle # the amount param, as recorded { "name": "amount", "required": true, "type": "number" }
number. Keep it in the source as documentation if you want it there, but the cap on the wire is not a cap, and nothing downstream is going to validate against it.§5What ships
The verdict splits the two jobs the description had been doing. The description keeps the one it is uniquely good at, which is saying what the tool is and when it applies. The policy goes into the graded plane, where a later reversal is a red build. And the cap gets a destination, because a limit with nowhere to send the overflow is how an agent invents one.
<$CONFIG> $CONFIG modality chat </$CONFIG> $SERVICE crm - base:: https://api.ardent.example.com - headers:: - Authorization:: Bearer @{env.CRM_KEY} $TOOL issue_credit - description:: Applies a goodwill credit to an account. Use it once a billing error is confirmed. - input:: - account_id:: string - amount:: number between 1 and 50 - request:: POST crm /credits # Role @@ role: the doer every rule below refers to You are a support agent for Ardent Energy. # Constraints @@ cap: the policy lives in the graded plane, so a later edit above it blocks the build - NEVER apply a credit above 50 pounds. @@ use: the tool is available once defined and discoverable only once a rule points at it - WHEN you confirm a billing error THEN call @[issue_credit]. @@ referral: the cap needs a destination, or the agent invents one - IF a customer asks for more than 50 pounds THEN transfer to a team leader. $TEST cap - input:: My bill was wrong for four months, I want 200 pounds back. - expect:: - matches /^(?!.*200 pounds).*$/ - the reply offers to pass the request to a team leader
0 error, 0 warning, 0 info under check --strict, A (93/100) with runtime 90 and annotation 100, and typeglish test shipped.tg --dry reports coverage: 1/1 rules exercised behind a negative regex rather than a negative contains. The description says what the tool is and when to reach for it, and nothing else.Two notes from building it. The description originally read use it after you confirm a billing error, which produced prompt/declared-later at info, because the description mentions you above the line that declares who you are, and the scan runs in document order. Rewording to once a billing error is confirmed cleared it and took the file from A (91/100) to A (93/100). And the referral rule is not decoration: a cap with no stated alternative is a rule the model can only obey by refusing, which is the shape how to write what your agent says when the lookup fails is about.
The one-line rule of thumb, if you keep one thing: descriptions describe, prompts decide. If a sentence in a description contains a modal, it is in the wrong file.
§6Common questions
- Should tool instructions go in the tool description or the system prompt?
- Put what the tool is in the description and what the agent may do with it in the prompt. The description is the right place for the tool's purpose and the situation that calls for it, because it travels attached to the tool. A policy is not a purpose, and once it moves into the description it leaves the graded plane: the identical
NEVER apply a credit above 50 poundsscoresenforceability 75as a constraint and50inside a description, and appending aMUSTthat breaks it is 2 blockinglogic/contradictionerrors with the build refused in the first file and0 error, 0 warning, 0 infoin the second. - Does TypeGlish check the text inside a tool description?
- It scans the words and never parses the statements. Word-level findings reach in:
prompt/hedgingon Try to,prompt/vagueon reasonable,security/leaked-secreton a credential literal, andprompt/declared-laterif the description saysyouabove the line that declares who you are. Anything that needs the line to BE a rule does not: a description containing bothNEVER apply a credit above 50 poundsandALWAYS apply a credit above 50 poundsis0 error, 0 warning, 0 infoundercheckandcheck --strict, and a typographic dash that is 2structure/em-dashwarnings in prose is silent inside a description. - Does a rule in a tool description still reach the model?
- Yes, which is why this is a real trade rather than a mistake.
typeglish build --bundlewrites the agent bundle beside the artifact, and the description arrives verbatim on thetoolsarray while the prompt text carries no trace of it. So the policy does ship, attached to the tool it governs, at the point the model decides to call it. What it does not do is participate in any proof, appear in any score facet, or block a build. - Can I enforce a numeric cap in the tool parameter type instead?
- You can write it and the compiler accepts it, but check what survives the build before relying on it. A parameter declared
amount:: number between 1 and 50checks clean, and the bundle records that parameter astype numberwith the refinement gone, so the cap is not on the wire for a runtime to validate against. Keep the refinement in the source as documentation if you like, put the policy in the prompt where a later contradiction blocks the build, and pin it with a$TESTassert.
The two planes in this bake-off are the same split your system prompt has a compile time draws, with one wrinkle worth carrying: a $TOOL body is neither purely compile-time nor purely runtime. Its scaffolding strips, its schema is emitted as machine-plane JSON, and its description is prose the model reads without any of the analysis prose gets. That is the only region of a .tg file where text reaches the model and no rule analysis reaches the text.