Module 6: The Deterministic Shell

The structured action

Overview

Lesson 2 established that the model proposes and the system disposes, and drew the box —the deterministic shell— that interposes itself between the proposal and the effect. This lesson opens that box at its input and asks the question that was left pending: if the model is going to propose an action, in what format does it propose it? The answer defines whether everything else is possible. A model can "propose" a refund in three very different ways: writing free text ("I think we should give this order's money back"), generating code for someone to run (db.execute("UPDATE ...")), or returning a structured command —an object with a name and typed fields: {action: "refund", order_id: "A-1001", amount: 50.00}—. Only the third form lets a deterministic layer validate and dispatch the proposal with guarantees. The other two are, each in its own way, an open door.

The structured action is that third form: the model returns a validatable proposal —a command from a known menu, with the fields that command requires—, and a deterministic dispatcher dispatches it. What isn't a known command from the menu, or is missing fields, or is free text, can't be dispatched: it's rejected at the channel, before even reaching the business-rule validation. In this lesson you'll see, executed, how of six model proposals, three are dispatchable commands and three are rejected at the channel —the free text, the invented action, the incomplete command—.

Connection with the module. This lesson is the format of the proposal, the shell's input. Lesson 4 will say what commands the model can propose (the capabilities menu); lesson 5, against which rules they're validated (the policy). Here we treat something more basic and prior: that the proposal be a dispatchable command. The boundary with module 4 is fine and must be drawn carefully: M4 validated the content of the output (is the JSON well-formed?, does it have a forbidden claim?); here we don't validate the content, but that the output be an action the system knows how to dispatch —a command from the menu, with its fields—. An M4 guardrail says "this is valid JSON"; the M6 action channel says "this is a command I can execute". And the boundary with AI Engineering: how to get the model to return structured actions reliably —tool use, function calling, structured outputs— is AI Eng; here we treat why the structured format is the shell's only safe input.

An analogy: the order on the ticket versus the waiter who shouts to the kitchen

In a professional kitchen, orders arrive on a ticket: a slip (or a digital ticket) with a fixed format —table, menu dish, quantity, notes—. The kitchen only prepares what comes on a well-filled ticket: a dish that exists on the menu, with the table noted and the quantity clear. If a ticket arrives with a dish that isn't on the menu, or without a table number, or illegible, the kitchen rejects it and asks for it to be corrected. The ticket is a structured format: known fields, validatable values, a closed menu of dishes. That's why the kitchen can process it with confidence and without guessing.

Imagine the alternative: a waiter who instead of a ticket shouts to the kitchen what he thinks the customer ordered —"something with chicken for the table at the back, I think without onion, and a drink!"—. The kitchen has to interpret: which chicken dish?, which table at the back?, does "I think" mean it should confirm? Each interpretation is an opportunity for error, and worse: if the waiter shouts "and charge them whatever!", the kitchen has no way of knowing what's valid to charge and what isn't. The shout is free text: it has no format, no fields, it can't be validated against a menu. The kitchen either guesses (and sometimes gets it wrong) or blindly executes what it heard (and sometimes it's a disaster).

The structured ticket is the model's structured action; the shout is the free text. When the LLM proposes {action: "refund", order_id: "A-1001", amount: 50.00}, it's a ticket: the shell sees the "dish" (refund) on its menu, sees the required fields (order_id, amount), and can process it or reject it with judgment. When the LLM proposes "I think we should give the money back", it's a shout: there's no command to dispatch, no fields to validate, and the only sensible option is to reject it at the kitchen door. The well-designed kitchen only accepts tickets.

Worked example: what's dispatchable and what's rejected at the channel

Let's execute the kitchen door. We define a menu of known commandsrefund, escalate_to_human, send_message— each with its required fields. The is_structured_action function does the channel check: is it a command (an object), not free text? does it name an action from the menu? does it carry all the required fields? That check is prior to the business-rule validation (which is lesson 5): here we only decide whether the proposal is dispatchable at all. We run a batch of six model proposals that mixes well-formed commands with free text, an invented action, and an incomplete command.

# Module 6, Lesson 3: the structured action.
# The LLM doesn't execute free code nor "do" things: it returns a PROPOSAL in a
# validatable format -a named command with typed fields- that a deterministic
# DISPATCHER dispatches. What isn't a known command can't be dispatched.
# No network, no API, no keys. Fixed data.

# Menu of known commands and their REQUIRED fields. The dispatcher only knows
# how to dispatch these; nothing else is "executable".
ACTION_MENU = {
    "refund":            {"order_id", "amount"},
    "escalate_to_human": {"reason"},
    "send_message":      {"text"},
}


def is_structured_action(proposal):
    # CHANNEL check (not business policy, which goes in lesson 5):
    # 1) it's a dict (a command), not free text;
    # 2) it names an action from the known menu;
    # 3) it carries all the required fields of that action.
    if not isinstance(proposal, dict):
        return (False, "not a command (free text)")
    action = proposal.get("action")
    if action not in ACTION_MENU:
        return (False, f"unknown action: {action!r}")
    required = ACTION_MENU[action]
    missing = required - proposal.keys()
    if missing:
        return (False, f"missing fields {sorted(missing)}")
    return (True, "dispatchable command")


# What the probabilistic core (SIMULATED) proposed in a batch. It mixes well-formed
# commands with free text, an invented action and an incomplete command.
PROPOSALS = [
    {"action": "refund", "order_id": "A-1001", "amount": 50.00},
    "I'll refund you, give me a moment",                # free text
    {"action": "delete_database"},                      # invented action
    {"action": "refund", "order_id": "A-1005"},         # missing 'amount'
    {"action": "escalate_to_human", "reason": "customer asks for a supervisor"},
    {"action": "send_message", "text": "Your order is on its way"},
]

dispatchable = rejected = 0
print(f"{'#':<3}{'proposal':<48}{'verdict':<14}reason")
print("-" * 92)
for i, p in enumerate(PROPOSALS):
    ok, reason = is_structured_action(p)
    verdict = "DISPATCHABLE" if ok else "REJECTED"
    if ok:
        dispatchable += 1
    else:
        rejected += 1
    shown = (p["action"] if isinstance(p, dict) and "action" in p
             else (repr(p)[:44] if isinstance(p, str) else str(p)))
    print(f"{i:<3}{shown:<48}{verdict:<14}{reason}")

print()
print(f"Dispatchable (valid command in the menu) : {dispatchable}/{len(PROPOSALS)}")
print(f"Rejected at the channel                  : {rejected}/{len(PROPOSALS)}")

What to expect. When you run the file, the output is exactly this:

#  proposal                                        verdict       reason
--------------------------------------------------------------------------------------------
0  refund                                          DISPATCHABLE  dispatchable command
1  "I'll refund you, give me a moment"             REJECTED      not a command (free text)
2  delete_database                                 REJECTED      unknown action: 'delete_database'
3  refund                                          REJECTED      missing fields ['amount']
4  escalate_to_human                               DISPATCHABLE  dispatchable command
5  send_message                                    DISPATCHABLE  dispatchable command

Dispatchable (valid command in the menu) : 3/6
Rejected at the channel                  : 3/6

Read the six cases, because each one teaches a part of the channel.

The three dispatchable ones (0, 4, 5) are complete commands from the menu. The refund with order_id and amount, the escalate_to_human with reason, the send_message with text: each names an action the system knows and carries the fields that action needs. They're well-filled tickets. Notice something important: being dispatchable doesn't mean they'll be executed. The dispatchable refund in case 0 still has to pass through the business-rule validation (does the order exist?, within the window?, amount within the limit?) which is lesson 5. The channel check only says "this is a command I can process"; the execution depends on the gates that follow. A well-filled ticket enters the kitchen; whether the dish gets prepared depends on whether there are ingredients.

Case 1 is free text, and it's rejected because there's nothing to dispatch. "I'll refund you, give me a moment" isn't a command: it's a phrase. It has no action, no fields, there's no way to turn it into an operation without interpreting it —and interpreting free text to execute actions is exactly the shout to the kitchen—. The shell doesn't try to guess what refund it wanted to do; it rejects it at the door. This is the case that matters most to understand: a model that "proposes" in prose isn't proposing an action, it's asking that someone deduce it, and that deduction is a surface of error (and of attack) we don't want.

Case 2 is an invented action, and it's rejected because it's not on the menu. delete_database isn't a command the system knows —it's not in ACTION_MENU—, so there's nowhere to dispatch it. Notice how powerful this is: even if the model hallucinates a destructive action, the channel rejects it by definition, because it only dispatches what's on the menu. This is a preview of lesson 4's bounded capabilities: a closed menu means the actions that aren't on it don't exist for the shell, however much the model proposes them.

Case 3 is a known but incomplete command, and it's rejected because it's missing a field. {action: "refund", order_id: "A-1005"} names an action from the menu (refund), but is missing amount —refund how much?—. A command without its required fields isn't dispatchable: the kitchen can't prepare a dish from the menu if the ticket doesn't say the quantity. Rejecting it at the channel is better than dispatching it with a default or guessed amount, because any invented value would be an amount the model didn't propose.

The count, three and three, is the channel's filter. Of six proposals, three passed the channel (they're dispatchable commands) and three were rejected before reaching the rule validation. That's the job of the structured format: turn "what the model said" into "a command the system can process or reject with judgment", and filter out at the door everything that isn't even a command. Without this filter, the free text and the invented actions would reach deeper layers where someone would have to interpret them —and that's where the disasters are born—.

Going deeper: why the structured format is the only safe input

The example showed what happens; it's worth understanding why the structured format is the condition that makes the whole module possible.

A structured command is validatable; free text and code aren't (safely). When the proposal is {action: "refund", order_id: "A-1001", amount: 50.00}, the shell can ask concrete and deterministic questions: is action on the menu?, is amount a number?, is amount <= MAX_REFUND? Each question has a hard answer. When the proposal is free text —"give them the money back"—, there are no fields to interrogate: to validate it, you first have to interpret it, and the interpretation itself is probabilistic (which order?, how much?). The structured format moves the uncertainty to a single place —the model filling the fields— and leaves the rest of the pipeline on deterministic ground. That's why the structured action is the boundary between the probabilistic core and the deterministic shell: it's where the uncertain proposal turns into data the certain code can handle.

The dispatcher is a table, not an interpreter. The right way to execute a structured action is a dispatcher: a table that maps the command's name to a function that handles it —{"refund": handle_refund, "escalate_to_human": handle_escalate, ...}—. The model chooses from the table; the code executes the corresponding function. Notice the radical difference with the dangerous alternative: if the model generated code (db.execute(...)) and the system ran it, the model would have arbitrary power —it could do anything the code can express—. With a dispatcher, the model can only select from a set of operations that you wrote and control. The model doesn't write the action; it names it. That's the difference between giving the waiter the ticket pad (chooses from the menu) and giving him access to the cash register (does whatever he wants).

   The LLM returns a STRUCTURED ACTION (a ticket), not code nor prose:

        {action: "refund", order_id: "A-1001", amount: 50.00}
              │
              ▼
     ┌──────────────────┐   is it a dict?                no → REJECT (free text)
     │  channel check    │   action in the menu?          no → REJECT (unknown action)
     │  (this lesson)    │   carries required fields?     no → REJECT (missing fields)
     └──────────────────┘
              │ yes (dispatchable command)
              ▼
     ┌──────────────────┐   DISPATCHER (a table, not an interpreter):
     │   dispatch        │   {"refund": handle_refund, "escalate": handle_escalate}
     │                   │   the model NAMES the action; your code executes it
     └──────────────────┘
              │
              ▼
      capabilities (L4) -> business rules (L5) -> execute

The channel check is prior to everything else. Notice the diagram's order: the channel check goes before the capabilities and the business rules. It makes sense: you can't ask "is this refund within policy?" if you don't even know the proposal is a refund with an amount. First you turn the proposal into a well-formed command (or reject it), and only then can the following layers reason about it. It's the same order as the kitchen: first the ticket has to be well-filled; then you see whether there are ingredients.

The boundary with module 4, precisely. It's easy to confuse the channel check with module 4's schema guardrail, because both "validate the structure of the output". The distinction: the M4 guardrail validates that the output meets a schema (that it's a JSON with certain fields and types) as an end in itself —so it's parseable and has no forbidden content—. The M6 channel check validates that the output be an executable command —that it names an action the system knows how to dispatch—. In practice they overlap (a well-formed command usually passes a schema guardrail), but the question is different: M4 asks "is it a valid output?"; M6 asks "is it an action I can execute?". And there are outputs that pass M4 and fail the M6 channel: {action: "delete_database"} is impeccable JSON (passes M4) that the channel rejects because delete_database isn't on the menu (fails M6). The schema looks at the form; the channel looks at whether the action exists.

Common mistakes

Letting the model return free text and then interpreting it to act. What happens: the agent responds in prose —"sure, I'll proceed to refund your order"— and a second step (sometimes another LLM, sometimes a heuristic) tries to deduce from that prose what action to execute. Each interpretation is a probability of error, and free text can hide instructions you didn't want to execute. Why it happens: it's more "natural" for the model to speak in prose, and it seems friendly. How to detect it: if between the model's output and the execution there's a step that interprets language to decide what to do, you have a shout to the kitchen. How to fix it: make the model return a structured action —a command with fields— and dispatch it with a table, not with an interpreter. The prose can go in a message field to show the user, but the action goes in structured fields.

Letting the model generate code to execute it. What happens: to give it "flexibility", the model is asked to generate a SQL query, or a snippet, and the system runs it. Now the model has arbitrary power: it can express any operation, including the ones you never wanted to allow (a DELETE without WHERE, a massive UPDATE). Why it happens: generating code feels powerful and general. How to detect it: if at some point the system executes a string the model produced (eval, exec, raw SQL, shell), you have an open door the size of the language you execute. How to fix it: never execute model code; make it choose from a menu of commands you wrote. The model names the operation (refund); your code implements it. The expressiveness you lose is exactly the one you didn't want to give it.

Accepting any action the model puts, without a closed menu. What happens: the dispatcher tries to handle any action name that comes —"if action is X, look for a handler called handle_X"—, instead of having a fixed menu. The day the model hallucinates action: "delete_account", if by chance a handle_delete_account exists, it's executed. Why it happens: it seems more extensible to resolve handlers dynamically. How to detect it: if your dispatcher doesn't explicitly reject the actions that aren't in a closed set, you don't have a menu. How to fix it: define an explicit ACTION_MENU and reject everything that isn't in it, as in the example. A closed menu is what makes delete_database be rejected by definition, not by chance. Lesson 4 develops this as bounded capabilities.

Exercises

Exercise 1 — The ticket and the shout. For each of these model proposals, say whether it's a dispatchable structured action or a shout (free text / not dispatchable), and why: (a) {action: "send_message", text: "Your package arrives tomorrow"}; (b) "Escalate it to a human please"; (c) {action: "refund", amount: 50.00} (without order_id); (d) {action: "ban_seller", seller_id: "S-9"} with a menu that only has refund, escalate_to_human, send_message.

See solution
  • (a) → dispatchable. It's a command from the menu (send_message) with its required field (text). Well-filled ticket; the kitchen processes it.
  • (b) → shout (free text). "Escalate it to a human please" is prose, not a command: it has no action nor fields. Even though its intent is an escalation, it's not dispatchable —it would have to be interpreted—, and the shell rejects it at the channel. The right thing would be for the model to return {action: "escalate_to_human", reason: "..."}.
  • (c) → not dispatchable (missing field). refund is on the menu, but it's missing order_id: refund which order? A known but incomplete command is rejected at the channel, just like a ticket without a table number.
  • (d) → not dispatchable (action outside the menu). ban_seller isn't on this agent's menu, so the shell doesn't know how to dispatch it and rejects it by definition. It doesn't matter that the JSON is well-formed; the action doesn't exist for this shell. (It's the case lesson 4 will treat as a capability not granted.)

The pattern: dispatchable = command from the menu + all its fields. Everything else —prose, incomplete command, action outside the menu— is rejected at the channel, before the rule validation.

Exercise 2 — Dispatchable isn't executable. In the example, the refund in case 0 came out "DISPATCHABLE", but that doesn't mean the refund gets executed. Explain the difference between "dispatchable" (channel check, this lesson) and "executable" (rule validation, lesson 5), and give an example of a command that's dispatchable but that lesson 5 should block.

See solution

"Dispatchable" means the proposal is a well-formed command from the menu: it has an action the system knows and all its required fields. It's a check of form: can I even process this as an action? "Executable" means the action, besides being well-formed, meets the business rules: it's a check of policy —does the order exist?, is it within the window?, is the amount within the limit?—. They're two gates in series: first channel (is it a command?), then rules (is the action valid?).

An example of dispatchable but not executable: {action: "refund", order_id: "A-1005", amount: 5000.00}. It passes the channel —it's a refund with order_id and amount, a perfectly filled ticket— but lesson 5 should block it because the amount ($5000) exceeds the order total and the agent's limit. The ticket is well-written; the dish can't be prepared. Being dispatchable only opens the door to the next gate; it doesn't guarantee the execution. That's why the module has several layers: the channel filters out what isn't even a command; the rules filter the commands that violate the policy.

Exercise 3 — From the shout to the ticket. A team has an agent that responds in prose and a second step that "reads" that prose with another LLM to decide the action. Explain the two problems of this design according to the lesson, and rewrite the interface so the agent returns a structured action. Show the command menu you'd propose for a support agent.

See solution

The two problems: (1) interpreting free text to act is probabilistic —the second LLM may deduce wrong what action the first wanted, adding a second source of error on top of the first—; and (2) free text is a surface of attack and of ambiguity —a prose can hide instructions or intentions that the interpreter executes unintentionally, and there are no concrete fields to validate before acting—. In the lesson's terms, it's a shout to the kitchen resolved with more guessing instead of with a ticket.

The rewrite: the agent directly returns a structured action from a closed menu, and the shell dispatches it with a table (not with an interpreter). A reasonable menu for a support agent:

ACTION_MENU = {
    "refund":            {"order_id", "amount"},
    "escalate_to_human": {"reason"},
    "send_message":      {"text"},          # the prose for the customer goes HERE,
    "resend_receipt":    {"order_id"},      # as a field, not as the action
}

Notice that the friendly prose for the customer doesn't disappear: it lives in the text field of the send_message command. What changes is that the action (refund, escalate, resend) is a structured command that's validated and dispatched, not a phrase someone interprets. That way the second interpreter LLM is eliminated: there's nothing to interpret, there's a command to dispatch. The uncertainty stays contained in a single place (the model filling the fields), and everything else is deterministic.

Summary and next step

In this lesson you opened the shell's box at its input: the structured action. If the model proposes and doesn't execute, it proposes in a validatable format —a named command with typed fields, {action: "refund", ...}— that a deterministic dispatcher dispatches, not in free text nor in code. You measured it: of six proposals, three were dispatchable commands and three were rejected at the channel —the free text because there's nothing to dispatch, the invented action because it's not on the menu, the incomplete command because it's missing a field—. You saw that the channel check is prior to the rule validation (dispatchable isn't executable), that the dispatcher is a table and not an interpreter (the model names the action, your code executes it), and that the boundary with M4 is one of question: M4 validates "is it a valid output?", the M6 channel validates "is it an action I can execute?".

Before moving on you should be able to: explain why a structured action is validatable and free text isn't safely; distinguish a dispatcher (command table) from an interpreter (executes whatever the model expresses); argue why model code is never executed; and separate "dispatchable" (channel) from "executable" (rules).

Lesson 4 takes the command menu we took for granted here and turns it into a design principle: the bounded capabilities. Case 2 of the example —delete_database rejected because it's not on the menu— was a preview: a closed menu means the actions outside it don't exist for the shell. You'll see, measured, how an agent's blast radius grows with the size of its menu: giving it broad capabilities "so it's useful" puts irreversible operations within reach of every hallucination, while a minimal menu leaves them at zero. Classic security's least privilege, applied to what an LLM can propose.

Resources

  • Claude documentation, tool usedocs.anthropic.com. The canonical form of the structured action: the model returns a tool call with a name and typed arguments, defined by you, and your system decides what to do with it. It's exactly this lesson's "command from the menu". Read it without focusing on a specific model version. In English.
  • Anthropic, "Building Effective Agents" (2024) — anthropic.com/engineering/building-effective-agents. Its insistence on giving the model a bounded set of well-defined tools, instead of free execution, is the basis of the structured format and the dispatcher. In English.
  • Martin Fowler and Bharani Subramaniam, "Emerging Patterns in Building GenAI Apps" — martinfowler.com/articles/gen-ai-patterns. The pattern of structuring the model's output as validatable data, instead of prose to interpret, appears as a recurring piece in robust GenAI apps. In English.
  • Chip Huyen, AI Engineering (O'Reilly, 2024). Its treatment of function calling and structured outputs covers how to get the model to return well-formed actions reliably —the boundary with AI Eng—; here we keep the why the structured format is the shell's safe input. In English.