← Back to blog

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.start response
 { "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:

a rejected call
{
  "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:

gateway.yaml
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

  1. 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
  2. "HATEOAS." Wikipedia. en.wikipedia.org/wiki/HATEOAS