← Tidelines/Deep dives

Your tool's URL is proven. Its purpose is a warning.

An ablation over the machine plane instead of the rules. Sixteen rows pulled out of the top of a care prompt, one per copy, and the report comes back ordered almost exactly against what the model actually reads.

by TypeGlish team9 min read#deep-dives
Sixteen rows. Five silences.

TL;DR Removing one row from a $TOOL or $SERVICE block gives three outcomes and they are ordered against usefulness: eight removals are blocking errors at F (82/100) with the build refused, three are advisories the score prices between B (82/100) and B (85/100), and five are 0 error, 0 warning, 0 info under --strict with a single identical artifact hash across all of them, because the compiler proves the plumbing the model never sees and can only warn about the one field it reads.

Every ablation on this blog so far has pulled at the prose. A rule, a whole line, a numeric bound, the scope on a rule, every test, a single character. This one leaves all thirteen prose lines alone and takes the top of the file apart instead: the $SERVICE and the two $TOOL blocks, the region that gets skimmed in review because it looks like configuration rather than policy.

It is worth doing because that region is where a support agent stops being a chatbot. A returns bot that cannot read an order is a FAQ page with a cursor. So the question the ablation asks is narrow and practical: of the rows that make a tool a tool, which ones is the compiler actually holding you to?

§1One clean file, sixteen rows

The subject is a two-tool care prompt with a shared backend. One tool reads an order, one opens a case for a colleague, both request through a $SERVICE that holds the base URL and the credential once. Three annotated rules underneath, two test cases at the bottom. Nothing exotic, and nothing in it that a contact centre would not recognise.

care.tg - the baseline✓ A (95/100)
<$CONFIG>
  $CONFIG modality chat
</$CONFIG>

$SERVICE crm
  - base:: https://api.harlowhome.com
  - headers::
    - Authorization:: Bearer @{env.CRM_KEY}

$TOOL get_order
  - description:: Looks up one order by its reference. Call this before confirming anything about an order.
  - input::
    - order_ref:: string: the order reference the customer quoted.
  - request:: GET crm /orders/@{order_ref}

$TOOL open_case
  - description:: Opens a case for a human colleague. Call this when a contact needs a colleague.
  - input::
    - queue:: one of delivery, damage, billing: the desk the case belongs to.
    - summary:: string up to 200 characters: what the customer asked for, in their words.
  - request:: POST crm /cases

# Role
You are a care agent for Harlow Home, a furniture retailer.

# Constraints
@@ lookup: read the record, never recall it
- You MUST call @[get_order] before you confirm an order.
@@ handoff: a case is how a colleague picks the contact up
- WHEN you cannot resolve a contact THEN you MUST call @[open_case].
@@ brevity: three sentences keeps a chat reply scannable
- You MUST keep every reply to at most 3 sentences.

$TEST order_lookup
  - input:: Where is order HH-4471?
  - expect::
    - at most 3 sentences
$TEST no_guess
  - input:: What is the delivery date for HH-4471?
  - expect::
    - matches /^(?!.*guarantee)/
Twenty-two of the forty-one lines are machine plane: one service, two tools, seven field rows, three parameter rows. The credential is a pointer at the environment rather than a literal, which is the one thing in this region a prompt genuinely cannot get away with, as the API key post covers.
tg check + tg score care.tg - output
$ npx typeglish check care.tg
 1 file — 0 error, 0 warning, 0 info

$ npx typeglish score care.tg
care.tg — A (95/100)  proven errors: none  tiers: base+z3
  planes  runtime 97 (what the model reads) · hygiene 88 (source only)
  facets  enforceability 90 x.21 · hardness 100 x.12 · directness 97 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 75 x.12 (hygiene) · style 100 x.08 · security 100 x.08
  lever   annotation 75/100 (up to +3 overall) — Put a "@@ why" note directly above each statement ("@@ name: why" also names it).
Note structure 100 and hygiene 88. Both of those numbers are about to do the work, because the structure facet is where a malformed tool block gets charged and hygiene is the plane it sits on.

Sixteen copies, one row edited in each. Fifteen are deletions and one is a single-character change, and every one is something that happens: a row lost to a paste, a service renamed in the API and not in the prompt, an enum relaxed to get a demo working, a description somebody meant to come back to.

rows/ - the sixteen edits
base_url      $SERVICE crm: delete "- base::"
service       delete the whole $SERVICE crm block
tool          delete the whole $TOOL get_order block
empty_tool    delete all four of get_order's field rows, keep the header
input_block   delete "- input::" and the order_ref row under it
param_row     delete the order_ref row, keep "- input::"
colon         "- description::"  ->  "- description:"
pointer       call @[get_order]  ->  call get_order
desc_get      delete get_order's "- description::" row
desc_both     delete both tools' "- description::" rows
orphan        delete the rule that points at @[get_order]
request       delete get_order's "- request::" row
headers       delete "- headers::" and the Authorization row
enum          queue:: one of delivery, damage, billing  ->  queue:: string
type          delete the type off the queue parameter
desc_text     replace get_order's description with the word "get_order"
Deliberately not sixteen ways to write nonsense. Each one leaves a file a reviewer would read past, which is the property that makes an ablation worth running rather than a syntax quiz.

§2The eight that refuse

Run the directory and the headline is 16 files, 11 error, 5 warning, 0 info. Eight of the sixteen carry a blocking error, and the whole report for those eight is short enough to read in one go.

tg check rows - the eight blocking variants✗ 11 error
$ npx typeglish check rows
rows/base_url.tg:5:1   error  structure/service-no-base  $SERVICE crm has no base — add a base field beneath it, like: "- base:: https://api.example.com".
rows/base_url.tg:13:3  error  structure/dangling-service  Request references $SERVICE "crm", which is not defined.
rows/base_url.tg:20:3  error  structure/dangling-service  Request references $SERVICE "crm", which is not defined.
rows/colon.tg:10:1     warn   structure/tool-no-description  $TOOL get_order has no description — the model has nothing to decide WHEN to call it.
rows/colon.tg:11:3     error  structure/malformed-tool  Tool fields use the definition marker "::" with a leading bullet, never a single colon. Replace this line with: "- description:: Looks up one order by its reference. Call this before confirming anything about an order.".
rows/empty_tool.tg:10:1   error  structure/empty-tool  $TOOL get_order has no body — indent its fields (description::, input::, request::) beneath it.
rows/input_block.tg:12:3  error  structure/dangling-binding-param  @{order_ref} in the request isn't a declared parameter — declare it under input:, or use @{env.NAME} for environment values.
rows/param_row.tg:13:3    error  structure/dangling-binding-param  @{order_ref} in the request isn't a declared parameter — declare it under input:, or use @{env.NAME} for environment values.
rows/pointer.tg:28:17     error  structure/bare-tool-ref  "get_order" is a tool, but this mention is bare prose — the compiler cannot bind it. Point it with @[get_order] (a checked reference), or quote it ("get_order") to speak the name as plain text.
rows/service.tg:10:3      error  structure/dangling-service  Request references $SERVICE "crm", which is not defined.
rows/service.tg:17:3      error  structure/dangling-service  Request references $SERVICE "crm", which is not defined.
rows/tool.tg:23:17        error  structure/undefined-tool-inline  @[get_order] — no $TOOL or $IMPORT tool named "get_order" in this file. Define it ("$TOOL get_order") or import it ("$IMPORT tool get_order") so the pointer has a target.

 16 files — 11 error, 5 warning, 0 info
program: 16 independent files — no $IMPORT compositions
Eight distinct codes for eight edits, seven of them never seen on this blog before, and every message names the row. Six of the eight carry the rewrite, which is what makes the class cheap: you paste the suggestion back rather than debugging anything.

Two things worth pulling out of that report. The first is blast radius: taking one row off the $SERVICE is three errors in three places, because a service with no base is not a broken service, it is not a service, so both tools requesting through it come back dangling. One deleted line, two tools disowned.

The second is that the single-colon typo is an error and a warning. The row did not become a malformed description; it stopped being a field at all, so get_order also has no description, and the file reports both facts. That pairing is the tell for this whole family: the compiler is reading these rows as a schema, and a schema row either parses or does not exist.

The scores put the eight in one bucket and say nothing else about them.

tg score + tg build - the eight, and one of them in full
rows/base_url.tg     — F (82/100)  proven errors — grade capped at F
rows/service.tg      — F (82/100)  proven errors — grade capped at F
rows/tool.tg         — F (82/100)  proven errors — grade capped at F
rows/empty_tool.tg   — F (82/100)  proven errors — grade capped at F
rows/input_block.tg  — F (82/100)  proven errors — grade capped at F
rows/param_row.tg    — F (82/100)  proven errors — grade capped at F
rows/colon.tg        — F (82/100)  proven errors — grade capped at F
rows/pointer.tg      — F (82/100)  proven errors — grade capped at F

$ npx typeglish build rows/param_row.tg
typeglish build: rows/param_row.tg refused — nothing written
rows/param_row.tg:13:3  error  structure/dangling-binding-param  @{order_ref} in the request isn't a declared parameter — declare it under input:, or use @{env.NAME} for environment values.
The same eighty-two points, eight times, because the cap is the mechanism and the rules underneath never changed. The grade is not the instrument here, the refusal is. Nothing gets written, so nothing gets deployed.

There is one honest way to summarise all eight: every blocking removal broke a reference. A pointer with no target (tool, input_block, param_row), a target with no name the compiler can bind (pointer, colon, empty_tool), or a base that two requests need in order to resolve (base_url, service). That is the same boundary the line-deletion ablation found in the prose plane, arrived at from the other end of the file, and it predicts the rest of this post exactly.

§3The three the score argues about

Three removals produce no error and are not silent either. Two of them are the description going missing, and the third is the rule that reaches the tool going missing.

desc_get.tg - the description row deleted✗ B (85/100)
$SERVICE crm
  - base:: https://api.harlowhome.com

$TOOL get_order
  - input::
    - order_ref:: string: the order reference the customer quoted.
  - request:: GET crm /orders/@{order_ref}

# Role
You are a care agent for Harlow Home, a furniture retailer.

# Constraints
@@ lookup: read the record, never recall it
- You MUST call @[get_order] before you confirm an order.
The tool still has a name, a typed parameter and a live HTTP binding, and a rule tells the agent when to reach for it. The only thing missing is the sentence a model would read to decide.
tg check + tg score - the three advisories
rows/desc_get.tg:10:1   warn  structure/tool-no-description  $TOOL get_order has no description — the model has nothing to decide WHEN to call it.
rows/desc_both.tg:10:1  warn  structure/tool-no-description  $TOOL get_order has no description — the model has nothing to decide WHEN to call it.
rows/desc_both.tg:15:1  warn  structure/tool-no-description  $TOOL open_case has no description — the model has nothing to decide WHEN to call it.
rows/orphan.tg:10:1     warn  structure/unused-tool  Defined tool "get_order" is never used in the prompt — the model cannot discover a tool no rule mentions. Reference it in a rule (e.g. "call get_order when ...") or delete the definition.

rows/desc_get.tg  — B (85/100)  proven errors: none
rows/desc_both.tg — B (82/100)  proven errors: none
rows/orphan.tg    — B (83/100)  proven errors: none

$ npx typeglish check rows/desc_get.tg rows/desc_both.tg rows/orphan.tg --strict
 3 files — 4 error, 0 warning, 0 info
Ten points of grade for one missing sentence, thirteen for two, and all four warnings become blocking errors under --strict. This is the one part of the tool block where the score is a real instrument and --strict is a real gate.

The facets are where it gets strange, and the strangeness is the point of the post.

tg score rows/desc_get.tg - which plane pays
rows/desc_get.tg — B (85/100)  proven errors: none  tiers: base+z3
  planes  runtime 97 (what the model reads) · hygiene 50 (source only)
  facets  enforceability 90 x.21 · hardness 100 x.12 · directness 97 x.08 · consistency 100 x.17 · structure 25 x.12 (hygiene) · annotation 75 x.12 (hygiene) · style 100 x.08 · security 100 x.08
  lever   structure 25/100 (up to +9 overall) — Fix the ledger rows with structure/ codes; most carry a one-click fix.
  L10  structure/tool-no-description −1  $TOOL get_order has no description — the model has nothing to decide WHEN to c
The structure facet goes 100 to 25 and hygiene goes 88 to 50, while runtime 97 (what the model reads) does not move at all. A tool description is the one field in the file whose entire job is to be read by the model, and it is charged to the plane labelled source only. It is not a bug in the scorer: the description does not appear in the .txt artifact, it travels in the bundle, and the runtime plane scores the artifact.

Which is exactly where the last five go.

§4The five that ship

Five removals produce nothing. No error, no warning, no info, unchanged under --strict, and A (95/100) on all five, the same grade as the file they were cut from.

tg check + tg score - the five silent variants✓ exit 0
$ npx typeglish check rows/request.tg rows/headers.tg rows/enum.tg rows/type.tg rows/desc_text.tg
 5 files — 0 error, 0 warning, 0 info

$ npx typeglish check rows/request.tg rows/headers.tg rows/enum.tg rows/type.tg rows/desc_text.tg --strict
 5 files — 0 error, 0 warning, 0 info
# exit 0

rows/request.tg   — A (95/100)   # no HTTP binding at all
rows/headers.tg   — A (95/100)   # no Authorization header
rows/enum.tg      — A (95/100)   # queue is now an open string
rows/type.tg      — A (95/100)   # queue has no type at all
rows/desc_text.tg — A (95/100)   # description is the word "get_order"
Note the pair at the top and the pair at the bottom. Deleting the description row costs ten points; leaving the row in place and making its content worthless costs nothing, because the check counts a field and the score counts a ledger row, and neither reads a sentence for meaning.

The request case is legal by design rather than by oversight. A $TOOL with no - request:: row is a tool the host runtime binds, which is the same thing $IMPORT tool declares in one line, so the compiler has nothing to complain about. The consequence is still that the endpoint you wrote yesterday is gone and the agent will happily go on calling the name.

desc_text.tg - the description nobody would call a defect✓ A (95/100)
$SERVICE crm
  - base:: https://api.harlowhome.com

$TOOL get_order
  - description:: get_order
  - input::
    - order_ref:: string: the order reference the customer quoted.
  - request:: GET crm /orders/@{order_ref}

# Role
You are a care agent for Harlow Home, a furniture retailer.

# Constraints
@@ lookup: read the record, never recall it
- You MUST call @[get_order] before you confirm an order.
Clean, and it builds. Whatever a tool description is for, this file has satisfied every check in the language on the subject.

Now build all six and the reason the five are silent stops being a theory. The prompt the model receives never mentioned any of this.

tg build --bundle - one artifact hash, five bundles
$ npx typeglish build care.tg --bundle
 built .typeglish/dist/care.txt ← care.tg (147635dc4a62, full)
# Role
You are a care agent for Harlow Home, a furniture retailer.

# Constraints
- You MUST call get_order before you confirm an order.
- WHEN you cannot resolve a contact THEN you MUST call open_case.
- You MUST keep every reply to at most 3 sentences.

# the same command over the baseline and the five silent variants
care.tg        artifact 147635dc4a62   bundle 7321078ea349
request.tg     artifact 147635dc4a62   bundle 950e212e6075
headers.tg     artifact 147635dc4a62   bundle 5b4625358fe3
enum.tg        artifact 147635dc4a62   bundle 7ff5b23e1f6e
type.tg        artifact 147635dc4a62   bundle 7ff5b23e1f6e
desc_text.tg   artifact 147635dc4a62   bundle 17aee5a8f465
One artifact hash, six times. The document the model reads carries two bare tool names and no URL, no header, no parameter and no description, so every row in this section is invisible to it. Five different bundles, and enum and type collide on one hash because an untyped parameter and a string parameter are the same schema.

The bundle is the second artifact and the one that carries everything this ablation touched. Reading the three fragments beside each other is the whole finding in nine lines.

care.agent.json - the same tool, three ways
// baseline: get_order
"binding": { "method": "GET", "url": "https://api.harlowhome.com/orders/@{order_ref}",
             "headers": [ { "name": "Authorization", "value": "Bearer @{env.CRM_KEY}" } ],
             "service": "crm", "line": 13 }

// headers.tg: the request survives, unauthenticated
"binding": { "method": "GET", "url": "https://api.harlowhome.com/orders/@{order_ref}",
             "headers": [], "service": "crm", "line": 11 }

// request.tg: the binding key is not there. get_order's keys, in order:
[ "name", "description", "params", "line" ]

// enum.tg: open_case's queue parameter, before and after
{ "name": "queue", "required": true, "type": "string",
  "enum": [ "delivery", "damage", "billing" ], "description": "the desk the case belongs to." }
{ "name": "queue", "required": true, "type": "string", "description": "the desk the case belongs to." }
"headers": [] is the one to sit with. The tool still resolves, still builds, still exits 0, and every call it makes now goes out without a credential. There is no diagnostic anywhere in the language for that, because a service with no headers is a completely reasonable service.

Which leaves the gate, and the gate cannot be the artifact hash. Every previous post here has recommended pinning artifactSha256, and it remains the right thing to pin for the prose plane, but a tool block barely reaches it. The manifest is explicit about what it covers.

.typeglish/build-manifest.json - what is pinned
{
  "source": "care.tg",
  "sourceSha256": "aca7eb0d68708eaf72b12aa1752c2afd6bc30531c2003c2eeda8062bd42fa7e0",
  "artifact": ".typeglish/dist/care.txt",
  "artifactSha256": "147635dc4a6208d8ecfdb15d8d19316f415b88b7839966cd23639e28a11b8056",
  "vars": null,
  "typeglish": "0.9.0",
  "checkMode": "full",
  "report": { "ok": true, "counts": { "error": 0, "warn": 0, "info": 0 }, "strict": false }
}
A hash of the file and a hash of the artifact, and nothing about care.agent.json. The source hash does move on all sixteen edits, which is worth something, but it moves on every intended edit too and is a re-approval trigger rather than a finding.

So the gate for this region of the file is three lines of CI, in this order. Run check --strict, which is the only thing in the toolchain that reaches a missing description and turns all four advisories into exit 1. Run build --bundle and hash the .agent.json yourself, committing the hash beside the artifact hash, because that is the one number that separates the five silent variants. And then read the bundle in review rather than the source, for the same reason you read the artifact rather than the file: it is the only place the two halves of a tool, the schema and the sentence, appear together in the form the runtime will use them.

The offline suite cannot help, and it is worth being precise about why.

tg test care.tg --dry - the denominator
$ npx typeglish test care.tg --dry
 care.tg  coverage: 0/3 rules exercised
  · order_lookup — "Where is order HH-4471?" (not run)
       at most 3 sentences
  · no_guess — "What is the delivery date for HH-4471?" (not run)
       matches /^(?!.*guarantee)/
 1 prompt — 0 failed
0/3 rules exercised is honest rather than reassuring. The expectations available to a $TEST are about text: contains, matches, a sentence count. There is no assert that observes a tool call, so neither case can pin the two rules whose entire content is call this tool, and no offline suite will ever notice that get_order lost its endpoint.

§5Common questions

Why does my agent not call the tool I gave it?
Because defining a tool makes it available, not discoverable, and the two things that make it discoverable are the weakest-checked rows in the file. The model sees a tool's name and its description, and nothing else from the block: no base URL, no headers, no parameter list. A $TOOL with no description at all is a warning, structure/tool-no-description, which reads the model has nothing to decide WHEN to call it. A description whose text is useless is nothing at all: replacing the whole sentence with the tool's own name is 0 error, 0 warning, 0 info at A (95/100), the same grade as the file it was cut from. And a tool no rule points at is structure/unused-tool at B (83/100). Fix it in the prompt with a rule that names the tool through @[name], then write the description for the decision rather than the endpoint.
Does TypeGlish check my $TOOL definitions?
Thoroughly, and almost entirely on the wiring. Sixteen one-row removals from a clean two-tool prompt split eight blocking, three advisory and five silent. The blocking eight are structure/service-no-base, structure/dangling-service, structure/undefined-tool-inline, structure/empty-tool, structure/dangling-binding-param twice, structure/malformed-tool and structure/bare-tool-ref, and every one of them is a broken reference: a name with no target, or a target with no name. All eight land on F (82/100) with the build refused. What is not proven is meaning: the description's content, the parameter's type, the enum on that type, the HTTP method and URL, and the Authorization header can each be removed or emptied with no diagnostic under check --strict.
Why did my prompt still pass after I broke the tool's HTTP request?
Because a $TOOL with no - request:: row is a legal declaration of a tool the host runtime binds, so deleting the row does not break anything the compiler can see. It is 0 error, 0 warning, 0 info at A (95/100), and the artifact is byte-identical, because the prompt the model reads carries the bare tool name and never carried the URL. The loss shows up in one place only: build --bundle writes the agent bundle beside the artifact, and the tool's binding key is simply absent from it. Removing the - headers:: rows is the same shape and worse in consequence, since the binding survives with headers set to an empty list and the request goes out unauthenticated.
How do I gate a change to a tool schema in CI?
Not on the artifact hash, because a tool block has almost no effect on it. Six variants of one prompt, including one with no request binding, one with no credential header, one with no enum and one with a meaningless description, all build to 147635dc4a62. Run check --strict, which turns the three advisories into blocking errors and is the only gate that reaches a missing description. Then hash the bundle yourself: build --bundle writes <name>.agent.json, the five silent variants produce five distinct bundles, and .typeglish/build-manifest.json records sourceSha256 and artifactSha256 and nothing about the bundle at all.
Field note

The ranking is not a gap in the compiler, and reading it as one leads to the wrong fix. Eight of these sixteen rows participate in a reference, so their removal makes a provably false claim about the file and the prover says so. The other eight are content: a URL is a fact about somebody else's server, a type is a promise about what a caller will send, and a description is a judgement about when an action is appropriate. None of those is checkable from inside the file, and a compiler that guessed at them would be guessing loudly and often. What the ablation actually changes is where you put your attention in review. The rows that look most technical, the base and the method and the pointer, will fail loudly on their own and need almost none of your time. The two rows that look like documentation, the description and the enum, are the ones a reviewer has to read as policy, because they are what the model reasons over and nothing behind them will object. Which is the same conclusion the bake-off between the prompt and the description reached from the other direction, with one addition: a description is not a weak place to put a rule, it is the right place to put a purpose, and the rule that fires it still belongs in the Constraints section where the checker can see it.

∿ washed up Sep 6, 2026 ∿