Journeys

A few steps that share state, ending in one write a person confirms. Journeys are how you describe a goal that takes more than one tool call.

When one tool is not enough

Most tools do one thing and forget it: list-trips, cancel-order, search-products. Some goals do not fit that shape. Booking a ride is not one call, it is:

  1. turn the destination the user said into the coordinates the API needs,
  2. ask when they want to be picked up,
  3. actually request the ride, after the person agrees to it.

Three things make this a different kind of problem:

  • The steps depend on each other. The ride request needs what the first two steps produced.
  • The agent cannot invent a step's output. It knows "SFO airport" as words. The ride API needs coordinates, and those have to come from a real geocoder. Ask a model to make them up and your server rejects the request.
  • The last step changes something real. Requesting a ride should not happen because an agent decided it should. A person should say yes.

A journey is how you describe that goal once: a shared place to collect the answers, one tool per step, and a submit that a human confirms.

The mental model: a form, filled in steps

The easiest way to hold journeys in your head is a form.

  • The draft is the form. One object in the page's memory, for the life of the page. Steps write on it; the submit reads from it. It is not a database and nothing is saved to disk.
  • Each step fills in part of the form. A step either calls one of your existing tools and stores what it returns (the destination's coordinates), or just takes the agent's input and stores it (the pickup time). Every step replies with what is still blank, so the agent always knows the next move.
  • The submit is the Send button. It stays disabled until every field is filled, it shows the person what is about to happen, and only then does it run the real tool.

From the agent's side there is no hidden machinery. It sees a small flat list of tools and fills them in one at a time, reading the "still needed" reply after each call. Journeys do not coordinate the agent. They give it a form.

The whole thing in one example

A rideshare app has two generated tools: geocode-address (a read) and request-ride (a write, so it is withheld until enabled). The journey that ties them into one goal is a single file:

// src/webmcp/journeys/book-ride.webmcp.ts
import { createJourney } from "../journey.webmcp";
import { geocodeAddressTool, fetchGeocodeAddress } from "../geocode-address.webmcp";
import { executeRequestRide, type RequestRideInput } from "../request-ride.webmcp";

export const bookRide = createJourney({
  name: "book-ride",
  goal: "Book a ride to a destination the user gives",

  steps: {
    // A tool-backed step. The agent gives an address as text; the real
    // geocoder turns it into coordinates, and the step stores the result.
    "resolve-destination": {
      tool: geocodeAddressTool,
      call: (input, signal) => fetchGeocodeAddress(input as never, signal),
      store: (place) => ({ destination: place }), 
      provides: ["destination"], 
    },

    // A free step. No API call: the pickup time the agent passed in is
    // stored on the draft as-is.
    "set-pickup-time": {
      description: "Set when the user wants to be picked up.",
      input: {
        type: "object",
        properties: {
          pickupAt: { type: "string", description: "The pickup time, in ISO 8601." },
        },
        required: ["pickupAt"],
      },
      provides: ["pickupAt"], 
    },
  },

  submit: {
    description: "Request the ride and show the driver's details.",
    // The draft becomes the real tool's input; the write runs after the human confirms.
    build: (draft) => draft as RequestRideInput, 
    run: executeRequestRide,
  },
});

That is the whole feature. There is no config block and no new language: a journey is TypeScript that imports the tools you already generated.

What the agent sees

On page load, createJourney registers three ordinary tools. They look like any other tool; nothing marks them as special to the agent.

book-ride-resolve-destination   Fills the destination. Part of "book-ride": Book a ride...
book-ride-set-pickup-time       Set when the user wants to be picked up. Part of "book-ride"...
book-ride-submit                Request the ride and show the driver's details.

The agent fills the form by calling them in order, reading each reply. The whole exchange, as a diagram:

Two details are doing a lot of work here. The "still needed" replies are how the agent learns the order without being told it in prose. And the submit cannot run until the draft is full, so an agent that skips a step is refused with the list of what is missing, not a failed request.

How state moves between steps

Three fields move the data:

  • call does the work. For a tool-backed step it calls the raw caller your generated file exports (fetchGeocodeAddress), not the agent-facing wrapper. You reuse one request, and the schema stays single-sourced.
  • store decides what lands on the draft. It receives the call's result and returns the fields to save. This is where a resolved object becomes a named form field.
  • provides names the draft fields the step is responsible for. The submit refuses until every promise in every step is on the draft.

The submit's build is the reverse: it takes the finished draft and assembles the real tool's input. Because the only path to the real input is build(draft), an agent cannot call the write with a half-filled form.

Rules worth knowing

  • Keep journeys short. Two to five steps. Past five, you are usually describing two journeys.
  • Steps read, submit writes. A step should call a read tool or fill the draft. Put anything that changes real data in the submit, where the human is asked first. verify marks a step read-only only when the tool it composes is a read, and it cannot inspect code you write by hand.
  • Reuse, do not re-implement. A tool-backed step inherits the generated tool's description and schema and calls its raw caller. Edit the tool, regenerate, and the journey follows. Never write a direct fetch in a journey file; verify flags it.
  • Do not double-register. A tool that only makes sense inside a flow should not also register standalone. Withhold it in .webmcp-codegen.json, or exclude it, so the journey is its only door. Journeys should shrink the surface, not grow it.
  • The draft is per page load, per journey. It is reliable within one page view and gone on reload, on purpose. Two parallel attempts at the same journey on one page share a draft; in practice agents work one thing at a time.

The gate itself cannot be edited away. journey.webmcp.ts owns the confirmation and the draft clearing, and it is regenerated on every generate. The step logic is your code, which is why the rules above exist.

What ships, and what you write

generate writes two things into your tools directory:

  • journey.webmcp.ts, the factory that defines the draft, registers the steps, and holds the submit gate. Generator-owned, regenerated every run.
  • index.ts imports every file in journeys/ and registers whatever createJourney returns. Drop a journey file in, re-run generate, and it is live.

What it never writes is the journey itself, because an OpenAPI spec cannot tell it that "book a ride" is really resolve, then schedule, then confirm. That is product knowledge. You write it, or your coding agent does, guided by the skill file.

Journeys are not grouping

Two features sound similar and are not:

  • Grouping merges endpoints that are one action split across two calls (a request-upload plus a complete-upload) into a single tool. It happens at generate time, straight from the spec, with no human input.
  • Journeys chain different decisions that share state and end in one guarded submit. They are written per product, after generation.

Same action split across calls: group it. Several decisions along the way: a journey.

Next