The typo that compiles is the one to worry about
An ablation over keystrokes rather than rules. Sixteen one-character edits to a clean care prompt, one per copy, and the split is ten blocking, three advisory, three completely silent. The report gets quieter as the risk goes up.
TL;DR Sixteen one-character edits to a clean A (92/100) agent prompt split three ways: ten refuse to compile, three warn and become errors under --strict, and three produce 0 error, 0 warning, 0 info with every facet unmoved. What decides the split is grammar rather than danger, because a keystroke that breaks the form of a declaration has no valid reading and a keystroke that leaves a grammatical sentence behind has one, so gate the quiet class on the build hash instead of the checker.
Every ablation on this blog has removed something a person wrote on purpose: a whole line, a numeric bound, a modal, the scope on a rule, a test. This one removes a character. It is the edit nobody plans and everybody makes: 6pm on a Thursday, one word changed in the file that runs the agent, no review, straight to the branch.
§1One clean file, sixteen keystrokes
The subject is a small care prompt with one of everything: a folded config block, a typed input, a named condition, a deterministic chain, four annotated rules, a literal zone and a test. Nothing exotic. It is roughly what a reviewed prompt looks like once somebody has moved the scaffolding out of the model's way.
<$CONFIG> $IMPORT tool get_account $REQUIRE variable account_id: string $REQUIRE variable tier: one of gold, silver, bronze $DEFINE priority AS @{tier} is equal to gold </$CONFIG> <role> You are a care agent for Larkspur Broadband. You do not have access to a customer's payment card. </role> <constraints> @@ brevity: three sentences keeps a chat reply scannable - You MUST keep every reply to at most 3 sentences. @@ account: the account number unlocks the record - You MUST confirm an account number @{account_id} back to a customer. @@ lookup: read the record, never recall it - You MUST look up an account with @[get_account]. @@ hours: quote the published window, never a guess - You MUST state the support hours as 9am - 5pm. $IF @{priority}: You MUST offer a callback within 2 hours. $ELSE: You MUST offer a callback within 24 hours. </constraints> <"escalation_card"> L1 -> L2 -> duty manager </"escalation_card"> $TEST callback - input:: My broadband is down again. - expect:: - at most 3 sentences
$ npx typeglish check care.tg
✓ 1 file — 0 error, 0 warning, 0 info
$ npx typeglish score care.tg
care.tg — A (92/100) proven errors: none tiers: base+z3+spell
planes runtime 98 (what the model reads) · hygiene 75 (source only)
facets enforceability 91 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 50 x.12 (hygiene) · style 100 x.08 · security 100 x.08
lever annotation 50/100 (up to +6 overall) — Put a "@@ why" note directly above each statement ("@@ name: why" also names it).
base+z3+spell tier line matters for later. All three tiers are looking at this file, and one of them has a finding that check will not print.Sixteen copies, one edit each. Every edit is a character dropped, added or replaced, and every one is something a real person does: a sigil that did not survive a paste, a hyphen where an underscore belongs, an autocorrected apostrophe, a dedent from a reformat, a digit.
sigil $IMPORT tool get_account -> IMPORT tool get_account
pointer_at number @{account_id} back -> number {account_id} back
pointer_dash @{account_id} -> @{account-id}
oneof_colon tier: one of gold, ... -> tier: one of: gold, ...
define_eq $DEFINE priority AS @{tier} ... -> $DEFINE priority = @{tier} ...
half_quote <"escalation_card"> -> <escalation_card">
switch_colon $IF @{priority}: -> $IF @{priority}
test_colon - input:: My broadband ... -> - input: My broadband ...
test_indent (2-space indent on - input::) -> (dedented to column 0)
member_typo is equal to gold -> is equal to glod
period at most 3 sentences. -> at most 3 sentences
curly a customer's payment card -> a customer’s payment card
endash 9am - 5pm -> 9am – 5pm
bound_digit at most 3 sentences. -> at most 8 sentences.
modal_case You MUST keep every reply -> You Must keep every reply
prose_typo for Larkspur Broadband. -> for Larkspur Broadbnad.
AS to =, and the dedent), because there is no one-character way to make those mistakes. Everything else is a single keystroke.§2The ten that refuse
Run the whole directory and the headline is 16 files, 15 error, 16 warning, 1 info. Split out the ten with a blocking error and the report reads like this.
$ npx typeglish check blocking blocking/define_eq.tg:5:3 error structure/define-binder The $DEFINE binder is AS, never "=". Replace this line with: " $DEFINE priority AS @{tier} is equal to gold". blocking/half_quote.tg:27:1 error structure/undeclared-tool Tool "escalation_card" is used but never imported (add it to a $IMPORT tool line). blocking/half_quote.tg:27:1 warn structure/missing-period Unterminated statement — end it with a period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract. blocking/half_quote.tg:27:1 warn structure/loose-content This content is outside any section — put it inside a <tag> (a heading ends where a tag or the next heading begins). blocking/half_quote.tg:27:1 info prompt/unregistered-doer A bare generic doer never enters the world model - instruction to the agent, or background about users? Use the imperative if the agent acts, or a definite party ("The user should ...") to register the doer. blocking/half_quote.tg:28:3 warn structure/missing-period Unterminated statement — end it with a period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract. blocking/half_quote.tg:28:1 warn structure/bad-indent Indentation mirrors section nesting: expected column 0 (0 levels deep), found 2. The indent unit is 2 spaces. blocking/half_quote.tg:28:3 warn structure/loose-content This content is outside any section — put it inside a <tag> (a heading ends where a tag or the next heading begins). blocking/half_quote.tg:29:1 error structure/stray-close-tag </"escalation_card"> has no matching <"escalation_card"> to close. blocking/member_typo.tg:5:3 error structure/impossible-value $DEFINE priority: @{tier} is never glod (it's one of gold, silver, bronze). blocking/oneof_colon.tg:4:3 error structure/bad-input-type Input "tier": "one of: gold, silver, bronze" is not a domain. The forms: boolean, number, integer, string, or "one of a, b". blocking/pointer_at.tg:3:3 warn structure/unused-import Required variable "account_id" is never used. blocking/pointer_at.tg:3:21 warn clarity/unused-variable $REQUIRE variable "account_id" is never used — no @{account_id} reference fills it. Remove it, or reference it in the prompt. blocking/pointer_at.tg:16:40 error structure/retired-brace-ref Bare braces are prose — they no longer reference. "account_id" is a declared name, so point at it: replace {account_id} with @{account_id}. blocking/pointer_dash.tg:3:3 warn structure/unused-import Required variable "account_id" is never used. blocking/pointer_dash.tg:3:21 warn clarity/unused-variable $REQUIRE variable "account_id" is never used — no @{account_id} reference fills it. Remove it, or reference it in the prompt. blocking/pointer_dash.tg:16:40 error structure/bad-pointer-name @{account-id} is not a name the compiler can read — a pointer name begins with a LETTER, then letters, digits, and underscore (a dot namespaces the runner's environment: @{env.KEY}). The renderer cannot read it either, so the model receives this line with the compile-time @ still on it. Rename the symbol — @{account_id} is the mechanical rewrite. blocking/sigil.tg:2:3 error structure/config-section-content Content inside <$CONFIG> never reaches the model — only $-commands ($IMPORT, $CONFIG, $TOOL, $DEFINE, $TEST) belong here. Move prompt content outside the section. blocking/sigil.tg:2:3 error structure/needs-sigil Commands must start with "$" — did you mean "$IMPORT"? blocking/sigil.tg:18:38 error structure/undefined-tool-inline @[get_account] — no $TOOL or $IMPORT tool named "get_account" in this file. Define it ("$TOOL get_account") or import it ("$IMPORT tool get_account") so the pointer has a target. blocking/switch_colon.tg:22:5 error logic/time-strength Conflicts with line 18. One bound per slot — "within 2 hours" already entails "within 24 hours"; the looser rule on "you offer a · callback" is dead weight. Keep one. blocking/switch_colon.tg:22:1 warn structure/bad-indent Indentation mirrors section nesting: expected column 2 (1 level deep), found 4. The indent unit is 2 spaces. blocking/switch_colon.tg:23:3 error structure/bad-switch $ELSE without a deterministic $IF above it at the same column — a chain reads $IF @{x} …: / $ELSE IF …: / $ELSE:. blocking/switch_colon.tg:24:5 error logic/time-strength Conflicts with line 16. One bound per slot — "within 2 hours" already entails "within 24 hours"; the looser rule on "you offer a · callback" is dead weight. Keep one. blocking/switch_colon.tg:24:1 warn structure/bad-indent Indentation mirrors section nesting: expected column 2 (1 level deep), found 4. The indent unit is 2 spaces. blocking/test_colon.tg:31:1 error structure/bad-test only "- input::" and "- expect::" fields belong in $TEST callback — "- input: My broadband is down again." is not one. blocking/test_colon.tg:32:3 warn structure/loose-content This content is outside any section — put it inside a <tag> (a heading ends where a tag or the next heading begins). blocking/test_indent.tg:31:1 error structure/bad-test $TEST callback needs an "- input:: …" line. blocking/test_indent.tg:34:5 warn structure/missing-period Unterminated statement — end it with a period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract. ✗ 10 files — 15 error, 13 warning, 1 info program: 10 independent files — no $IMPORT compositions
Two things in that report are worth more than the count. The first is blast radius. The sigil variant is one dollar sign, and it produces three errors in two places: the line itself, and a tool pointer sixteen lines further down that no longer has anything to point at.
<$CONFIG> IMPORT tool get_account </$CONFIG> <role> You are a care agent for Larkspur Broadband. </role> <constraints> - You MUST look up an account with @[get_account]. </constraints>
structure/needs-sigil on the line, structure/config-section-content because a line with no sigil is content and content is not allowed in a folded config block, and structure/undefined-tool-inline on the pointer that lost its declaration. Declaring a thing and pointing at it are one contract, so a break at either end is reported at both.The second is that half the class is the reference family: a pointer is the one construct in the language whose name has to survive a keystroke exactly, and the compiler treats a name it cannot read differently from a name that reads fine and resolves to nothing.
<$CONFIG> $REQUIRE variable account_id: string </$CONFIG> <role> You are a care agent for Larkspur Broadband. </role> <constraints> - You MUST confirm an account number {account_id} back to a customer. </constraints>
structure/retired-brace-ref, plus two warnings saying the declared input is now unused. Change the underscore to a hyphen instead and the code is structure/bad-pointer-name, whose message spells out the consequence the other one avoids: the renderer cannot read the name either, so the line would reach the model with its compile-time @ still attached.§3The three that only warn
Three of the sixteen are not errors. All three are lookalikes: a character that renders like the ASCII one and parses like nothing.
$ npx typeglish check warn warn/curly.tg:9:84 warn structure/curly-quote A curly apostrophe is a lookalike: the parsers read ASCII ', so possession and contractions written with it are invisible to the consistency checker. Straighten it. warn/endash.tg:20:44 warn structure/em-dash A typographic dash hides its break from the ASCII parsers, and models mirror the tic (the canonical AI-tell). Its plain form is the spaced hyphen: " - ". warn/period.tg:14:3 warn structure/missing-period Unterminated statement — end it with a period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract. ✓ 3 files — 0 error, 3 warning, 0 info program: 3 independent files — no $IMPORT compositions $ npx typeglish check warn --strict warn/curly.tg:9:84 error structure/curly-quote A curly apostrophe is a lookalike: the parsers read ASCII ', so possession and contractions written with it are invisible to the consistency checker. Straighten it. warn/endash.tg:20:44 error structure/em-dash A typographic dash hides its break from the ASCII parsers, and models mirror the tic (the canonical AI-tell). Its plain form is the spaced hyphen: " - ". warn/period.tg:14:3 error structure/missing-period Unterminated statement — end it with a period (or ! ?; a lead-in may end with ":"). Statement boundaries are a compile contract. ✗ 3 files — 3 error, 0 warning, 0 info program: 3 independent files — no $IMPORT compositions
B (88/100), four points off the baseline, and all three blocking once --strict is on. If you only take one CI change out of this post, take that flag: this is the class where the default severity is genuinely arguable and the escalation is free.Two of the three arrive without anybody typing them. A curly apostrophe is what a word processor, a ticketing system or a chat window gives you when you type a straight one, and a typographic dash is what half the writing tools in a marketing team produce from two hyphens. That is the whole argument for the severity: the character is not a mistake in the sense of being unintended, it is a mistake in the sense that a parser reading ASCII now sees a different sentence than the one on your screen.
What does not help here is the formatter. fmt joins wrapped fragments, splits compound rules and normalises whitespace, and it leaves every one of these alone.
$ npx typeglish fmt warn --check
✓ 3 files already formatted
§4The three that ship
Here is the part of the run that made it a post.
$ npx typeglish check silent ✓ 3 files — 0 error, 0 warning, 0 info program: 3 independent files — no $IMPORT compositions $ npx typeglish check silent --strict ✓ 3 files — 0 error, 0 warning, 0 info program: 3 independent files — no $IMPORT compositions $ npx typeglish score care.tg care.tg — A (92/100) proven errors: none tiers: base+z3+spell $ npx typeglish score silent/bound_digit.tg silent/bound_digit.tg — A (92/100) proven errors: none tiers: base+z3+spell $ npx typeglish score silent/modal_case.tg silent/modal_case.tg — A (92/100) proven errors: none tiers: base+z3+spell $ npx typeglish score silent/prose_typo.tg silent/prose_typo.tg — A (91/100) proven errors: none tiers: base+z3+spell
One of the three is correct and should be silent. MUST and Must are the same operator, because every operator in this language is case-insensitive, so modal_case is not a defect at all: the file means exactly what it meant, and the only trace is that the artifact carries the capitalisation you typed.
The other two are the interesting ones, and they share one property. Each leaves a grammatical English sentence behind.
<role> You are a care agent for Larkspur Broadband. </role> <constraints> - You MUST keep every reply too at most 3 sentences. </constraints>
0 error, 0 warning, 0 info at A (92/100). Not one of the sixteen originally, just the cheapest demonstration of the rule: too is a word, so no tier has anything to object to, and the bound is still parsed and still provable. The sentence is wrong in the way only a reader notices.bound_digit is the same shape with worse consequences: at most 3 sentences becomes at most 8 sentences, and 8 is exactly as well-formed a bound as 3. Every facet is identical, because there is nothing in the file for the new number to argue with. A checker that flagged this would be flagging the possibility that you changed your mind, which is most of what a prompt repository does.
And prose_typo is where the two commands come apart. A misspelling in a rule is not in the check report at all, and it is in the score ledger.
$ npx typeglish check silent/prose_typo.tg
✓ 1 file — 0 error, 0 warning, 0 info
$ npx typeglish score silent/prose_typo.tg
silent/prose_typo.tg — A (91/100) proven errors: none tiers: base+z3+spell
planes runtime 97 (what the model reads) · hygiene 75 (source only)
facets enforceability 91 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 50 x.12 (hygiene) · style 93 x.08 · security 100 x.08
lever annotation 50/100 (up to +6 overall) — Put a "@@ why" note directly above each statement ("@@ name: why" also names it).
L9 spelling/unknown-word −0.25 Unrecognized word — "Broadbnad" isn't in the dictionary. Fix the spelling, or
style, one row in the ledger, and no diagnostic. If your gate is check and nothing else, spelling is not in your pipeline at all. Two more surfaces we checked while we were there: the same misspelling inside an @@ annotation moves nothing, because annotations never compile, and a typo that lands on a real word moves nothing anywhere.The compiler defends the form of your prompt. Nothing defends the meaning of a sentence that is still a sentence.
Which is the whole asymmetry, and it is not a gap so much as the definition of the job. Ten of the sixteen edits produced text with no valid reading: a command with no sigil, a domain that is not a domain, a chain arm with no chain, a pointer name outside the grammar. There is exactly one thing to say about text like that, and the compiler says it, usually with the rewrite attached. The other three produced text with a valid reading that happens not to be yours, and telling those apart requires knowing what you meant.
§5A gate for the quiet three
The quiet class has one property you can gate on: all three change what the model reads. That is not true of most edits to a .tg file, where annotations, tests, config and scaffolding never reach the artifact at all, and it is what makes the build hash a sharper instrument here than the checker.
$ npx typeglish build care.tg silent/*.tg ✓ built .typeglish/dist/care.txt ← care.tg (82cbd42c61b8, full) ✓ built .typeglish/dist/silent/bound_digit.txt ← silent/bound_digit.tg (a69be5a420d9, full) ✓ built .typeglish/dist/silent/modal_case.txt ← silent/modal_case.tg (46832016db8f, full) ✓ built .typeglish/dist/silent/prose_typo.txt ← silent/prose_typo.tg (114f9cb464b7, full) ✓ built 4/4 $ diff dist/care.txt dist/silent/bound_digit.txt 6c6 < - You MUST keep every reply to at most 3 sentences. --- > - You MUST keep every reply to at most 8 sentences. $ diff dist/care.txt dist/silent/prose_typo.txt 2c2 < You are a care agent for Larkspur Broadband. You do not have access to a customer's payment card. --- > You are a care agent for Larkspur Broadbnad. You do not have access to a customer's payment card.
So the gate is nine lines: build the file, read the twelve-character hash out of the manifest, and compare it to the one somebody approved.
// A keystroke that changes what the model reads changes the artifact hash. Nothing else has to notice.
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const [, , file] = process.argv;
execFileSync('npx', ['typeglish', 'build', file], { stdio: 'pipe' });
const { builds } = JSON.parse(readFileSync('.typeglish/build-manifest.json', 'utf8'));
const built = builds.find((b) => b.source === file).artifactSha256.slice(0, 12);
const approved = readFileSync('approved.txt', 'utf8').trim();
console.log(built === approved ? `approved ${built}` : `CHANGED ${approved} -> ${built}`);
process.exit(built === approved ? 0 : 1);
artifactSha256 in .typeglish/build-manifest.json, so you can read them out of the manifest rather than scraping stdout.$ node hash-gate.mjs care.tg approved 82cbd42c61b8 exit 0 $ node hash-gate.mjs silent/bound_digit.tg CHANGED 82cbd42c61b8 -> a69be5a420d9 exit 1 $ node hash-gate.mjs silent/modal_case.tg CHANGED 82cbd42c61b8 -> 46832016db8f exit 1 $ node hash-gate.mjs silent/prose_typo.tg CHANGED 82cbd42c61b8 -> 114f9cb464b7 exit 1
Be clear-eyed about that trade before you wire it in. This gate has a false-positive rate of one hundred percent on intended edits, so it is only tolerable where the artifact changes rarely and the approval is cheap: one reviewer, one line in the pull request, the twelve characters and the diff. On a prompt that changes twice a day it is noise. On the file that runs your contact centre, which is the case this blog keeps writing about in the artifact you actually deployed, it is the difference between reading a diff and trusting one.
Two smaller habits from the same run, both free. Keep --strict on in CI, which converts the three lookalikes from a warning nobody reads into a failure. And run score alongside check rather than instead of it, because the spelling row lives in one and not the other, and a misspelled brand name in the role line is the single most embarrassing typo on this list.
§6Common questions
- Will TypeGlish catch a typo in my agent system prompt?
- Most of them, and it tells you which kind you have by how loud it is. Across sixteen one-character edits to a clean care prompt, ten were blocking errors, three were warnings that become errors under
--strict, and three produced0 error, 0 warning, 0 info. The dividing line is not severity, it is grammar: a keystroke that breaks the FORM of a declaration, a pointer, a domain, a block or a chain has no valid reading, so the compiler names it. A keystroke that leaves a grammatical English sentence behind has a valid reading, and a checker cannot tell a valid reading from the one you meant. - Why did my prompt still pass after I mistyped a number?
- Because a number typo is indistinguishable from a policy change. Changing
at most 3 sentencestoat most 8 sentencesleaves the file at0 error, 0 warning, 0 infoand atA (92/100), the identical grade, with every facet unmoved, because8is as well-formed a bound as3and nothing else in the file argues with it. The one thing that does move is the build: the artifact hash goes from82cbd42c61b8toa69be5a420d9, so a gate on the approved hash catches it and the checker never will. - Does
typeglish checkreport spelling mistakes in a prompt? - No, and
typeglish scoredoes. A misspelled word in a rule (custmerforcustomer) is0 error, 0 warning, 0 infoundercheck, and the same file scoresA (91/100)with aspelling/unknown-wordrow in the ledger worth -0.25. So if your CI only runscheck, you never see spelling at all. Two more surfaces are worth knowing: a misspelling inside an@@annotation is invisible to both, because annotations never compile, and a typo that lands on a real word (tooforto) draws nothing anywhere. - Should I run
typeglish checkwith--stricton an agent prompt? - Yes, for the lookalike class specifically. A curly apostrophe, a typographic dash and a missing period are all warnings by default and all three become blocking errors under
--strict, which matters because each of them hides something from a parser rather than breaking it: a curly apostrophe conceals a possession, a dash splices two statements onto one line, and a missing period leaves a statement boundary undeclared. Note that--strictchanges nothing about the quiet class. The three silent edits are still0 error, 0 warning, 0 infounder--strict.
Run this on your own file and the number that matters is not ten out of sixteen, it is which of your constructs are load-bearing. A prompt made of pointers, typed domains, tool declarations and chains has most of its surface defended by the grammar, because every one of those constructs is a name that has to match something else in the file. A prompt made of flat prose rules has almost none, and it is the same lesson as deleting a line at a time read one character down: a defect is visible exactly when the text participates in a reference. The practical version is short. Every construct you replace with plain English buys you a sentence the model reads more easily and costs you a keystroke the compiler can no longer catch, and that trade is worth making deliberately rather than by habit.