HATEOAS for AI: REST's oldest idea is the right pattern for agents
You gave a model a set of tools, and it called one at the wrong time — out of order, on a workflow step that had already moved on. So you wrote the rules into the system prompt. "First do this, then that, never the other thing." And you hoped.
That hope is the bug. The model is holding your entire state machine in its head, from a paragraph of prose, with no feedback about where it actually is. Of course it drifts.
There's a 25-year-old idea from web API design that fixes exactly this. Almost nobody used it for REST. It turns out to be the right pattern for AI agents.
The most-ignored idea in REST
It's called HATEOAS — Hypermedia As The Engine Of Application State. It comes from Roy Fielding's 2000 dissertation, the document that defined REST in the first place.
The idea is simple. The server's response shouldn't just hand back data. It should hand back the links — the actions you can legally take next, from right here, in this state. The client never needs out-of-band knowledge of the workflow. It reads a response and follows a link.
Most APIs that call themselves RESTful skip this entirely. You read documentation, you memorize the endpoint sequence, you hard-code the order of calls. The server returns data; the workflow lives in your head and in a wiki. HATEOAS says: that's backwards. The workflow should live in the responses.
Why it fits agents — exactly
Think about what a language model is good at and bad at, as a client.
It is bad at out-of-band knowledge. Ask it to carry a multi-step set of rules across a 20-turn conversation and apply them consistently, and it will lose the thread. Not because it's careless — because that's a memory-and-consistency job, and that's not its strength.
It is excellent at reading what's in front of it and doing the obvious next thing. Give it a response that says "here are your three legal moves," and picking one is easy.
So don't make the model remember the state machine. Make every response carry the legal next moves. That's the whole pattern. mcp-flowgate is built on it.
One honest note before the details: this is HATEOAS-inspired, not HATEOAS by the book. The protocol underneath is JSON-RPC over MCP, not REST with hypermedia media types. What carries over is the principle that matters here — server-driven navigation through links.
Two layers of links
The model works through two link layers, and they answer two different questions.
Discovery — "what can I do?" Three tools.
gateway.home is the entry point — it hands back
the ways in: a search link, and a list of what's available.
gateway.search takes a query and returns
capability hits, each carrying a link to start it.
gateway.describe returns the detail for one item,
including its input schema. The model arrives knowing nothing
and navigates in.
Action — "what's the next legal step here?"
Four tools. workflow.start opens a workflow and
returns the first moves. workflow.submit takes a
move and returns the moves legal from the new state.
workflow.get re-fetches the current snapshot and
its links. workflow.explain — more on that one
shortly — answers questions about legality.
The model's whole loop is: search, start, then submit, submit, submit — following the links each response hands back. It never carries the map. It carries one current response.
What it looks like on the wire
Here's a real workflow.start response from the
content-publish example:
← { "workflow": { "id": "wf_8f3a", "version": 0, "state": "idea" },
"result": { "status": "started" },
"links": [
{ "rel": "create_outline",
"method": "workflow.submit",
"args": {
"workflowId": "wf_8f3a",
"expectedVersion": 0,
"transition": "create_outline",
"arguments": {} } } ] }
Look at the links array. It carries exactly one
legal next move — create_outline — and the
args are already shaped: the workflow ID, the
version to expect, the transition name. The model doesn't guess
the transition. It doesn't skip a step. It doesn't invent a
tool name. It follows the link.
Notice expectedVersion in there. Every workflow
carries a version that increments on each transition, and a
submit must name the version it expects. If two callers race,
or the model works from a stale snapshot, the runtime catches
it with STALE_WORKFLOW_VERSION instead of
silently applying a move to the wrong state. The link doesn't
just say "you may do this" — it says "you may do this, from
exactly this version."
Submit that, and the next response carries the next link:
write_draft. Submit that, and you get
run_brand_review. The model walks the workflow
forward one server-handed link at a time, and the engine —
not the prompt — decides what "forward" means.
The part that matters most: being wrong
The happy path is the easy part. The real test of this pattern is what happens when the client gets it wrong — because a model will.
Say the model tries a transition whose guards don't pass. Here's what comes back:
{
"result": { "status": "rejected" },
"error": {
"code": "GUARD_REJECTED",
"message": "One or more guards rejected the transition.",
"attemptedTransition": "approve"
},
"links": [
{ "rel": "request_changes", "method": "workflow.submit", "args": { ... } }
]
}
The call was rejected — and the response still carries
a links array. The model made a bad move and got
back, in the very same response, the moves that are
legal right now. It recovers without restarting the workflow
and without re-reading a system prompt.
Walk the recovery through. The model attempted
approve; the response says rejected, code
GUARD_REJECTED, and offers request_changes
as a legal move. The model reads that, understands approval
isn't available from here, and either follows the offered link
or asks why — but it is never stuck, never guessing into the
dark. The path forward arrived attached to the error.
That holds across every rejection the runtime produces —
STALE_WORKFLOW_VERSION,
INVALID_TRANSITION,
INPUT_SCHEMA_VIOLATION,
GUARD_REJECTED, EXECUTOR_FAILED,
CHAIN_FAILED. A wrong call is never a dead end.
It's a response with an error code and a way forward.
This is the difference between an agent that recovers and an agent that spirals. A spiral starts when a model gets an error with no path attached, guesses, gets another error, guesses again. Hand it the legal links every time and the spiral has nowhere to begin.
Asking whether a move is legal
There's a fourth action tool, and it's the one that makes this
a genuine conversation rather than trial and error:
workflow.explain.
Instead of attempting a transition to find out whether
it's allowed, the model — or a developer debugging a config —
can ask. workflow.explain answers "is this
transition allowed right now, and if not, what's blocking it?"
without side effects. A HATEOAS client that can also
interrogate the state machine before committing to a move
spends fewer round trips on doomed attempts, and a human
reading the explanation gets a straight answer about why the
workflow is parked where it is.
The server can hand back only what will work
Plain HATEOAS returns every transition the state defines and lets the client discover, by trying, which ones currently pass their guards. mcp-flowgate can go one step further.
Turn on linkFilter: byGuards — on a whole workflow
or a single state — and the runtime evaluates each transition's
guards silently before it builds the response, returning only
the links that would actually succeed right now. The
links array stops meaning "moves that exist" and
starts meaning "moves that will work." For a model, that's the
difference between a menu with greyed-out items and a menu where
everything on it is orderable. Fewer wrong attempts, fewer round
trips spent learning what it couldn't have done anyway.
Links can come pre-filled
A link doesn't have to be a bare "you may do X." It can arrive
with the parts of X the server already knows. A transition's
prefill block does that:
transitions:
create_pr:
target: review
prefill:
repo: "$.workflow.input.repo"
base: "main"
head: "$.context.branch_name"
executor: { kind: mcp, connection: github, tool: create_pull_request }
When the runtime generates the link for create_pr,
it resolves those expressions and fills repo,
base, and head into the link's
arguments. The model only has to generate the genuinely unknown
fields — the PR title and body. The server says, in effect,
"you may do this, and here's what I already know about it."
Fewer fields for the model to invent means fewer fields for it
to get wrong.
Where the pattern stops
Be clear about the boundary. Links guide the model; they don't bind it. Nothing stops a model from ignoring a link and submitting some other transition. What stops that is a separate layer — guards and actor checks that the runtime enforces regardless of what the model tried.
HATEOAS-style links are the ergonomics layer: they make the right move the obvious move. The enforcement layer is what makes the wrong move impossible. They're complementary, and they're not the same thing — worth remembering when you read the post on MCP security.
The oldest idea, the newest client
REST's most-skipped principle asked a lot of human-written clients: give up your hard-coded call sequence, trust the server's links. Most teams didn't want to.
An AI agent has the opposite preference. It doesn't want to hold your state machine. It would much rather read one response and follow a link. The pattern nobody adopted for REST is the pattern that fits agents best.
The mental model is laid out in the docs; the discovery guide covers the search-and-link loop in full.
References
- Fielding, R. T. "Architectural Styles and the Design of Network-based Software Architectures." Doctoral dissertation, University of California, Irvine, 2000 — Chapter 5, REST. ics.uci.edu/~fielding/pubs/dissertation
- "HATEOAS." Wikipedia. en.wikipedia.org/wiki/HATEOAS