Skip to content

19 min read

ReAct's real content was a text protocol, not a prompt style

On this page

A language model on its own emits text, so asked which invoices a customer received in July it answers from whatever it absorbed during training, fluently and with no way to check. The convention that fixes that has a name and a paper behind it. ReAct, a contraction of reasoning and acting, comes from ReAct: Synergizing Reasoning and Acting in Language Models, and it is a way of letting a model do something in the world part-way through producing an answer. ReAct has the model write its request for a lookup into the text it was already writing, in a fixed shape: Thought: for its reasoning, Action: naming a tool and its arguments, and Observation: where a result will be pasted in. Code on your side reads the Action: line, calls the matching function, writes the return value in behind the Observation: label, and sends the extended text back for another round, so the model reasons, acts, sees what happened, and reasons again over fetched facts rather than memory.

Understanding a ReAct loop means understanding that convention, because the convention is where every interesting property of the system comes from — including the failure modes that occupy the rest of this topic. A system built this way has no tools in any sense its provider knows about. It has a shape it expects part of the model's output to take, and a piece of client-side code that takes the shape seriously, and every property worth arguing about follows from that pair.

Three places bring most engineers into contact with it: agent code written between 2022 and mid-2023 and never revisited, which is where the fourth-tool ticket later in this chapter lives; the agent classes several frameworks still ship under names like create_react_agent; and the vocabulary, where "a ReAct agent" is said of systems that parse nothing at all. What took over from the labels is native tool calling, in which the request you send carries a list of tools described as machine-readable schemas and the response carries the model's request as a structured field — a tool name and an arguments object — rather than as sentences you have to pick apart.

Three labels, a stop sequence, and a regular expression

The prompt is what establishes the format. It names the tools, describes their arguments in English, and shows a worked exchange so that the model has a shape to imitate:

python
SYSTEM = """Answer the question by cycling through these three steps.

Thought: your reasoning about what to do next.
Action: one of the tools below, written as name[arguments].
Observation: the result of that action. Do not write this yourself.

Available tools:
  search[query]        - search the internal knowledge base
  list_invoices[customer_id, since]  - list invoices for a customer
  get_charge[charge_id]  - fetch one charge by id

When you have the answer, write:
Answer: <the answer>

Example:
Question: When was ACME's last invoice issued?
Thought: I need ACME's invoices, most recent first.
Action: list_invoices[cus_4417, 2026-01-01]
Observation: [{"id": "INV-2088", "issued": "2026-07-14", "total": 4200}]
Thought: The most recent issue date is 2026-07-14.
Answer: ACME's last invoice was issued on 2026-07-14.
"""

The loop around it is short, and the two lines that matter are the stop argument and the re.search:

python
import re

ACTION_RE = re.compile(r"^Action:\s*(\w+)\[(.*)\]\s*$", re.MULTILINE)

def run(question: str, max_steps: int = 8) -> str:
    transcript = f"{SYSTEM}\nQuestion: {question}\n"

    for _ in range(max_steps):
        completion = client.completions.create(
            model="some-completion-model",
            prompt=transcript,
            max_tokens=512,
            stop=["Observation:"],          # cut before the model invents a result
        )
        text = completion.choices[0].text
        transcript += text

        if "Answer:" in text:
            return text.split("Answer:", 1)[1].strip()

        match = ACTION_RE.search(text)
        if not match:
            transcript += "\nObservation: Could not parse an action. Retry.\n"
            continue

        name, raw_args = match.group(1), match.group(2)
        result = TOOLS[name](*[a.strip() for a in raw_args.split(",")])
        transcript += f"\nObservation: {result}\n"

    return "Step limit reached."

Two lines in that listing carry the design, and both are easy to skim past. The stop argument tells the provider to end the completion the moment those characters appear and to leave them out of what it returns, so the model is cut off at exactly the boundary where it would otherwise begin writing a result it does not have. The re.search is the other, and its anchor is doing work: ^Action: under re.MULTILINE matches only at the start of a line, so a sentence that mentions an action in passing is not one. Everything else in the function is bookkeeping — a string that grows, a counter that stops it, and a branch that returns when the completion contains Answer:.

Run one question through it and the alternation becomes visible as what it is, which is a single growing string. The transcript after two iterations of a question about a duplicate charge looks like this, with the parts the model wrote and the parts the loop wrote interleaved in one flat document:

text
Question: Was cus_8812 charged twice for INV-2041 in July?
Thought: I should list this customer's July invoices before assuming a duplicate.
Action: list_invoices[cus_8812, 2026-07-01]
Observation: [{"id": "INV-2041", "issued": "2026-07-03", "total": 129.00},
              {"id": "INV-2041", "issued": "2026-07-03", "total": 129.00}]
Thought: Two rows share an id and a date, so I should check the charges behind
them rather than trusting the invoice list.
Action: get_charge[ch_1KZq8f]
Observation: {"id": "ch_1KZq8f", "amount": 12900, "invoice": "INV-2041",
              "status": "succeeded"}
Thought: There is one succeeded charge against INV-2041, so the duplicate is in
the invoice listing, not in billing.
Answer: The customer was charged once; the invoice list contains a duplicate row.

Every line of that came out of one text field, and the loop's entire job was to notice which lines began with Action: and to write the lines beginning with Observation: itself.

Walk the first iteration against the listing and every line acquires an owner. The loop sends the system string with the question appended, and the provider returns everything the model wrote from its first thought up to the point where it began the observation label, which the stop parameter cut away. That text is appended to the transcript. It contains no Answer:, so the regex runs, matches the action line, and captures two groups: a name and one undivided string of everything between the brackets. The loop looks the name up in TOOLS, splits that string on commas to get the arguments, calls the function, and appends what came back behind an observation label it writes itself. The second request then sends the whole document again, so the model's next completion is conditioned on a text in which its own request and your database's answer are adjacent lines.

Two of those steps are decisions rather than requirements, and separating them matters because the protocol is usually blamed for what the pair does. Splitting the argument string on commas is a choice about how to encode more than one argument in a channel that has no notion of arguments; a different implementation could have demanded JSON between the brackets, or one argument to a line. Passing the pieces positionally is a second choice, made independently, and it is what makes the number of commas load-bearing. The protocol requires neither. It requires only that an action be recognisable on a line of its own, which is why two systems can both be ReAct and fail in unrelated ways.

The branch under if not match prices something the design cannot avoid. When a completion contains no line the regex accepts, the loop writes a parse complaint behind an observation label and goes round again, which spends a whole model call — a request carrying the entire transcript, and a reply the loop discards — to tell the model that its formatting was wrong. That is the only channel available for saying so, because the transcript is the only channel there is. A complaint about syntax and a fact about a customer's invoices arrive at the model behind the same label, and nothing marks which is which.

The worked example in the prompt is not decoration. A model that has never been shown the format will produce something reasonable and unparseable — I'll look up the invoices for cus_8812 is a perfectly good response and matches nothing — so the format has to be taught by demonstration, which is why every ReAct prompt in circulation carries at least one full exchange and most carry three or four. Those exchanges are the prompt's few-shot exemplars — worked examples included so the model can copy their shape rather than for anything they assert — and they occupy real context. The system string above is around 340 tokens with a single example; the production version of it, with three examples and eight tools described in English, ran to 1,400 tokens, and because the whole transcript is resent on every iteration, an eight-step conversation paid that 1,400 tokens eight times over. Roughly 11,200 tokens per conversation went to teaching the model a format, and that budget competed directly with the documents the agent was supposed to be reasoning about.

Read those two prompt sizes as a gap rather than as two levels, because the gap is the only figure on the page that says what an exemplar costs. The listed string carries one worked exchange and three tools described in a line each; the production string carries three exchanges and eight tool descriptions, and the difference between them is about a thousand tokens. Spread across two extra exchanges and five extra descriptions, that puts an exemplar in the low hundreds of tokens — paid once in the file, and then on every request of every conversation for as long as the prompt lives. The two strings belong to different systems, so the arithmetic to do on them is the subtraction and not a division: dividing a prompt size by a tool count would hand the exemplars' share to the tools and make each tool look several times more expensive than it is.

The total has a shape worth naming, and it is a different shape from the conversation's own growth. The prompt prefix is a constant. The same string rides in every request, so what it costs in total is that constant multiplied by the number of steps, which is a straight line. An observation is not a constant. Each one is written into the transcript once and then resent by every request after it, so in an eight-step conversation the first observation is paid for seven times and the last is paid for once, and the total the observations cost grows with the square of the step count. The consequence is worth carrying: the exemplar budget dominates a short conversation and is dwarfed by a long one, so a team trimming exemplars to control cost is attacking the term that stops mattering first.

The stop sequence is what makes the second half of the loop's job possible, the half that consists of writing the observation lines itself. Without it, a model that has been shown an example containing observations will happily continue past its own action and write a plausible-looking result — inventing invoice rows that were never fetched, and then reasoning confidently from them. Instrumenting the loop above with the stop sequence removed, across a hundred questions, produced 61 completions that ran straight past Action: into a fabricated Observation: line. The stop sequence is not a formatting nicety; it is the mechanism that stops the model from playing both roles in a conversation it is having with itself.

The reason the model does that is the same reason it produced a parseable action in the first place, and it is worth following link by link because the two behaviours are one behaviour. A completion model continues the string in front of it. The string in front of it contains a worked exchange in which an action line is followed by an observation line carrying invoice rows. Continuing the established pattern therefore means writing an observation, and nothing in the model's situation marks the observation it is meant to wait for as different from the one it has already been shown. So the exemplar that teaches the format also teaches the model to forge its own results, and the stop parameter is the only thing separating the two lessons.

Three in five is the figure that settles what the stop parameter is, and the ratio matters more than the count. A guard whose absence changes two questions in five is a nicety worth having. One whose absence changes three in five is the mechanism, because with it removed more than half the tool results in the run would be fabrications the model then reasoned from confidently. The remaining two in five are not evidence that the format holds on its own: they are completions that stopped for some other reason, and nothing in the loop can tell a completion that respected the boundary from one that happened not to reach it.

The parser decides which command runs

The provider in this design has been told nothing. It received a string and returned a string, and as far as its API contract is concerned the completion was prose. Nothing in the response distinguishes the characters list_invoices[cus_8812, 2026-07-01] from any other sentence the model might have produced. The decision that those particular characters name an executable action, and that cus_8812 is the first argument to a Python function that will open a database connection, is made entirely by ACTION_RE and the two lines beneath it.

That is an assertion until the steps under it are laid out, and each step is checkable against the listing above. The request body carries a prompt, a stop parameter and a token limit, and carries no field in which a tool could be described — so the provider holds no representation of a tool, not a partial one. Its reply is one text field, and a text field has nowhere to mark a span as a command, so nothing in the response distinguishes an action line from a sentence about actions. The characters of the action therefore arrive with exactly the status every other character in the completion has, which is none. The only thing that can raise them to the status of a call is code you wrote matching them, and that is why the boundary between data and instruction sits inside a regular expression in your own file, with no help from anywhere else.

Put plainly, the model has not been given a way to request anything. It has been given a way to write, and you have separately agreed to treat some of what it writes as a request. That agreement is entirely one-sided and lives entirely in your client, which is the reframe to carry into the next three chapters: the model is not calling your functions, your regular expression is, on the model's suggestion.

Sequence: Your loop → Provider (prompt (one string)); Provider → Your loop (completion (one string)); Your loop → ACTION_RE (search(text)); ACTION_RE → Your loop (name="get_charge", args="ch_1KZq8f); Your loop → TOOLS dict (TOOLS["get_charge"]("ch_1KZq8f")); TOOLS dict → Your loop (result); Your loop → Your loop (append "Observation: ..." to transcript).

Put the same request through a modern provider with a declared toolset and that diagram loses a participant. The model's response carries a typed block — a tool name drawn from the schemas you supplied and an arguments object the provider serialised as JSON — so the client code reads fields instead of matching characters. Nothing has to be recovered from prose, because nothing was encoded into prose in the first place. That is the substitution native tool calling actually made. It did not replace the idea of alternating reasoning with action, which survives intact and is what everyone still means when they say a system is agentic. It replaced the text protocol: three reserved labels, a stop sequence keyed to one of them, and a regular expression that turns a substring into a function call.

One consequence of the provider knowing nothing is that the model's choice among tools rests entirely on prose. There is no description field the provider surfaces, no argument names it can see, no place to say that since is an ISO date; there is a paragraph in a prompt, and the model's tool selection is as good as that paragraph's wording. Changing search[query] - search the internal knowledge base to mention that the knowledge base contains only published help articles is, in this design, indistinguishable from any other prompt edit, which means the tool inventory has no schema, no version and no test that fails when someone rewords it.

The distinction matters because of where it puts the boundary of trust. In the parsed design, the boundary sits inside your regex, which is operating on a string assembled from model output, tool output, and anything either of those happened to contain. In the typed design, the boundary sits at the provider's serialiser, and the only thing your code receives is a structure whose shape was fixed before the request went out. Both designs run your functions with your credentials. They differ in what has to go right for the correct function to run.

What the labels were for in 2022

Yao and colleagues published ReAct: Synergizing Reasoning and Acting in Language Models in October 2022, and the experiments were about interleaving. On HotpotQA and FEVER the model alternated reasoning steps with Wikipedia search actions; on ALFWorld and WebShop it alternated reasoning with environment actions. The finding was that reasoning-only prompting hallucinated facts it could not check, action-only prompting flailed without a plan, and alternating the two beat both. That finding is about control flow, and it has aged well enough that it is now simply how agents are built.

The three labels were not the finding. They were the only carrier available. In 2022 the models people ran this against — text-davinci-002, PaLM — exposed a completion endpoint that took a string and returned a string, with a stop parameter as the sole structural affordance. If you wanted a model to request an action, the request had to be written in the same channel as everything else, which meant reserving some tokens as delimiters and writing a parser. OpenAI shipped function calling in June 2023 with the 0613 model revisions, giving the request a functions array of JSON Schema objects and the response a structured function_call field, and every major provider followed with some version of the same shape. The eight months between those two events are the entire reason ReAct's protocol exists, and the reason it stopped being necessary.

The interval is worth computing rather than accepting, because it is load-bearing three times over and each use rests on the same subtraction. October to the following June is eight months, and that is the whole window in which carrying an action as labelled text was the only way to get one out of a model. The first job the interval does is explain why the protocol was invented, which is that nothing else existed. The second is explain why it stopped being necessary, which is that something else then did. The third is date a codebase: a parser written inside the window was written by people with no alternative, and one written after it was somebody's choice. Those are three conclusions from one measurement rather than three separate facts, and the later chapters of this topic reuse it in that third form without recomputing it. and the thing to read it for is the ablation tables rather than the prompt appendix. The prompts are a historical artifact of a completion API; the evidence that interleaving beats either half alone is the part you are still relying on every time you write a loop.

The half-explanation that keeps circulating

"ReAct is a prompting technique where you tell the model to think step by step before it acts" is the description you will hear in most interviews and read in most blog summaries, and it describes the prompt while missing the protocol. Told that way, ReAct sounds like a variant of chain-of-thought, which the earlier topic on scratchpads already covered — and if that were all it was, native tool calling would have replaced nothing, since you can still ask a model to reason before it calls a tool and providers now emit that reasoning as its own content. The half that goes missing is ACTION_RE: the client-side machinery that made a substring executable.

The belief is tempting for a nameable reason, and the reason is worth stating because the same shape produces most of the wrong conclusions about this design. The prompt is the visible artefact. It is what the paper prints in its appendix, what gets pasted into a blog post, and what a reader can inspect without opening anybody's repository. It is also genuinely unchanged by the migration: an instruction to reason about the request before acting is present, correct, and doing the same work under a typed protocol as under a parsed one. So the quantity a reader is holding fixed — the instruction — really is constant, and their conclusion that nothing important differs would follow if the instruction were the mechanism.

What varies underneath that constant is whether anything parses. Two systems can carry identical prompts, produce identical reasoning, and differ completely in whether a substring of the model's output can become a function call, because that property lives in the client and the prompt is silent about it in both. Checking the prompt therefore cannot distinguish them, and this is the general form of the error rather than a detail about ReAct: the visible artefact was held constant while the dangerous property moved. The same substitution also runs in the other direction, and it produces a mirror-image mistake with the same cause.

The consequence of the half-explanation is practical rather than pedantic. Someone who believes ReAct is a prompt style will look at a migration to native tool calling and see no work to do, because the prompt can stay as it is; someone who has seen ACTION_RE knows the migration deletes the parser, the stop sequence, the exemplars and the English tool descriptions, and that the prompt is the smallest part of it. The same misunderstanding shows up in the other direction when a team decides to "add ReAct" to a system that already uses native tool calling, and ends up writing a system prompt asking the model to think before acting — which may help and is not what the paper contributed.

ReAct is also not the agent loop. The previous topic built the loop from scratch — message accumulation, dispatch, stop conditions, an iteration cap — and every one of those parts is present in the listing above, which is why the listing looks familiar. What ReAct adds on top of the loop is a wire format, and a loop can carry that format or a typed one without changing shape. It is not a framework either, though several were built around it; there is no ReAct library you are meant to import, and the LangChain agent types named after it are wrappers over the same three labels.

What ReAct contributed and what it carried are separable, and separating them is what the chapter has been for. The contribution is the finding that a model alternating reasoning with action beats one doing either alone, and that survives every change of transport; it is why the loops in production today are shaped the way they are. The carrier was three reserved labels, a stop parameter keyed to one of them, and a regular expression — a design that existed because for eight months there was no other channel, and one that put the decision about what executes inside a line of your own client rather than inside the provider. Every property the next five chapters examine is a consequence of that placement, which is also the reason the vocabulary matters: a system described as ReAct may be parsing prose or may be doing nothing of the kind, and only the request body says which.

The prompt above parses cleanly because every example in it was written by someone who knew what the parser expected. The model produced list_invoices[cus_8812, 2026-07-01] and the regex found a name and two comma-separated arguments where it hoped to. The next chapter feeds both designs the same three abuses — an argument that is malformed, a required argument that is absent, and an argument nobody declared — and the difference between a parser and a schema stops being a matter of style.

ReAct as history — read the current version at https://daviddominguez.dev/learn/en/react-as-history/react-s-real-content-was-a-text-protocol-not-a-prompt-style

Back to top