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.
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.
<$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)/
$ 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).
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.
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"
§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.
$ 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
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.
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.
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.
$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.
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
--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.
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
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.
$ 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"
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.
$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.
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.
$ 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
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.
// 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.
{
"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 }
}
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.
$ 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
$TOOLwith 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 isstructure/unused-toolat 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-paramtwice,structure/malformed-toolandstructure/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 undercheck --strict. - Why did my prompt still pass after I broke the tool's HTTP request?
- Because a
$TOOLwith 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 --bundlewrites the agent bundle beside the artifact, and the tool'sbindingkey is simply absent from it. Removing the- headers::rows is the same shape and worse in consequence, since the binding survives withheadersset 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. Runcheck --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 --bundlewrites<name>.agent.json, the five silent variants produce five distinct bundles, and.typeglish/build-manifest.jsonrecordssourceSha256andartifactSha256and nothing about the bundle at all.
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.