← Tidelines/Guides

How to write what your agent says when the lookup fails

Every agent prompt has a rule for calling the tool. Most have no rule for the call coming back empty, and the model still has to say something, so it says the most plausible thing it can compose. That is where the invented depot delay comes from.

by TypeGlish team8 min read#guides
The call came back empty. Now what?

TL;DR Make the tool outcome a typed input rather than something the model infers ($REQUIRE variable lookup: one of found, missing, offline), branch on it with a $SWITCH ON block so a forgotten failure arm is a blocking structure/non-exhaustive-switch error naming the member, and add one NEVER rule for the cause the tool did not return. The same decision written as prose IF / ELSE IF over the same typed variable leaves a member unhandled at 0 error, 0 warning, 0 info under both check and check --strict.

A customer asks where their order is. The tracking service is having a bad afternoon and returns a 503. The agent replies: Your order is currently in transit and there appears to be a slight delay at the depot. You should receive it by tomorrow. None of that came from anywhere. There is no depot in your data model, nothing said tomorrow, and now a customer has a date in writing.

This is not a hallucination in the interesting sense. It is a prompt that described one world and then asked the model to operate in a different one.

§1The rule everybody writes describes one outcome

Here is a working order-tracking agent, cut to three rules. It has a tool, it has a trigger for the tool, it has a length bound and a bit of tone. It is a perfectly ordinary prompt and I have read something close to it a hundred times.

tracking.tg - the happy path, and only the happy path✓ compiles
<$CONFIG>
  $IMPORT tool get_order_status
</$CONFIG>

# Role
You are an order-tracking agent for Arden Home.

# Instructions
- WHEN a customer asks about a delivery THEN call @[get_order_status] and read the result back.
- MUST keep every reply to at most 3 sentences.
- SHOULD apologize if something has gone wrong.
The third rule is the failure path, in the sense that somebody wrote it after an incident. It names no outcome, no fact and no action, so it functions as a mood.
tg check and score tracking.tg - output
$ typeglish check tracking.tg
 1 file - 0 error, 0 warning, 0 info

$ typeglish score tracking.tg
tracking.tg - B (82/100)  proven errors: none  tiers: base+z3
  planes  runtime 92 (what the model reads) · hygiene 50 (source only)
  facets  enforceability 83 x.21 · hardness 83 x.12 · directness 97 x.08
          consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene)
          style 100 x.08 · security 100 x.08
A clean file at B (82/100). There is no diagnostic for a missing world, and there should not be one: the checker cannot know which outcomes your tool has. That knowledge is the thing you are about to write down.

Read the trigger rule as a specification and count the worlds it covers. Call the tool and read the result back is complete when a result exists. When the call 503s, or returns an empty array because the order number was mistyped, or hangs until the runtime gives up, the rule has no consequent and the conversation still needs a turn. The model fills the gap with the highest-probability support sentence available, which is a sentence about a depot.

An agent invents a reason when you asked it a question and gave it nothing true to answer with.

§2Name the outcomes, and let the host own them

The first move is to stop asking the model to work out what happened. Your runtime already knows: it made the call, it holds the status code and the body, it knows whether it retried. That is a fact, and a fact belongs in an input rather than in an inference.

Three members carry almost every support tool I have seen: the call answered, the call answered with nothing that matches, the call did not answer. Name them in the customer's vocabulary rather than HTTP's, because the arm bodies are prose the model reads.

the outcome, typed
$REQUIRE variable lookup: one of found, missing, offline
Keep the member keys to single English words. A key that looks like an identifier gets read as one: - no_match:: is reported as structure/undeclared-tool, Tool "no_match" is used but never imported, which is a confusing five minutes if you were not expecting it.

Now the decision. There are two planes it can live on and they are not interchangeable, so this is the part worth being deliberate about. Write it as prose and the model weighs it at runtime; write it as a $SWITCH ON block and the compiler resolves it before the model sees anything. Here is the prose version, over the typed variable, with the offline member left out exactly the way a real prompt leaves it out.

tracking-prose.tg - a member missing, in prose✓ compiles
<$CONFIG>
  $IMPORT tool get_order_status
  $REQUIRE variable lookup: one of found, missing, offline
</$CONFIG>

# Role
You are an order-tracking agent for Arden Home.

# Instructions
- WHEN a customer asks about a delivery THEN call @[get_order_status].
- IF @{lookup} is equal to found THEN read the returned status back.
- ELSE IF @{lookup} is equal to missing THEN ask for an order number again.
tg check, --strict and score - tracking-prose.tg
$ typeglish check tracking-prose.tg
 1 file - 0 error, 0 warning, 0 info

$ typeglish check tracking-prose.tg --strict
 1 file - 0 error, 0 warning, 0 info

$ typeglish score tracking-prose.tg
tracking-prose.tg - C (76/100)  proven errors: none  tiers: base+z3
  facets  enforceability 50 x.21 · hardness 100 x.12 · directness 90 x.08
          consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 0 x.12 (hygiene)
The variable is declared with three members, two of them have arms, and the file is clean. enforceability 50 is the only trace of anything, and it is a comment on how the rules are phrased rather than on the hole. Nothing anywhere says offline is unhandled.

Move the identical decision onto the deterministic plane and the same omission is a blocking error that names the member out loud:

tg check tracking-noarm.tg - the same hole, in a $SWITCH
tracking-noarm.tg:13:1  error  structure/non-exhaustive-switch  @{lookup} can be offline, but no
  arm handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 1 file - 1 error, 0 warning, 0 info
This is the whole reason to spend a line declaring the domain. The compiler cannot know your tool fails; it can absolutely know you declared three outcomes and wrote two rules. A catch-all arm vs. one arm per member works through why you want that error rather than an otherwise row swallowing it.

§3Say the true thing, and bound what else can be said

Now write the arms, and be concrete in a specific way: each failure arm needs the true fact and the next action, and nothing else. Then add the rule almost nobody writes, which is the prohibition on the invented cause. Without it, say tracking is offline and invent a depot are both satisfiable at once, and only one of them is what you meant.

tracking-v2.tg - one arm per outcome, and one prohibition✓ A (92/100)
<$CONFIG>
  $IMPORT tool get_order_status
  $REQUIRE variable lookup: one of found, missing, offline
</$CONFIG>

# Role
@@ role: order tracking only
You are an order-tracking agent for Arden Home.

# Instructions
@@ lookup_first: the status is a fact from the tool, never a guess
- WHEN a customer asks about a delivery THEN call @[get_order_status].
$SWITCH ON @{lookup}
  - found::
    @@ read_back: the tool answered, so quote it and stop
    - MUST read the returned status back to the customer.
  - missing::
    @@ reread: a typo is the likeliest cause and the customer can fix it
    - MUST ask the customer for an order number again.
  - offline::
    @@ own_it: name the outage, own the follow-up, promise nothing else
    - MUST say that order tracking is offline.
    - MUST offer a callback.

# Constraints
@@ no_cause: a cause the tool did not return is a story
- NEVER state a cause for a late delivery.
@@ brevity: three sentences keeps a chat reply scannable
- MUST keep every reply to at most 3 sentences.
0 error, 0 warning, 0 info at A (92/100). The NEVER rule is the load-bearing one: it puts the invented cause in the same slot as every other rule about explaining a delivery, so a future edit asking the agent to explain the hold-up is a provable conflict rather than a difference of opinion about tone.

Then read what the model actually receives, which is the part of this technique that pays for the ceremony. The chain is compile-time, so each binding produces a prompt carrying one instruction set instead of a menu.

tg build --vars - two bindings, two prompts
$ typeglish build tracking-v2.tg --vars '{"lookup":"offline"}' && cat .typeglish/dist/tracking-v2.txt
 built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (a80fe4ac5d8e, full)
# Role
You are an order-tracking agent for Arden Home.

# Instructions
- WHEN a customer asks about a delivery THEN call get_order_status.
- MUST say that order tracking is offline.
- MUST offer a callback.

# Constraints
- NEVER state a cause for a late delivery.
- MUST keep every reply to at most 3 sentences.

$ typeglish build tracking-v2.tg --vars '{"lookup":"found"}' && cat .typeglish/dist/tracking-v2.txt
 built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (0b4880b58c82, full)
# Role
You are an order-tracking agent for Arden Home.

# Instructions
- WHEN a customer asks about a delivery THEN call get_order_status.
- MUST read the returned status back to the customer.
# Constraints
- NEVER state a cause for a late delivery.
- MUST keep every reply to at most 3 sentences.
Two artifacts, one instruction set each, and the annotations cost nothing because they never leave the source. The prohibition rides along in both, which is right: the found path is where a helpful embellishment is most tempting.

The build worth staring at is the third one. Leave the variable unbound and the template ships every arm as prose, which is the failure mode of this whole technique and the reason to bind it in the runtime rather than hoping.

tg build - unbound, the shape to avoid in production
$ typeglish build tracking-v2.tg && cat .typeglish/dist/tracking-v2.txt
 built .typeglish/dist/tracking-v2.txt ← tracking-v2.tg (1012ff9bf85d, full)
# Role
You are an order-tracking agent for Arden Home.

# Instructions
- WHEN a customer asks about a delivery THEN call get_order_status.
If lookup is found:
  - MUST read the returned status back to the customer.
If lookup is missing:
  - MUST ask the customer for an order number again.
If lookup is offline:
  - MUST say that order tracking is offline.
  - MUST offer a callback.

# Constraints
- NEVER state a cause for a late delivery.
- MUST keep every reply to at most 3 sentences.
Three arms, one model, and the model back in the business of deciding which world it is in from a tool result it may have misread. This is the same trap Once per what? names for cross-turn counts: the technique only pays if the host actually binds the value.

§4Pin the wording before somebody softens it

The last line of defence is a deterministic assert, because the prohibition in §3 is exactly the kind of rule that gets quietly relaxed when a QA scorecard says the agent sounded curt during an outage. A negative lookahead over the words your agent has no business using is cheap and it does not need a judge.

tracking-v3.tg - the assert, and test --dry
$TEST offline_invents_nothing
  - input:: Where is my order 88213?
  - expect::
    - matches /^(?!.*(depot|courier|tomorrow)).*$/
    - at most 3 sentences

$ typeglish check tracking-v3.tg
 1 file - 0 error, 0 warning, 0 info

$ typeglish test tracking-v3.tg --dry
 tracking-v3.tg  coverage: 0/6 rules exercised
  · offline_invents_nothing - "Where is my order 88213?" (not run)
       matches /^(?!.*(depot|courier|tomorrow)).*$/
       at most 3 sentences
 1 prompt - 0 failed
Read the coverage number honestly. --dry is fully offline, so the two ticks mean the asserts are well formed and nothing more, and 0/6 is the suite telling you this case is not attributed to any of the six rules. A live run needs ANTHROPIC_API_KEY, and the case is worth running against the offline build specifically, since that is the artifact where the invented sentence lives.

§5The four lines, in order

Compressed to something you can apply to a prompt you already have, in the order I would do it:

  • List the outcomes your tool really has. Not the HTTP codes, the outcomes a customer can tell apart. Three is usual: answered, answered with nothing, did not answer. If a fourth genuinely behaves differently for the customer (a partial result, a permission denial), it earns a member.
  • Declare it as a closed domain and make the host bind it. $REQUIRE variable lookup: one of found, missing, offline, single-word English keys. The detection is the runtime's job and always was; the prompt's job is the wording.
  • Write one arm per member in a $SWITCH ON block. Each arm gets the true fact and the next action. Resist the - otherwise:: row: on a fully covered domain it is a blocking dead arm anyway, and its whole purpose is to absorb the member you add next year without telling you.
  • Add the prohibition, and one assert. NEVER state a cause for a late delivery is what stops the depot. The matches assert is what stops the prohibition being softened by somebody who was not in the incident review.

Four lines of ceremony for the outcome, one for the prohibition, one test. What you get is a prompt where the failure path is a compiler obligation instead of an intention, and where the day the tracking service falls over your agent says the boring true thing. Boring is the goal. Nobody has ever escalated a ticket because the agent was insufficiently imaginative about a 503.

§6Common questions

What should my AI agent do when a tool call fails?
Say the specific true thing and stop. Three lines cover almost every failure a support agent meets: name what happened in the terms the customer cares about (tracking is offline, the order number does not match), do the one thing that moves it forward (offer a callback, ask for the number again), and never state a cause the tool did not return. The third line is the one everybody leaves out, and it is the one that stops an invented depot delay becoming a promise your team has to honour. Written as NEVER state a cause for a late delivery, it is a rule in the same slot as every other rule about that action, so a later edit asking the agent to explain the delay is a provable conflict rather than a tone change.
Why does my agent make up a reason when the API is down?
Because the prompt asked it a question it had to answer and gave it nothing true to answer with. A rule like WHEN a customer asks about a delivery THEN call get_order_status and read the result back describes exactly one world, the one where a result comes back. In every other world the model still has to produce a reply, and the most plausible-sounding reply is a generic logistics sentence. Nothing in check catches that: the happy-path-only file is 0 error, 0 warning, 0 info at B (82/100). The fix is not a longer instruction, it is naming the other outcomes so there is a rule to follow in each of them.
How do I make sure I have not forgotten an error case in a system prompt?
Type the outcome as a closed domain and branch on it with a $SWITCH ON block, and forgetting becomes a compile error rather than a discovery. With $REQUIRE variable lookup: one of found, missing, offline, a switch missing the third arm is structure/non-exhaustive-switch and the message names the member: @{lookup} can be offline, but no arm handles it. The same decision written as prose IF and ELSE IF over the same typed variable, with the same member unhandled, is 0 error, 0 warning, 0 info under both check and check --strict. Choosing the plane is choosing whether the omission is enforced.
Should the failure path live in the prompt or in the runtime?
The detection belongs to the runtime and the wording belongs to the prompt. Your host already knows whether the call returned a row, returned nothing, or timed out, so make it pass that in as a typed value rather than asking the model to infer it from a tool result it may have misread. Then the compiler resolves the branch at build time and the model is handed one instruction: the offline binding builds to a80fe4ac5d8e carrying only the offline rules, the found binding to 0b4880b58c82 carrying only the read-back rule. The unbound template build at 1012ff9bf85d is the shape to avoid in production, because it ships all three arms as prose and tells the model three things at once.
Field note

Getting the tool wired up correctly is the step before any of this, and it has its own set of ways to come loose: how to connect an agent tool to a real API covers $TOOL against $IMPORT tool, the - request:: binding, and the dangling references the checker blocks. A failure arm is only worth writing once you are sure the call is being made at all.

∿ washed up Aug 19, 2026 ∿