# Flows and stages

> Every request FrontMCP handles runs through named flows, each a fixed plan of stages. Every flow FrontMCP runs, with its stages in order and the state each one sets, how flows nest in one request, and how to write your own.

Source: https://frontmcp.dev/reference/server/flows

Every request FrontMCP handles runs through a **flow**: a named plan of stages. A `tools/call` runs the `tools:call-tool` flow, whose stages find the tool, check auth and limits, validate the input, run `execute()` and send the result. Flows are what [hooks](https://frontmcp.dev/reference/sdk/hooks) attach to: a hook names a flow and one of its stages. This page lists every flow FrontMCP 1.9.4 has, with its stages and the state each one sets, shows how several flows nest inside one request, and shows how to write a flow of your own.

```ts
// Hook a stage of a built-in flow (see Hook decorators)
const { Will, Did, Around, Stage } = FlowHooksOf("tools:call-tool");

// Or write a flow, register it, and run it
@Flow({ name, access, inputSchema, outputSchema, plan: { pre, execute, post, finalize, error } })
class MyFlow extends FlowBase<typeof name> {
  @Stage("stageName") async stageName() { /* this.input, this.state, this.respond(), this.fail() */ }
}

await this.scope.registryFlows(MyFlow);
const output = await this.scope.runFlowForOutput(name, input);
```

---

## Reference

### How a flow runs

A flow's **plan** puts its stage names into phases, which run in this order:

| Phase | Runs | When a stage answers (`respond`) | When a stage fails |
| --- | --- | --- | --- |
| `pre` | First. Lookups and checks: finding the tool, auth, rate limits. | The rest of `pre` and all of `execute` are skipped. `post` and `finalize` still run. | `error` runs, then `finalize`, and the request fails with the error. |
| `execute` | If nothing in `pre` answered. The work itself. | The rest of `execute` is skipped. `post` and `finalize` still run. | As for `pre`. |
| `post` | After `execute`, and after an answer from `pre` or `execute`. | The rest of `post` still runs, and the **last** answer is the one sent. | As for `pre`. |
| `finalize` | Always, last, whether the request worked or not. | | The request fails. If it was already failing, it keeps its first error. |
| `error` | Only when a stage in `pre`, `execute` or `post` fails, before `finalize`. | Ignored: the request still fails. | |

Each stage runs its hooks around it: `Will` hooks, then `Around` hooks, then the stage's own step (with any `Stage` hooks beside it), then `Did` hooks. A `Did` hook runs after a stage that worked or answered, not after one that failed. [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#the-order-hooks-run-in) has the order within a stage.

Every run starts fresh: a new flow object, with its own `input` and an empty `state`. A hook's `ctx` **is** that object, so `ctx.state` is the state the stages share, and `ctx.respond()` answers exactly as a stage would.

No built-in flow has `error` stages. The list flows put their lookups in `execute` and their formatting in `post`; the flows that run one entry (`tools:call-tool`, `resources:read-resource`, `prompts:get-prompt`, `agents:call-agent`, `jobs:execute-job`) have no `post`, and close with `finalize` stages instead. That's why `ctx.respond()` in a `Will("findTool")` hook ends a tool call but `finalize` still runs.

### Flows in one request

One request runs several flows, one inside another. For a `tools/call` from a 2026-07-28 client over HTTP:

| Flow | What it does | Where it runs |
| --- | --- | --- |
| `http:request` | Every HTTP request at the MCP endpoint: tracing, IP filter, server-wide rate limits, auth, then routing to a transport. | The HTTP entry points. |
| `session:verify` | Checks the caller's credentials for the server's `auth` mode. | Inside `http:request`'s `checkAuthorization` stage. |
| `handle:mcp-202607280728` | Reads a 2026-07-28 request (the name is spelled that way in 1.9.4) and answers it. | Inside `http:request`'s `handleMcp2026` stage. |
| `tools:call-tool` | The tool call. | Inside `handle:mcp-202607280728`'s `handleMessage` stage. |

Which flows a request runs depends on how it arrives:

| Request | Flows |
| --- | --- |
| 2026-07-28 over HTTP (`createFetchHandler()`, `bootstrap()`, the Playground) | `http:request` → `session:verify` → `handle:mcp-202607280728` → the method's flow |
| Older clients over Streamable HTTP (`bootstrap()`) | `http:request` → `session:verify` → `handle:streamable-http` → the method's flow |
| stdio, [`createDirect()`](https://frontmcp.dev/reference/sdk/create), [`connect()`](https://frontmcp.dev/reference/sdk/connect) | The method's flow only. `http:request` hooks never run. |
| [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool) from a tool | Another `tools:call-tool`, inside the caller's `execute` stage. |
| `invoke_<agent>`, a call to an [agent](https://frontmcp.dev/reference/sdk/agent) | `tools:call-tool`, then `agents:call-agent` inside its `execute` stage. |
| An agent's model reading the agent's resources or prompts | `resources:list-resources` and `resources:list-resource-templates`, `resources:read-resource`, `prompts:list-prompts` or `prompts:get-prompt`, in the agent's own scope, inside `agents:call-agent`'s `execute` stage. Plugins in `@Agent({ plugins })` hook them, and the app's and the server's only with `execution.inheritPlugins`. New in 1.9.4. |
| `execute_job`, `execute_workflow` | `tools:call-tool`, and [`jobs:execute-job`](#jobsexecute-job) once per attempt of the job, or of each step: inside the call's `execute` stage while the client waits, on its own for a run in the background. New in 1.9.4. |
| `server/discover`, `initialize` | No method flow. |
| A 2026-07-28 call that asks the user with [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) | One `tools:call-tool` per round: the first ends when `execute()` asks, and `finalize` still runs. |

For 2026-07-28 requests whose response is streamed (the request asks for progress or log messages), the closing stages of `http:request` and `handle:mcp-202607280728` run as soon as the stream starts, **before** the tool call has run. Don't time requests or release per-request resources in them.

The method's flow for each request:

| Request | Flow |
| --- | --- |
| `tools/call`, including `invoke_<agent>` and FrontMCP's job tools | `tools:call-tool`; for `invoke_<agent>` also `agents:call-agent`, and for `execute_job` and `execute_workflow` also `jobs:execute-job` |
| `tools/list` | `tools:list-tools` |
| `resources/read` | `resources:read-resource` |
| `resources/list` | `resources:list-resources` |
| `resources/templates/list` | `resources:list-resource-templates` |
| `prompts/get` | `prompts:get-prompt` |
| `prompts/list` | `prompts:list-prompts` |
| `completion/complete` | `completion:complete` |
| `skills/search`, `skills/load` | `skills:search`, `skills:load`, each with `skills:filter` inside it, which drops skills the caller may not see |
| `skills/list` | `skills:filter` |
| `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe` (clients before 2026-07-28) | `logging:set-level`, `resources:subscribe`, `resources:unsubscribe` |
| `tasks/get`, `tasks/result`, `tasks/cancel`, `tasks/list` (clients before 2026-07-28) | `tasks:get`, `tasks:result`, `tasks:cancel`, `tasks:list` |

### Stages of the request flows

Each table lists the stages in order, by phase, and the `ctx.state` fields each stage sets. A field stays set for the rest of the flow. [Reading what the stages set](#reading-what-the-stages-set) shows them.

#### `tools:call-tool`

| Phase | Stage | Sets |
| --- | --- | --- |
| `pre` | `parseInput` | `input` (the request's `name`, `arguments` and `_meta`), `authInfo`, `jsonRpcRequestId`, and `progressToken` when the request has one |
| | `ensureRemoteCapabilities` | Loads the tool lists of [remote apps](https://frontmcp.dev/reference/server/remote), if any. |
| | `findTool` | `tool`: the tool's entry, with `metadata` and `owner` |
| | `checkPublicAccess` | Refuses an anonymous caller a tool the server's [`publicAccess`](https://frontmcp.dev/reference/auth/modes#publicaccess) doesn't list, with `PUBLIC_ACCESS_DENIED`. New in 1.9.2. |
| | `checkToolAuthorization`, `checkEntryAuthorities` | Refuses callers the tool's [`authorities`](https://frontmcp.dev/learn/authorizing-calls) don't allow. |
| | `createTaskIfRequested` | Starts a background task, for tools with `execution.taskSupport`. |
| | `createToolCallContext` | `toolContext` (the tool instance `execute()` runs on) and `executionAbort` |
| | `checkToolCredentials` | Checks the tool's `authProviders`. |
| | `acquireQuota`, `acquireSemaphore` | The tool's [`rateLimit` and `concurrency`](https://frontmcp.dev/reference/sdk/guard). |
| `execute` | `validateInput` | Validates the arguments into `toolContext.input`. |
| | `execute` | Runs `execute()`, and puts what it returned in `toolContext.output`. |
| | `validateOutput` | `rawOutput`: a copy of `toolContext.output` |
| `finalize` | `releaseSemaphore`, `releaseQuota`, `applyUI` | Frees the limits, and attaches the tool's [UI](https://frontmcp.dev/reference/sdk/tool). |
| | `finalize` | Checks `rawOutput` against `outputSchema` (a mismatch is `INVALID_OUTPUT`), builds the result and sends it. What a hook put in `resultMeta` is merged into the result's `_meta`, not into its data (new in 1.8.6: the [Cache plugin](https://frontmcp.dev/reference/plugins/cache) records `cache: "hit"` there). |

#### `tools:list-tools`, `resources:list-resources`, `resources:list-resource-templates`, `prompts:list-prompts`

The four list flows share one plan:

| Phase | Stage | Sets |
| --- | --- | --- |
| `pre` | `parseInput` | Tools only: `authInfo`, `platformType`, and `cursor` when the request has one |
| | `ensureRemoteCapabilities` | |
| `execute` | `findTools`, `findResources`, `findTemplates` or `findPrompts` | `tools`, `resources`, `templates` or `prompts`: every entry on the endpoint. Tools also set `foundTools`, the same list, which the filters below leave as it is (new in 1.9.2). |
| | `filterByAuthorities` | Drops the entries the caller's `authorities` don't allow, from the same field. |
| | `filterByPublicAccess` | Tools and prompts only: drops what `publicAccess` doesn't offer an anonymous caller. New in 1.9.2. |
| | `resolveConflicts` | `resolvedTools`, `resolvedResources`, `resolvedTemplates` or `resolvedPrompts`: the entries with [clashing names](https://frontmcp.dev/reference/server/apps#name-clashes) prefixed |
| `post` | `parseTools`, `parseResources`, `parseTemplates` or `parsePrompts` | Builds the list the client gets, one page of it for [paged](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists) `tools/list`. |

A hook on the `find…` stage sees and changes what every app contributes: see [Changing what clients list](https://frontmcp.dev/reference/sdk/hooks#changing-what-clients-list).

#### `resources:read-resource`, `prompts:get-prompt`

| Phase | Stage | Sets |
| --- | --- | --- |
| `pre` | `parseInput` | `input` (the request's params), `authInfo` |
| | `ensureRemoteCapabilities` | |
| | `findResource` / `findPrompt` | `resource` with `params` (a template's variables) and `resourceOwnerId`; or `prompt` and `promptOwnerId` |
| | `checkPublicAccess` | Prompts only, as in `tools:call-tool`. New in 1.9.2. |
| | `checkEntryAuthorities` | Refuses callers the entry's `authorities` don't allow. |
| | `createResourceContext` / `createPromptContext` | `resourceContext`; or `parsedArgs` and `promptContext` |
| `execute` | `execute` | Runs `execute()`, into `resourceContext.output` or `promptContext.output`. |
| | `validateOutput` | `rawOutput`: a copy of that output |
| `finalize` | `finalize` | Builds the result from `rawOutput` and sends it. |

#### `agents:call-agent`

| Phase | Stage | Sets |
| --- | --- | --- |
| `pre` | `parseInput` | `input`, `authInfo` |
| | `findAgent` | `agent`: the agent's entry |
| | `checkCallDepth` | Refuses a call that would nest agents deeper than `swarm.maxCallDepth` allows, with `AGENT_CALL_DEPTH_EXCEEDED`. New in 1.9.0. |
| | `checkEntryAuthorities`, `checkAgentAuthorization` | Refuses callers the agent's `authorities` don't allow. |
| | `createAgentContext` | `agentContext` |
| | `acquireQuota`, `acquireSemaphore` | The agent's `rateLimit` and `concurrency`. |
| `execute` | `validateInput`, `execute`, `validateOutput` | `executionStartedAt`, `executionMeta` (`execute`); `rawOutput` (`validateOutput`) |
| `finalize` | `releaseSemaphore`, `releaseQuota`, `parseOutput`, `emitCompletion`, `finalize` | `parsedOutput` (`parseOutput`). `emitCompletion` feeds [`agent-completion` channels](https://frontmcp.dev/reference/sdk/channel). |

Under `invoke_<agent>`, `checkEntryAuthorities`, `acquireQuota` and `acquireSemaphore` pass straight through: the surrounding `tools:call-tool` has already applied them, so they count once. Changed in 1.8.5: before, this flow never ran, and hooks on it never fired.

#### `jobs:execute-job`

One run per attempt of a [job](https://frontmcp.dev/reference/sdk/job): `execute_job` while the client waits or in the background, each retry, and each step of a [workflow](https://frontmcp.dev/reference/sdk/workflow). Its hooks are [`JobHook`](https://frontmcp.dev/reference/sdk/hooks#hooking-a-jobs-attempts).

| Phase | Stage | Sets |
| --- | --- | --- |
| `pre` | `parseInput` | `job` (the job's entry), `input` (as given), `attempt` (1 for the first, 2 for the first retry), `authInfo`; `runId` for a run of `execute_job`, or `workflow` (`{ name, stepId, runId }`) for a workflow step |
| | `checkJobAuthorization` | Refuses a caller the job's [`permissions`](https://frontmcp.dev/reference/sdk/job#permissions) don't let `execute` it, with `JOB_NOT_AUTHORIZED`. A refusal isn't retried. |
| | `validateInput` | `parsedInput`: `input` checked against the job's `inputSchema`. A hook before it can replace `input`. |
| | `createJobContext` | `jobContext`: the job instance `execute()` runs on. A `@Job` class's instance hooks join the run here. |
| `execute` | `execute` | `output`: what `execute()` returned, or passed to `this.respond()` |
| | `validateOutput` | `answer`: `{ result, logs }`, `result` checked against `outputSchema` (a mismatch is `INVALID_OUTPUT`, and isn't retried) |
| `finalize` | `updateRunState` | Records the attempt on the run as `completed`, `retrying` or `failed`, and notifies. A workflow step has no run of its own: its workflow run records it. `flowError` is set when the attempt failed. |
| | `finalize` | Answers with `answer`. |

A hook that calls `ctx.respond({ result, logs })` before `execute` answers for the job, which doesn't run: `updateRunState` records that answer as the run's result. An app's plugins hook that app's jobs; the server's plugins hook every app's. New in 1.9.4: before, jobs ran through no flow.

```ts attempt-log.plugin.ts active
import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk";

export const attempts: object[] = [];

@Plugin({ name: "attempt-log" })
export class AttemptLog extends DynamicPlugin<object> {
  @JobHook.Did("updateRunState")
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { job, attempt, runId, workflow, answer } = ctx.state;
    attempts.push({ job: job?.name, attempt, hasRunId: runId !== undefined, workflow, answer });
  }
}
```

```ts help-desk.app.ts
import { App, Job, JobContext, Workflow, z } from "@frontmcp/sdk";
import { AttemptLog } from "./attempt-log.plugin";

@Job({ name: "count-open-tickets", inputSchema: {}, outputSchema: { open: z.number() } })
class CountOpenTickets extends JobContext {
  async execute() {
    this.log("Counted the open tickets");
    return { open: 12 };
  }
}

@Workflow({ name: "nightly-report", steps: [{ id: "count", jobName: "count-open-tickets" }] })
class NightlyReport {}

@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets], workflows: [NightlyReport], plugins: [AttemptLog] })
export class HelpDeskApp {}
```

```ts attempts.test.ts
import { test, expect } from "@frontmcp/testing";
import { attempts } from "./attempt-log.plugin";

test("execute_job's attempt has a run id and an answer", async ({ mcp }) => {
  attempts.length = 0;
  await mcp.tools.call("execute_job", { name: "count-open-tickets", input: {} });
  expect(attempts).toEqual([
    {
      job: "count-open-tickets",
      attempt: 1,
      hasRunId: true,
      workflow: undefined,
      answer: { result: { open: 12 }, logs: [expect.stringMatching(/Counted the open tickets$/)] },
    },
  ]);
});

test("a workflow step's attempt names the step instead", async ({ mcp }) => {
  attempts.length = 0;
  const run = (await mcp.tools.call("execute_workflow", { name: "nightly-report", input: {} })).json();
  expect(attempts).toMatchObject([
    { job: "count-open-tickets", attempt: 1, hasRunId: false, workflow: { name: "nightly-report", stepId: "count", runId: run.runId } },
  ]);
});
```

#### `completion:complete`, `skills:filter`

| Flow | Stages | Sets |
| --- | --- | --- |
| `completion:complete` | `pre`: `parseInput`, `findReference`, `checkPublicAccess`, `checkEntryAuthorities`, `findWidgetTools`; `execute`: `complete`; `finalize`: `finalize`. `checkPublicAccess` and `checkEntryAuthorities` are new in 1.9.2. | `ref`, `argument` (`parseInput`); `prompt` or the resource (`findReference`); `output` (`complete`) |
| `skills:filter` | `pre`: `parseInput`; `execute`: `filterSkills`; `finalize`: `finalize` | `skills` |

#### `skills:search`, `skills:load`, `logging:set-level`, `resources:subscribe`, `resources:unsubscribe`

| Flow | Stages | Sets |
| --- | --- | --- |
| `skills:search` | `pre`: `parseInput`; `execute`: `search`; `finalize`: `finalize` | `query`, `options` (`parseInput`); `results` (`search`); `output` (`finalize`) |
| `skills:load` | `pre`: `parseInput`; `execute`: `loadSkills`, `activateSessions`; `finalize`: `finalize` | `skillIds`, `format`, `activateSession`, `policyMode`, `warnings` (`parseInput`); `loadResults` (`loadSkills`) |
| `logging:set-level` | `pre`: `parseInput`; `execute`: `setLevel`; `finalize`: `finalize` | `input` (`{ level }`), `sessionId` |
| `resources:subscribe` | `pre`: `parseInput`, `validateResource`; `execute`: `subscribe`; `finalize`: `finalize` | `input` (`{ uri }`), `sessionId`. `validateResource` refuses a URI `resources/read` would refuse, with the same error. |
| `resources:unsubscribe` | `pre`: `parseInput`; `execute`: `unsubscribe`; `finalize`: `finalize` | `input` (`{ uri }`), `sessionId` |

Changed in 1.9.3: before, these five flows were registered but never ran, so hooks on them never fired: `skills/search` and `skills/load` read the skill registry directly, and the other three were answered without a flow. [Session requests and skills](#session-requests-and-skills) shows them running.

### Stages of the HTTP and transport flows

| Flow | `pre` | `execute` | `finalize` | Sets |
| --- | --- | --- | --- | --- |
| `http:request` | `traceRequest`, `checkIpFilter`, `relayToSessionOwner`, `acquireQuota`, `acquireSemaphore`, `checkAuthorization`, `acquireIdentityQuota`, `router` | `checkPersistentSessionOwner`, which answers `404` `Session not found` to anyone but the caller who opened a Durable Object's session (new in 1.8.4); then `handleMcp2026`, `handleWebFetch`, `handleLegacySse`, `handleSse`, `handleStreamableHttp`, `handleStatefulHttp`, `handleStatelessHttp`, `applyDeleteNodeHeaders`, `handleDeleteSession`: the one that matches the request answers it. `applyDeleteNodeHeaders` (new in 1.9.2) sets the session owner's `X-FrontMCP-Machine-Id` on a `DELETE` relayed to another node | `audit`, `metrics`, `releaseSemaphore`, `releaseQuota`, `finalize` | `verifyResult` (`checkAuthorization`), `intent` (`router`) |
| `handle:mcp-202607280728` | `parseInput`, `validate`, `router` | `handleNotification`, `handleSubscriptions`, `handleMessage` | `cleanup` | `isAnonymous`, `version` (`validate`), `requestType` (`router`) |
| `handle:streamable-http` | `applyNodeHeaders`, `parseInput`, `router` | `onInitialize`, `onMessage`, `onElicitResult`, `onSseListener`, `onExtApps` | `cleanup` | `token`, `session`, `requestType` |
| `handle:legacy-sse` | `parseInput`, `router` | `onInitialize`, `onMessage`, `onElicitResult` | `cleanup` | For the old SSE transport; not shown here. |
| `handle:stateless-http` | `applyNodeHeaders`, `parseInput`, `router` | `handleRequest` | `cleanup` | For stateless HTTP; not shown here. |
| `session:verify` | `parseInput`, `handleStaticToken`, `handlePublicMode`, `handleAnonymousFallback`, `requireAuthorizationHeader`, `verifyIfJwt` | `deriveUser`, `parseSessionHeader`, `buildAuthorizedOutput` | | Answers from the first stage that can decide: in public mode, `handlePublicMode`. |

`relayToSessionOwner` is new in 1.9.0: on a `distributed` deployment, it passes a request for a session another node owns to that node, and does nothing elsewhere (read in FrontMCP's code; it needs Redis and two nodes, so it isn't run here). `applyNodeHeaders` (new in 1.8.7) sets the `X-FrontMCP-Machine-Id` response header on a `distributed` deployment. `ctx.rawInput.request` in an `http:request` hook is the request: `method`, `path`, `headers`, `query` and the parsed `body`. [Seeing every HTTP request](https://frontmcp.dev/reference/sdk/hooks#seeing-every-http-request) uses it.

### Other flows

These run for HTTP endpoints outside the MCP endpoint, or for features you turn on. Through `createFetchHandler()`, they don't run inside `http:request`. The `acquireQuota` stage after `checkIpFilter` counts the request against `throttle.global` (new in 1.9.2).

| Flow | Runs for | Stages |
| --- | --- | --- |
| `well-known.oauth-protected-resource` | `GET /.well-known/oauth-protected-resource` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`; `execute`: `collectData`; `post`: `validateOutput` |
| `well-known.oauth-authorization-server` | `GET /.well-known/oauth-authorization-server` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`; `execute`: `collectData` |
| `well-known.jwks` | `GET /.well-known/jwks.json` | `pre`: `checkIpFilter`, `acquireQuota`, `parseInput`, `validateInput`; `execute`: `collectData` |
| `oauth:authorize`, `oauth:token`, `oauth:callback`, `oauth:register`, `oauth:userinfo`, `oauth:connect`, `oauth:provider-callback`, `oauth:auth-ui-extra` | FrontMCP's own OAuth endpoints (`/oauth/authorize`, `/oauth/token`, …), for `local` and `remote` auth | Each starts with `checkIpFilter`, `acquireQuota`, `parseInput`. |
| `skills-http:llm-txt`, `skills-http:llm-full-txt`, `skills-http:api` | `GET /llm.txt`, `/llm_full.txt` and `/skills`, with `skillsConfig` enabled; each runs `skills:filter` | `pre`: `checkIpFilter`, `acquireQuota`, `checkEnabled` (`skills-http:api` adds `parseRequest`); `execute`: `generateContent` or `handleRequest` |
| `http:ip-filter` | Custom routes in `http.routes`, and a [channel](https://frontmcp.dev/reference/sdk/channel)'s webhook path on the Node server | `pre`: `checkIpFilter` |
| `elicitation:request`, `elicitation:result` | `this.elicit()` in a session of a client before 2026-07-28, [`connect()`](https://frontmcp.dev/reference/sdk/connect#answering-the-tools-questions)'s client included, and the answer | `request`: `parseInput`, `validateRequest`; `generateElicitId`, `storePendingRecord`, `buildRequestParams`; `finalize`. `result`: `parseInput`; `lookupPending`, `validateContent`, `buildResult`, `publishResult`; `finalize` |
| `tasks:get`, `tasks:result`, `tasks:cancel`, `tasks:list` | `tasks/*` requests from clients before 2026-07-28 | `pre`: `parseInput`; `execute`: one stage (`fetchAndRespond`, `awaitTerminal`, `cancelAndRespond`, `listAndRespond`) |
| `channels:send-notification`, `channels:list` | Every [channel](https://frontmcp.dev/reference/sdk/channel#hooks-on-channel-flows) event, however it's sent, and the channels a session subscribes to. New in 1.9.0: before, they never ran. | `send-notification`: `pre`: `parseInput`, `resolveMeta`; `execute`: `send`; `finalize`. `list`: `execute`: `listChannels`; `finalize` |

The OAuth, well-known, custom-route and task flows need a real server or a client on an older protocol, so this page doesn't show them running. The elicitation flows run for `connect()`'s in-process client, which keeps a session as older clients do. A question from a 2026-07-28 client runs neither:

```ts main.ts active
import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Tool, ToolContext, z } from "@frontmcp/sdk";

export const started: string[] = [];

const ElicitRequest = FlowHooksOf("elicitation:request");
const ElicitResult = FlowHooksOf("elicitation:result");

@Plugin({ name: "elicit-log" })
export class ElicitLogPlugin extends DynamicPlugin<object> {
  @ElicitRequest.Will("parseInput")
  async asked() {
    started.push("elicitation:request");
  }

  @ElicitResult.Will("parseInput")
  async answered() {
    started.push("elicitation:result");
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket once the user confirms", inputSchema: { id: z.string() } })
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const answer = await this.elicit(`Close ticket ${id}?`, z.object({ confirm: z.boolean() }));
    return { id, status: answer.status === "accept" && answer.content?.confirm ? "closed" : "open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CloseTicket] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [ElicitLogPlugin], elicitation: { enabled: true } };

@FrontMcp(config)
export default class Server {}
```

```ts elicit-flows.test.ts
import { test, expect } from "@frontmcp/testing";
import { connect } from "@frontmcp/sdk";
import { config, started } from "./main";

test("connect()'s client runs both flows for a question", async () => {
  const client = await connect({ ...config }, { onElicitation: () => ({ action: "accept", content: { confirm: true } }) });
  try {
    started.length = 0;
    expect(await client.callTool("close_ticket", { id: "T-1" })).toMatchObject({ structuredContent: { status: "closed" } });
    expect(started).toEqual(["elicitation:request", "elicitation:result"]);
  } finally {
    await client.close();
  }
});

test("a 2026-07-28 client's question runs neither", async ({ mcp }) => {
  mcp.onElicitation(() => ({ action: "accept", content: { confirm: true } }));
  started.length = 0;
  expect((await mcp.tools.call("close_ticket", { id: "T-2" })).json()).toEqual({ id: "T-2", status: "closed" });
  expect(started).toEqual([]);
});
```

### Session requests and skills

`logging/setLevel`, `resources/subscribe` and `resources/unsubscribe` come from clients that keep a session: clients before 2026-07-28 and [`connect()`](https://frontmcp.dev/reference/sdk/connect)'s in-process client. 2026-07-28 removed them, so they get `-32601` `Method not found: resources/subscribe was removed in protocol 2026-07-28` and run no flow. `skills/search` and `skills/load` run their flows for every client:

```ts main.ts active
import { App, DynamicPlugin, FlowHooksOf, FrontMcp, Plugin, Resource, ResourceContext, Skill, Tool, ToolContext, z } from "@frontmcp/sdk";

export const started: string[] = [];

const SetLevel = FlowHooksOf("logging:set-level");
const Subscribe = FlowHooksOf("resources:subscribe");
const Unsubscribe = FlowHooksOf("resources:unsubscribe");
const Search = FlowHooksOf("skills:search");
const Load = FlowHooksOf("skills:load");

@Plugin({ name: "request-log" })
export class RequestLogPlugin extends DynamicPlugin<object> {
  @SetLevel.Will("parseInput")
  async setLevel() {
    started.push("logging:set-level");
  }

  @Subscribe.Will("parseInput")
  async subscribe() {
    started.push("resources:subscribe");
  }

  @Unsubscribe.Will("parseInput")
  async unsubscribe() {
    started.push("resources:unsubscribe");
  }

  @Search.Will("parseInput")
  async search() {
    started.push("skills:search");
  }

  @Load.Will("parseInput")
  async load() {
    started.push("skills:load");
  }
}

@Tool({ name: "search_tickets", description: "Search support tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [], query };
  }
}

@Resource({ uri: "tickets://open", name: "open-tickets", mimeType: "application/json" })
class OpenTickets extends ResourceContext {
  async execute() {
    return { tickets: ["T-1"] };
  }
}

@Skill({ name: "triage", description: "Triage incoming tickets", instructions: "Search for duplicates, then set a priority.", tools: [SearchTickets] })
class Triage {}

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets], resources: [OpenTickets], skills: [Triage] })
class HelpDeskApp {}

export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [RequestLogPlugin] };

@FrontMcp(config)
export default class Server {}
```

```ts request-flows.test.ts
import { test, expect } from "@frontmcp/testing";
import { connect } from "@frontmcp/sdk";
import { config, started } from "./main";

test("a session client's setLevel, subscribe and unsubscribe run their flows", async () => {
  const client = await connect({ ...config });
  try {
    started.length = 0;
    await client.setLogLevel("info");
    await client.subscribeResource("tickets://open");
    await client.unsubscribeResource("tickets://open");
    expect(started).toEqual(["logging:set-level", "resources:subscribe", "resources:unsubscribe"]);
  } finally {
    await client.close();
  }
});

test("skills/search and skills/load run their flows", async ({ mcp }) => {
  started.length = 0;
  await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "triage" } });
  const loaded = await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "skills/load", params: { skillIds: ["triage"], activateSession: true } });
  expect(started).toEqual(["skills:search", "skills:load"]);
  expect(loaded.result.skills[0].session).toEqual({ activated: false });
});

test("a 2026-07-28 client's setLevel runs none", async ({ mcp }) => {
  started.length = 0;
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "logging/setLevel", params: { level: "info" } });
  expect(response.error).toMatchObject({ code: -32601 });
  expect(started).toEqual([]);
});
```

The answers are the same as before 1.9.3, when the five requests were answered without their flows, except that `skills/load` with `activateSession: true` now gives each skill a `session`: `{ activated: false }` here, where nothing starts a skill session.

### Writing a flow

A flow is a class that extends `FlowBase`, decorated with `@Flow` (also exported as `FrontMcpFlow`). Each stage is a method decorated with `Stage()` from `FlowHooksOf(name)`.

| `@Flow` option | Type | Description |
| --- | --- | --- |
| `name` | `string` | **Required.** The flow's name, like `"desk:triage"`. |
| `inputSchema` | Zod object | **Required.** Parses the input into `this.input` when a run starts. Input that doesn't match throws a `ZodError` from `runFlow()` before any stage or hook runs. |
| `plan` | `FlowPlan` | **Required.** Stage names by phase: `{ pre?, execute?, post?, finalize?, error? }`. A name without a matching `@Stage` method is skipped. |
| `outputSchema` | Zod schema | What `this.respond()` accepts: the answer is parsed with it, so fields it doesn't declare are dropped. Without one, `this.respond()` throws `Cannot read properties of undefined (reading 'parse')`. |
| `access` | `"public" \| "authorized"` | Required by the TypeScript type, defaults to `"public"` at run time, and never read by FrontMCP 1.9.4. |
| `middleware` | `{ path?, method?, canActivate? }` | Answer HTTP requests to `path` with this flow. See [Answering HTTP requests with a flow](#answering-http-requests-with-a-flow). |
| `dependsOn` | `Token[]` | Providers the flow uses. Not checked for a flow you register yourself, and not needed: `this.get()` finds the server's providers either way. |
| `description` | `string` | For your own documentation. |

Inside a stage:

| Member | Description |
| --- | --- |
| `this.input` | The input, parsed with `inputSchema`. `this.rawInput` is the input as it was passed. |
| `this.state` | The run's state: read a field as `this.state.title`, set it with `this.state.set("title", value)`. |
| `this.respond(output)` | Answers with `output`, as described in [How a flow runs](#how-a-flow-runs). |
| `this.fail(error)` | Fails the run with `error`, exactly like throwing it. |
| `this.get(token)` | A provider of the server, from `@FrontMcp({ providers })`. |
| `this.scope`, `this.scopeLogger` | The endpoint the flow runs on, and its logger. |

For typed `this.input`, `this.state` and stage names, add the flow to the global `ExtendFlows` interface, as in [Writing and running a flow](#writing-and-running-a-flow).

#### Registering and running

No `@FrontMcp` or `@App` option registers a flow. Register it at run time on an endpoint with `scope.registryFlows(MyFlow)`, from code that has the scope: `this.scope` in a tool, resource or prompt, or `createForGraph().getScopes()` in a script. Registering the same class again replaces the first registration.

| Method | Returns |
| --- | --- |
| `scope.runFlowForOutput(name, input)` | The answer. A run that ends without one throws `FlowExitedWithoutOutputError` (`FLOW_EXITED_WITHOUT_OUTPUT`). |
| `scope.runFlow(name, input)` | The answer, or `undefined`. |

Both throw what the run fails with, and `FlowNotRegisteredError` (`Flow "desk:nope" is not registered`) for a name that isn't registered on that endpoint. A run started from a tool is part of the tool's request: `this.context` in the flow is the tool's, with the same `requestId`.

#### Caveats

- TypeScript rejects a plan written `as const satisfies FlowPlan<string>`: `as const` makes the stage lists read-only, and `FlowPlan` wants plain arrays. Type the plan as `FlowPlan<Stages>` instead, as in [Writing and running a flow](#writing-and-running-a-flow).
- A flow's name must be unique on the endpoint. Registering a class with the name of a built-in flow, like `tools:list-tools`, **replaces** the built-in one.
- Plugin hooks on your flow run for every run of it, whichever app the plugin is on: a flow of your own has no owner to limit them.

---

## Usage

### Seeing which flows a request runs

A server plugin with a `Will` hook on the first stage of several flows records each flow as it starts, and the `librarian` agent has a plugin of its own for the reads its model makes. The tests show the nesting for each kind of request:

```ts flows.plugin.ts active
import { AgentCallHook, DynamicPlugin, FlowHooksOf, HttpHook, JobHook, ListToolsHook, Plugin, ResourceHook, ToolHook } from "@frontmcp/sdk";

export const started: string[] = [];

const SessionHook = FlowHooksOf("session:verify");
const Mcp2026Hook = FlowHooksOf("handle:mcp-202607280728");

@Plugin({ name: "flow-log" })
export class FlowLogPlugin extends DynamicPlugin<object> {
  @HttpHook.Will("traceRequest")
  async http() {
    started.push("http:request");
  }

  @SessionHook.Will("parseInput")
  async session() {
    started.push("session:verify");
  }

  @Mcp2026Hook.Will("parseInput")
  async transport() {
    started.push("handle:mcp-202607280728");
  }

  @ToolHook.Will("parseInput")
  async call() {
    started.push("tools:call-tool");
  }

  @ListToolsHook.Will("parseInput")
  async list() {
    started.push("tools:list-tools");
  }

  @AgentCallHook.Will("parseInput")
  async agent() {
    started.push("agents:call-agent");
  }

  @JobHook.Will("parseInput")
  async job() {
    started.push("jobs:execute-job");
  }

  @ResourceHook.Will("parseInput")
  async read() {
    started.push("resources:read-resource");
  }
}

// On an agent: hooks the agent's own flows
@Plugin({ name: "agent-flow-log" })
export class AgentFlowLogPlugin extends DynamicPlugin<object> {
  @ResourceHook.Will("parseInput")
  async read() {
    started.push("resources:read-resource, in the agent");
  }
}
```

```ts help-desk.app.ts
import { Agent, AgentContext, App, Job, JobContext, Resource, ResourceContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { AgentFlowLogPlugin } from "./flows.plugin";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

let syncs = 0;

@Job({ name: "sync-tickets", inputSchema: {}, outputSchema: { synced: z.boolean() }, retry: { maxAttempts: 2, backoffMs: 10 } })
class SyncTickets extends JobContext {
  async execute() {
    syncs += 1;
    if (syncs % 2 === 1) throw new Error("The ticket store is busy");
    return { synced: true };
  }
}

@Resource({ uri: "kb://priorities", name: "priorities", mimeType: "text/plain" })
class Priorities extends ResourceContext {
  async execute() {
    return "High when the customer can't work.";
  }
}

@Tool({ name: "get_ticket_summary", description: "Summarize a ticket in one line", inputSchema: { id: z.string() } })
class GetTicketSummary extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = await this.callTool("get_ticket", { id });
    return { summary: `${id}: ${(ticket.structuredContent as { title: string }).title}` };
  }
}

@Agent({
  name: "triage",
  description: "Suggest a priority for a support ticket",
  inputSchema: { id: z.string() },
  // Stands in for a real model, which the Playground can't reach
  llm: { adapter: { async completion() { return { content: "Priority: high", finishReason: "stop" }; } } },
})
class Triage extends AgentContext {}

@Agent({
  name: "librarian",
  description: "Answer from the help desk's notes",
  inputSchema: { question: z.string() },
  resources: [Priorities],
  plugins: [AgentFlowLogPlugin],
  // Reads the notes, then answers
  llm: {
    adapter: {
      async completion(prompt) {
        if (prompt.messages.length === 1) return { content: null, finishReason: "tool_calls", toolCalls: [{ id: "1", name: "read_resource", arguments: { uri: "kb://priorities" } }] };
        return { content: "High", finishReason: "stop" };
      },
    },
  },
})
class Librarian extends AgentContext {}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, GetTicketSummary], agents: [Triage, Librarian], jobs: [SyncTickets] })
export class HelpDeskApp {}
```

```ts main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { FlowLogPlugin } from "./flows.plugin";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp], plugins: [FlowLogPlugin] })
export default class Server {}
```

```ts flows.test.ts
import { test, expect } from "@frontmcp/testing";
import { started } from "./flows.plugin";

const transport = ["http:request", "session:verify", "handle:mcp-202607280728"];

test("a tool call runs the HTTP and transport flows, then tools:call-tool", async ({ mcp }) => {
  started.length = 0;
  await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(started).toEqual([...transport, "tools:call-tool"]);
});

test("tools/list runs tools:list-tools", async ({ mcp }) => {
  started.length = 0;
  await mcp.tools.list();
  expect(started).toEqual([...transport, "tools:list-tools"]);
});

test("server/discover runs no method flow", async ({ mcp }) => {
  started.length = 0;
  await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" });
  expect(started).toEqual(transport);
});

test("this.callTool() runs a second tools:call-tool inside the first", async ({ mcp }) => {
  started.length = 0;
  await mcp.tools.call("get_ticket_summary", { id: "T-1" });
  expect(started).toEqual([...transport, "tools:call-tool", "tools:call-tool"]);
});

test("invoke_<agent> runs agents:call-agent inside tools:call-tool", async ({ mcp }) => {
  started.length = 0;
  expect((await mcp.tools.call("invoke_triage", { id: "T-1" })).json()).toEqual({ response: "Priority: high" });
  expect(started).toEqual([...transport, "tools:call-tool", "agents:call-agent"]);
});

test("an agent's read runs in the agent, where only its own plugin hooks it", async ({ mcp }) => {
  started.length = 0;
  await mcp.tools.call("invoke_librarian", { question: "When is a ticket high?" });
  expect(started).toEqual([...transport, "tools:call-tool", "agents:call-agent", "resources:read-resource, in the agent"]);
});

test("execute_job runs jobs:execute-job inside tools:call-tool, once per attempt", async ({ mcp }) => {
  started.length = 0;
  await mcp.tools.call("execute_job", { name: "sync-tickets", input: {} });
  expect(started).toEqual([...transport, "tools:call-tool", "jobs:execute-job", "jobs:execute-job"]);
});
```

A hook on `http:request` sees every request, but it runs before FrontMCP knows which tool is called. To act on tool calls, hook `tools:call-tool`, which runs whichever way the call arrives: over HTTP, over stdio, or from `this.callTool()`.

### Reading what the stages set

`ctx.state.snapshot()` returns a copy of the state. This plugin records which fields are set after five stages of `tools:call-tool`, and after the `find…` stage of `tools:list-tools`. It also sets `resultMeta` before `finalize`, which the second test reads from the result:

```ts state.plugin.ts active
import { DynamicPlugin, FlowCtxOf, ListToolsHook, Plugin, ToolHook } from "@frontmcp/sdk";

export const fieldsAfter: Record<string, string[]> = {};

function record(stage: string, state: { snapshot(): object }) {
  const snapshot = state.snapshot() as Record<string, unknown>;
  fieldsAfter[stage] = Object.keys(snapshot).filter((key) => snapshot[key] !== undefined);
}

@Plugin({ name: "state-log" })
export class StateLogPlugin extends DynamicPlugin<object> {
  @ToolHook.Did("parseInput")
  async parsed(ctx: FlowCtxOf<"tools:call-tool">) {
    record("parseInput", ctx.state);
  }

  @ToolHook.Did("findTool")
  async found(ctx: FlowCtxOf<"tools:call-tool">) {
    record("findTool", ctx.state);
  }

  @ToolHook.Did("createToolCallContext")
  async created(ctx: FlowCtxOf<"tools:call-tool">) {
    record("createToolCallContext", ctx.state);
  }

  @ToolHook.Did("execute")
  async executed(ctx: FlowCtxOf<"tools:call-tool">) {
    record("execute", ctx.state);
  }

  @ToolHook.Did("validateOutput")
  async validated(ctx: FlowCtxOf<"tools:call-tool">) {
    record("validateOutput", ctx.state);
  }

  @ToolHook.Will("finalize")
  async marked(ctx: FlowCtxOf<"tools:call-tool">) {
    ctx.state.set("resultMeta", { checked: true });
  }

  @ListToolsHook.Did("findTools")
  async listed(ctx: FlowCtxOf<"tools:list-tools">) {
    record("findTools", ctx.state);
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext, z } from "@frontmcp/sdk";
import { StateLogPlugin } from "./state.plugin";

@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], plugins: [StateLogPlugin] })
export class HelpDeskApp {}
```

```ts state.test.ts
import { test, expect } from "@frontmcp/testing";
import { fieldsAfter } from "./state.plugin";

test("each stage of tools:call-tool adds its fields", async ({ mcp }) => {
  await mcp.tools.call("get_ticket", { id: "T-1" });
  // progressToken is there because the test client asks for progress on every call
  expect(fieldsAfter.parseInput).toEqual(["input", "authInfo", "progressToken", "jsonRpcRequestId"]);
  expect(fieldsAfter.findTool).toEqual([...fieldsAfter.parseInput, "tool"]);
  expect(fieldsAfter.createToolCallContext).toEqual([...fieldsAfter.findTool, "executionAbort", "toolContext"]);
  expect(fieldsAfter.execute).toEqual(fieldsAfter.createToolCallContext); // the result is in toolContext.output
  expect(fieldsAfter.validateOutput).toEqual([...fieldsAfter.execute, "rawOutput"]);
});

test("resultMeta goes into the result's _meta, and the data stays as the tool returned it", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.raw._meta?.checked).toBe(true);
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in" });
});

test("findTools sets the list of every app's tools", async ({ mcp }) => {
  await mcp.tools.list();
  expect(fieldsAfter.findTools).toEqual(["authInfo", "platformType", "tools", "foundTools"]);
});
```

Read fields by name, like `ctx.state.tool` or `ctx.state.toolContext`, rather than from a snapshot: a snapshot is a copy, and changing it changes nothing.

### Writing and running a flow

A triage flow for tickets: `loadTicket` answers at once for closed tickets and fails for unknown ones, `classify` picks a priority, and `answer` sends it. `audit`, in `finalize`, runs every time, and `onError` only when a stage failed. A tool registers the flow the first time it's called, then runs it, and a plugin hooks one of its stages like any other flow's:

```ts triage.flow.ts active
import { Flow, FlowBase, FlowHooksOf, PublicMcpError, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk";

const name = "desk:triage" as const;
const inputSchema = z.object({ id: z.string() });
const outputSchema = z.object({ id: z.string(), priority: z.enum(["low", "high", "none"]) });
const stateSchema = z.object({ title: z.string(), vip: z.boolean(), priority: z.enum(["low", "high"]) });

type Stages = "loadTicket" | "classify" | "answer" | "audit" | "onError";
const plan: FlowPlan<Stages> = {
  pre: ["loadTicket"],
  execute: ["classify"],
  post: ["answer"],
  finalize: ["audit"],
  error: ["onError"],
};

// Makes the name known to FlowHooksOf, FlowCtxOf and runFlowForOutput, with typed input and state
declare global {
  interface ExtendFlows {
    "desk:triage": FlowRunOptions<TriageFlow, typeof plan, typeof inputSchema, typeof outputSchema, typeof stateSchema>;
  }
}

const tickets: Record<string, { title: string; status: "open" | "closed"; customer: string }> = {
  "T-1": { title: "Cannot log in", status: "open", customer: "globex" },
  "T-2": { title: "Printer is slow", status: "open", customer: "globex" },
  "T-3": { title: "Old invoice", status: "closed", customer: "globex" },
  "T-4": { title: "Printer is slow", status: "open", customer: "acme" },
};

export const auditLog: string[] = [];
const { Stage } = FlowHooksOf(name);

@Flow({ name, access: "authorized", inputSchema, outputSchema, plan })
export class TriageFlow extends FlowBase<typeof name> {
  private steps: string[] = [];

  @Stage("loadTicket")
  async loadTicket() {
    this.steps.push("loadTicket");
    const ticket = tickets[this.input.id];
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${this.input.id}.`));
    if (ticket.status === "closed") this.respond({ id: this.input.id, priority: "none" });
    this.state.set({ title: ticket.title, vip: ticket.customer === "acme" });
  }

  @Stage("classify")
  async classify() {
    this.steps.push("classify");
    this.state.set("priority", /log in|password/i.test(this.state.title ?? "") ? "high" : "low");
  }

  @Stage("answer")
  async answer() {
    this.steps.push("answer");
    if (!this.state.priority) return; // post runs after an early answer too
    this.respond({ id: this.input.id, priority: this.state.priority });
  }

  @Stage("onError")
  async onError() {
    this.steps.push("onError");
  }

  @Stage("audit")
  async audit() {
    auditLog.push(`${this.input.id}: ${this.steps.join(" > ")}`);
  }
}
```

```ts triage.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TriageFlow } from "./triage.flow";

let registered: Promise<void> | undefined;

@Tool({ name: "triage_ticket", description: "Suggest a priority for a support ticket", inputSchema: { id: z.string() } })
export class TriageTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    registered ??= this.scope.registryFlows(TriageFlow);
    await registered;
    return this.scope.runFlowForOutput("desk:triage", { id });
  }
}
```

```ts vip.plugin.ts
import { DynamicPlugin, FlowCtxOf, FlowHooksOf, Plugin } from "@frontmcp/sdk";

const TriageHook = FlowHooksOf("desk:triage");

@Plugin({ name: "vip", description: "Raises every VIP customer's ticket to high priority" })
export class VipPlugin extends DynamicPlugin<object> {
  @TriageHook.Did("classify")
  async raise(ctx: FlowCtxOf<"desk:triage">) {
    if (ctx.state.vip) ctx.state.set("priority", "high");
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { TriageTicket } from "./triage.tool";
import { VipPlugin } from "./vip.plugin";

@App({ id: "help-desk", name: "Help Desk", tools: [TriageTicket], plugins: [VipPlugin] })
class HelpDeskApp {}

@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}
```

```ts triage.test.ts
import { test, expect } from "@frontmcp/testing";
import { auditLog } from "./triage.flow";

test("every phase runs, in order, and finalize last", async ({ mcp }) => {
  expect((await mcp.tools.call("triage_ticket", { id: "T-2" })).json()).toEqual({ id: "T-2", priority: "low" });
  expect(auditLog.at(-1)).toBe("T-2: loadTicket > classify > answer");
});

test("a plugin's Did hook changes the state the next stage reads", async ({ mcp }) => {
  // Same title as T-2, but acme is a VIP customer
  expect((await mcp.tools.call("triage_ticket", { id: "T-4" })).json()).toEqual({ id: "T-4", priority: "high" });
});

test("an answer in pre skips execute; post and finalize still run", async ({ mcp }) => {
  expect((await mcp.tools.call("triage_ticket", { id: "T-3" })).json()).toEqual({ id: "T-3", priority: "none" });
  expect(auditLog.at(-1)).toBe("T-3: loadTicket > answer");
});

test("a failure runs the error phase, then finalize, and fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("triage_ticket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.text()).toBe("There's no ticket T-9.");
  expect(auditLog.at(-1)).toBe("T-9: loadTicket > onError");
});
```

`answer` checks for a priority because `post` runs after the early answer for `T-3` too, and a second `this.respond()` there would replace it. The plugin is on the same app as the tool here, but it doesn't have to be: hooks on a flow of your own run whichever app's plugin declares them.

### Answering HTTP requests with a flow

A flow with `middleware: { method, path }` answers HTTP requests to that path, once it's registered. Its input is the request, and its output an HTTP response: `httpRespond.json(body)`, `httpRespond.ok(text)` and others build one. A tool registers this one here, so the endpoint appears after the first call:

```ts export.flow.ts active
import { Flow, FlowBase, FlowHooksOf, httpInputSchema, httpOutputSchema, httpRespond, z, type FlowPlan, type FlowRunOptions } from "@frontmcp/sdk";

const name = "desk:export" as const;
const stateSchema = z.object({});
const plan: FlowPlan<"serve"> = { execute: ["serve"] };

declare global {
  interface ExtendFlows {
    "desk:export": FlowRunOptions<ExportFlow, typeof plan, typeof httpInputSchema, typeof httpOutputSchema, typeof stateSchema>;
  }
}

const { Stage } = FlowHooksOf(name);

@Flow({
  name,
  access: "public",
  inputSchema: httpInputSchema,
  outputSchema: httpOutputSchema,
  plan,
  middleware: { method: "GET", path: "/exports" },
})
export class ExportFlow extends FlowBase<typeof name> {
  @Stage("serve")
  async serve() {
    const { query } = this.input.request as { query: Record<string, string | undefined> };
    this.respond(httpRespond.json({ format: query.format ?? "csv", tickets: ["T-1", "T-2"] }));
  }
}
```

```ts help-desk.app.ts
import { App, Tool, ToolContext } from "@frontmcp/sdk";
import { ExportFlow } from "./export.flow";

@Tool({ name: "enable_exports", description: "Turn on the /exports endpoint", inputSchema: {} })
class EnableExports extends ToolContext {
  async execute() {
    await this.scope.registryFlows(ExportFlow);
    return { enabled: "/exports" };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [EnableExports] })
export class HelpDeskApp {}
```

```ts export.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

const handler = FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
const send = (path: string, init?: RequestInit) => handler.then((h) => h(new Request(`https://desk.example.com${path}`, init)));

const callTool = (name: string) =>
  send("/", {
    method: "POST",
    headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": name },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/call",
      params: { name, arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
    }),
  });

test("GET /exports answers once the flow is registered", async () => {
  expect((await send("/exports?format=json")).status).toBe(404);

  expect((await callTool("enable_exports")).status).toBe(200);

  const response = await send("/exports?format=json");
  expect(response.status).toBe(200);
  expect(await response.json()).toEqual({ format: "json", tickets: ["T-1", "T-2"] });
});
```

With the Node server, the flow is mounted as an Express route on the same port. Because nothing registers a flow at startup, prefer [`http.routes`](https://frontmcp.dev/reference/sdk/frontmcp#http) for an endpoint that must exist from the first request; a flow is for an endpoint that should run hooks, or that code turns on later.

---

## Troubleshooting

### My hook on a flow never runs

Check that the request reaches the flow: stdio, `createDirect()` and `connect()` skip `http:request`, and a 2026-07-28 `resources/subscribe` or `logging/setLevel` never gets past the transport ([Session requests and skills](#session-requests-and-skills)). Before 1.9.3, `logging:set-level`, `resources:subscribe`, `resources:unsubscribe`, `skills:search` and `skills:load` never ran at all. [Hook decorators](https://frontmcp.dev/reference/sdk/hooks#my-hook-never-runs) lists the other reasons.

### `Flow "desk:triage" is not registered`

`runFlow()` or `runFlowForOutput()` was called with a name that isn't registered on that endpoint: the flow wasn't registered yet, its `name` is spelled differently, or it was registered on another endpoint (each [standalone app](https://frontmcp.dev/reference/server/apps#endpoints-standalone-and-splitbyapp) is one). Register it with `scope.registryFlows()` first, on the same scope.

### `Flow exited without producing output`

`runFlowForOutput()` ran the flow and no stage called `this.respond()`. Check that the stage that answers is in `plan` and has a `@Stage()` decorator with the same name: a plan entry without one is skipped without a warning. Use `runFlow()` if the flow isn't meant to answer.

### `Invalid input: expected Object, received undefined` from `@Flow`

The `path` in the error is `inputSchema`: every flow needs one, even a flow that takes no input (`z.object({})`). The error comes when the class is decorated, so importing the file is enough to throw it.

### `Cannot read properties of undefined (reading 'parse')` from `this.respond()`

The flow has no `outputSchema`, and `respond()` parses the answer with it. Add one, like `z.object({ ... })`.

### TypeScript says `Argument of type '"desk:triage"' is not assignable to parameter of type 'keyof ExtendFlows'`

`FlowHooksOf()`, `FlowCtxOf`, `FlowBase` and `@Flow` only accept names in the global `ExtendFlows` interface. Add yours with `declare global { interface ExtendFlows { ... } }`, as in [Writing and running a flow](#writing-and-running-a-flow). Every built-in flow is in it, all 46 of them, `jobs:execute-job` (new in 1.9.4) included. (Changed in 1.9.3: before, 19 were missing, `session:verify`, `handle:mcp-202607280728`, `handle:streamable-http`, `elicitation:request` and `elicitation:result`, `well-known.jwks` and `http:ip-filter` among them, and code wrote `FlowHooksOf<any>("…")` with a `ctx` type of its own. `prompts:get-prompt`, `prompts:list-prompts`, `completion:complete` and the channel flows are typed since 1.9.0, with `PromptHook`, `ListPromptsHook` and `CompletionHook` exported for them.)
