← Tidelines/Best practices

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.

by TypeGlish team10 min read#best-practices
Same rule. Two homes.

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.

A.tg - the cap as a rule✓ B (82/100)
<$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].
B.tg - the cap in the description✓ C (76/100)
<$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].
round 1 - typeglish check --strict and score
$ 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
Both clean, both silent under --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.

A3.tg - A, plus one appended rule✗ blocked
<$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.
B3.tg - B, plus the same appended rule✓ compiles
<$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.
round 2 - the same edit, two files
$ 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)
Read the two grades in the right order. A goes from 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.

D1.tg - a description at war with itself✓ compiles
$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].
one policy, four placements - typeglish check
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
Row 1 is the only one the prover reads, and it needs both halves in the prose. Rows 2 and 3 are symmetric at 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.

the same words, two placements
# 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
Hedging, vagueness and a leaked credential all fire inside the description, and 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.

tg build --bundle - A.txt, B.txt and B.agent.json
$ 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": []
}
B's cap is not lost. It is on the wire, verbatim, attached to the tool it governs, which is precisely where B said it belonged. Two different surfaces, one model, and exactly one of them is graded.

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.

the refinement, and what survives the build
# 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" }
The refinement checks clean and does not reach the bundle: the parameter is recorded as a bare 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.

shipped.tg - the spec✓ A (93/100)
<$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 pounds scores enforceability 75 as a constraint and 50 inside a description, and appending a MUST that breaks it is 2 blocking logic/contradiction errors with the build refused in the first file and 0 error, 0 warning, 0 info in 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/hedging on Try to, prompt/vague on reasonable, security/leaked-secret on a credential literal, and prompt/declared-later if the description says you above the line that declares who you are. Anything that needs the line to BE a rule does not: a description containing 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 and check --strict, and a typographic dash that is 2 structure/em-dash warnings 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 --bundle writes the agent bundle beside the artifact, and the description arrives verbatim on the tools array 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 50 checks clean, and the bundle records that parameter as type number with 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 $TEST assert.
Field note

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.

∿ washed up Aug 23, 2026 ∿