# @Workflow

> Chain jobs into steps that run in order or in parallel, each step's input built from the results of the steps before it.

Source: https://frontmcp.dev/reference/sdk/workflow

`@Workflow` declares a [workflow](https://frontmcp.dev/learn/chaining-jobs-into-workflows): a set of steps, each of which runs a [job](https://frontmcp.dev/reference/sdk/job). A step starts once the steps it depends on have finished, steps that don't depend on each other run in parallel, and a step's input can be built from the results of the steps before it. A client runs a workflow by name with the built-in `execute_workflow` tool.

```ts
@Workflow(options)
class MyWorkflow {}
```

---

## Reference

### `@Workflow(options)`

Apply `@Workflow` to an empty class: everything is in the options. List the class in an app's `workflows` array, and the jobs its steps run in the app's `jobs`.

```ts nightly-report.workflow.ts
import { Workflow } from "@frontmcp/sdk";

@Workflow({
  name: "nightly-report",
  description: "Collect one day's tickets, find the SLA breaches, and write a report",
  steps: [
    { id: "collect", jobName: "collect-tickets", input: { day: "2026-09-26" } },
    {
      id: "breaches",
      jobName: "find-sla-breaches",
      dependsOn: ["collect"],
      input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }),
    },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["collect", "breaches"],
      input: (steps) => ({ tickets: steps.get("collect").outputs.tickets, breaches: steps.get("breaches").outputs.breaches }),
    },
  ],
})
export class NightlyReport {}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CollectTickets, FindSlaBreaches, WriteReport } from "./jobs";
import { NightlyReport } from "./nightly-report.workflow";

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

[See more examples below](#usage), and the whole server in the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example.

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | What clients pass to `execute_workflow`. Unique within the server. If you also set `id`, the id replaces it. |
| `steps` | `WorkflowStep[]` | The steps, at least one. See [steps](#steps). Their order in the array doesn't matter: `dependsOn` decides the order they run in. |

Optional:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `description` | `string` | | What the workflow does. `list_workflows` shows it. |
| `id` | `string` | `name` | A stable identifier. When set, it **replaces** `name`: clients and `list_workflows` use the id. |
| `maxConcurrency` | `number` | `5` | How many steps may run at the same time. |
| `timeout` | `number` | `600000` (10 minutes) | How long the whole workflow may run, in milliseconds. Checked each time a batch of steps finishes, so it doesn't interrupt a step that's running. |
| `inputSchema` | object of Zod types | | Describes the input a client passes to `execute_workflow`. FrontMCP 1.8 doesn't check the input against it. |
| `outputSchema` | an output schema | | Accepted, and not used by FrontMCP 1.8. |
| `trigger` | `"manual" \| "webhook" \| "event"` | `"manual"` | How the workflow is meant to start. `list_workflows` shows it; nothing else in FrontMCP 1.8 reads it. |
| `webhook` | `{ path?, secret?, methods? }` | | Settings for a webhook trigger. FrontMCP 1.8 doesn't serve a workflow's webhook, so they have no effect. (A [channel's webhook source](https://frontmcp.dev/reference/sdk/channel#sources) is served, since 1.8.5.) |
| `tags` | `string[]` | `[]` | Labels to filter `list_workflows` by. |
| `labels` | `Record<string, string>` | `{}` | Key-value labels to filter `list_workflows` by. |
| `hideFromDiscovery` | `boolean` | `false` | Leave the workflow out of `list_workflows`. It still runs by name. |
| `permissions` | `JobPermission[]` | none: anyone may run it | Who may run and list the workflow. The rules work as [for jobs](https://frontmcp.dev/reference/sdk/job#permissions). |

#### Steps

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | | **Required.** Unique within the workflow. It names the step in `dependsOn`, in `steps.get()` and in the result's `stepResults`. |
| `jobName` | `string` | | **Required.** The job to run: its `name`, or its `id` if it has one. |
| `input` | object, or `(steps) => object` | the workflow's input | The job's input. A function receives [`steps`](#stepsgetid) and builds the input from earlier steps' results. It doesn't receive the workflow's input. |
| `dependsOn` | `string[]` | `[]` | Steps that must finish first. A step with none starts right away. |
| `condition` | `(steps) => boolean` | | Checked when the step is ready to run. If it returns `false` or throws, the step is `skipped`. |
| `continueOnError` | `boolean` | `false` | If the step fails, run its dependents anyway, and don't fail the workflow because of it. |
| `retry` | `JobRetryConfig` | the job's `retry`, or else 3 attempts | Retries for this step, with the same fields as a [job's `retry`](https://frontmcp.dev/reference/sdk/job#retry). **Without `retry` on the step or the job, a step is tried three times**, with waits of 1 and 2 seconds. |
| `timeout` | `number` | the job's `timeout` (5 minutes) | How long one attempt may run, in milliseconds. A step that runs longer fails, but its job isn't stopped: it runs to the end, side effects included, and [job hooks](#seeing-each-steps-attempts) see that attempt end as if it hadn't timed out. |

The job still checks its input against its own `inputSchema`, so input that doesn't match fails the step.

#### `steps.get(id)`

In `input` and `condition`, `steps.get(id)` returns a finished step's result: `{ outputs, state }`, where `outputs` is what the step's job returned and `state` is `"completed"`, `"failed"` or `"skipped"`. A failed or skipped step's `outputs` is `{}`.

For a step that hasn't finished, or doesn't exist, `steps.get()` throws: in `input`, the step fails, and in `condition`, it's skipped. So list every step whose results a step reads in its `dependsOn`.

#### How steps run

1. Every step whose `dependsOn` steps have all finished (completed or skipped) is ready. Up to `maxConcurrency` of them start together, as one batch.
2. The next batch starts when the whole batch has finished.
3. When a step fails, the steps that depend on it directly are `skipped`. Steps that don't depend on it keep running.
4. When every step has a result, the workflow is `failed` if any step failed without `continueOnError`, and `completed` otherwise.

A skipped step counts as finished, so the steps after it run. That's what you want after a `condition` skip, but it also happens after a failure: [a step two levels below a failed step runs](#a-step-ran-even-though-a-step-before-it-failed).

Each attempt of a step runs the job's `jobs:execute-job` flow, as `execute_job` does, so a [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) sees it, and the job's own `permissions` are checked there against the caller who started the workflow. See [Seeing each step's attempts](#seeing-each-steps-attempts). (New in 1.9.4: before, steps ran outside any flow.)

#### `workflow(options)`

The function form takes the same options and returns something to list in `workflows`:

```ts
import { workflow } from "@frontmcp/sdk";

export const remindAda = workflow({
  name: "remind-ada",
  steps: [{ id: "count", jobName: "count-open-tickets", input: { customer: "Ada" } }],
});
```

### Built-in tools

When any app declares a job or a workflow, FrontMCP adds these tools to the server, next to [the job tools](https://frontmcp.dev/reference/sdk/job#built-in-tools):

| Tool | In `tools/list` | Arguments | Returns |
| --- | --- | --- | --- |
| `execute_workflow` | Yes | `name`, `input?` (an object), `background?` (default `false`) | Without `background`: waits for every step, then `{ runId, state, result }`, where `result` is `{ workflowName, state, stepResults, startedAt, completedAt }` and `stepResults` maps each step's `id` to its `{ outputs, state }`. With `background: true`: `{ runId, state: "pending" }` at once. |
| `get_workflow_status` | Yes | `runId` | `{ runId, workflowName, state, stepResults, startedAt, completedAt? }`. In FrontMCP 1.8, `stepResults` is always `{}`: read the steps with `get_job_status` instead, which returns the whole `result` for a workflow's `runId`. Only the user who started the run can read it, [as for jobs](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run). |
| `list_workflows` | No | `query?`, `tags?`, `labels?` | `{ workflows: [{ name, description?, trigger, stepCount, tags }], count }`, without the workflows the caller can't list or run. |
| `remove_workflow` | No | `name` | Removes a workflow registered at run time. For a `@Workflow` class it fails with `Workflow "…" is not dynamic and cannot be removed`. |

A run's `state` is `pending`, `running`, `completed` or `failed`. A workflow that ran, even with failed steps, is a successful `execute_workflow` call: check `state`, not `isError`. The call itself fails only when the workflow can't run: an unknown name, a caller without permission, a `dependsOn` mistake, or its `timeout`.

#### Caveats

- **The result doesn't say why a step failed.** A failed step is `{ outputs: {}, state: "failed" }`, whatever the reason; the error goes to the server's log, and an error of the step's job to [job hooks](#seeing-each-steps-attempts) too.
- **Steps retry by default.** A step whose job has no `retry` is tried three times, so a failing step takes 3 seconds before it counts as failed. `execute_job` runs the same job once.
- **A failure only skips the next step.** The steps after a skipped step run, so a step two levels below a failed step runs, with `{}` as the skipped step's `outputs`. [Guard it with a `condition`.](#a-step-ran-even-though-a-step-before-it-failed)
- **`dependsOn` mistakes are found when the workflow runs**, not when the server starts.
- **A function `input` doesn't get the workflow's input.** Only steps without `input` do. [Pass it through a step.](#a-steps-input-function-cant-see-the-workflows-input)
- **The workflow's `inputSchema` isn't checked**, and `trigger` and `webhook` don't do anything.
- **Jobs' `logs` aren't kept**: a workflow's result has no logs.
- A step runs its job as the caller who started the workflow, so a step whose job has `permissions` the caller doesn't meet fails.
- A step's job calls tools on the `"job"` surface, as it does from `execute_job`: a tool whose `availableWhen.surface` leaves out `"job"` isn't found, and [`getCallSurface()`](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool) is `"job"` in the job and the tools it calls.

---

## Usage

### Running steps one after another

A step that lists another in `dependsOn` waits for it, and its `input` function can read that step's `outputs`:

```ts remind-customer.workflow.ts active
import { Workflow } from "@frontmcp/sdk";

@Workflow({
  name: "remind-customer",
  description: "Count Ada's open tickets and draft a reminder about them",
  steps: [
    { id: "count", jobName: "count-open-tickets", input: { customer: "Ada" } },
    {
      id: "draft",
      jobName: "draft-reminder",
      dependsOn: ["count"],
      input: (steps) => steps.get("count").outputs,
    },
  ],
})
export class RemindCustomer {}
```

```ts jobs.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", customer: "Ada", status: "open" },
  { id: "T-2", customer: "Grace", status: "closed" },
  { id: "T-3", customer: "Ada", status: "open" },
];

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: tickets.filter((t) => t.customer === customer && t.status === "open").length };
  }
}

@Job({
  name: "draft-reminder",
  inputSchema: { customer: z.string(), open: z.number() },
  outputSchema: { message: z.string() },
})
export class DraftReminder extends JobContext {
  async execute({ customer, open }: { customer: string; open: number }) {
    return { message: `Hi ${customer}, you have ${open} open tickets. Reply to any of them if you still need help.` };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets, DraftReminder } from "./jobs";
import { RemindCustomer } from "./remind-customer.workflow";

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

```ts workflow.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Job, JobContext, Tool, ToolContext, Workflow, getCallSurface, z } from "@frontmcp/sdk";

test("each step's outputs are in stepResults", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_workflow", { name: "remind-customer" });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({
    runId: expect.any(String),
    state: "completed",
    result: {
      workflowName: "remind-customer",
      state: "completed",
      stepResults: {
        count: { state: "completed", outputs: { customer: "Ada", open: 2 } },
        draft: {
          state: "completed",
          outputs: { message: "Hi Ada, you have 2 open tickets. Reply to any of them if you still need help." },
        },
      },
      startedAt: expect.any(Number),
      completedAt: expect.any(Number),
    },
  });
});

test("list_workflows describes it", async ({ mcp }) => {
  const list = (await mcp.tools.call("list_workflows", {})).json();
  expect(list.workflows).toEqual([
    {
      name: "remind-customer",
      description: "Count Ada's open tickets and draft a reminder about them",
      trigger: "manual",
      stepCount: 2,
      tags: [],
    },
  ]);
});

test("a step's job calls tools on the `job` surface", async () => {
  @Tool({ name: "open_count", description: "Count open tickets, for jobs", inputSchema: {}, availableWhen: { surface: ["job"] } })
  class OpenCount extends ToolContext {
    async execute() {
      return { open: 2, surface: getCallSurface() };
    }
  }
  @Job({ name: "count-with-tool", inputSchema: {}, outputSchema: { open: z.number(), surface: z.string() } })
  class CountWithTool extends JobContext {
    async execute() {
      return (await this.callTool("open_count", {})).structuredContent as { open: number; surface: string };
    }
  }
  @Workflow({ name: "count-report", steps: [{ id: "count", jobName: "count-with-tool" }] })
  class CountReport {}
  @App({ id: "help-desk", name: "Help Desk", tools: [OpenCount], jobs: [CountWithTool], workflows: [CountReport] })
  class Desk {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const run = await server.callTool("execute_workflow", { name: "count-report" });
  const listed = (await server.listTools()).tools.map((tool) => tool.name);
  await server.dispose();
  expect(run.structuredContent).toMatchObject({ result: { stepResults: { count: { state: "completed", outputs: { open: 2, surface: "job" } } } } });
  expect(listed).not.toContain("open_count");
});
```

### Running steps in parallel

Steps that don't depend on each other start together, up to `maxConcurrency` at a time. Here three queues load at once, and `report` waits for all three:

```ts queue-report.workflow.ts active
import { Workflow } from "@frontmcp/sdk";

const loadSteps = ["billing", "login", "shipping"].map((queue) => ({
  id: queue,
  jobName: "load-queue",
  input: { queue },
}));

@Workflow({
  name: "queue-report",
  steps: [
    ...loadSteps,
    {
      id: "report",
      jobName: "sum-queues",
      dependsOn: ["billing", "login", "shipping"],
      input: (steps) => ({
        sizes: ["billing", "login", "shipping"].map((id) => steps.get(id).outputs.size as number),
      }),
    },
  ],
})
export class QueueReport {}

@Workflow({ name: "queue-report-one-at-a-time", maxConcurrency: 1, steps: loadSteps })
export class QueueReportOneAtATime {}
```

```ts jobs.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

const sizes: Record<string, number> = { billing: 4, login: 9, shipping: 2 };

@Job({
  name: "load-queue",
  inputSchema: { queue: z.string() },
  outputSchema: { queue: z.string(), size: z.number(), startedAt: z.number(), finishedAt: z.number() },
})
export class LoadQueue extends JobContext {
  async execute({ queue }: { queue: string }) {
    const startedAt = Date.now();
    await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query
    return { queue, size: sizes[queue], startedAt, finishedAt: Date.now() };
  }
}

@Job({ name: "sum-queues", inputSchema: { sizes: z.array(z.number()) }, outputSchema: { total: z.number() } })
export class SumQueues extends JobContext {
  async execute({ sizes }: { sizes: number[] }) {
    return { total: sizes.reduce((a, b) => a + b, 0) };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { LoadQueue, SumQueues } from "./jobs";
import { QueueReport, QueueReportOneAtATime } from "./queue-report.workflow";

@App({ id: "help-desk", name: "Help Desk", jobs: [LoadQueue, SumQueues], workflows: [QueueReport, QueueReportOneAtATime] })
export class HelpDeskApp {}
```

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

type Load = { startedAt: number; finishedAt: number };

async function loads(mcp: any, name: string): Promise<Load[]> {
  const { stepResults } = (await mcp.tools.call("execute_workflow", { name })).json().result;
  return ["billing", "login", "shipping"].map((id) => stepResults[id].outputs);
}

test("the three queues load at the same time", async ({ mcp }) => {
  const [a, b, c] = await loads(mcp, "queue-report");
  const lastStart = Math.max(a.startedAt, b.startedAt, c.startedAt);
  expect(lastStart).toBeLessThan(Math.min(a.finishedAt, b.finishedAt, c.finishedAt));
});

test("report runs after them, with their sizes", async ({ mcp }) => {
  const { stepResults } = (await mcp.tools.call("execute_workflow", { name: "queue-report" })).json().result;
  expect(stepResults.report).toEqual({ state: "completed", outputs: { total: 15 } });
});

test("with maxConcurrency: 1, each waits for the one before", async ({ mcp }) => {
  const [a, b, c] = await loads(mcp, "queue-report-one-at-a-time");
  expect(b.startedAt).toBeGreaterThanOrEqual(a.finishedAt);
  expect(c.startedAt).toBeGreaterThanOrEqual(b.finishedAt);
});
```

Without the TC39 `AsyncContext`, FrontMCP's [browser build](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) runs ready steps one at a time, as if `maxConcurrency` were `1`. (Changed in 1.8.5: before, steps that overlapped there failed and were [retried](#caveats), and the workflow could end `failed`.) The Playground provides a minimal `AsyncContext`, so here they run at the same time, as on Node.

### Passing input to a workflow

`execute_workflow`'s `input` goes to every step that has no `input` of its own. To use it in later steps, have the first step return it, and read it from that step's `outputs`:

```ts remind-customer.workflow.ts active
import { Workflow, z } from "@frontmcp/sdk";

@Workflow({
  name: "remind-customer",
  description: "Count a customer's open tickets and draft a reminder about them",
  inputSchema: { customer: z.string() },
  steps: [
    { id: "count", jobName: "count-open-tickets" }, // no input: gets the workflow's
    {
      id: "draft",
      jobName: "draft-reminder",
      dependsOn: ["count"],
      input: (steps) => steps.get("count").outputs, // includes the customer
    },
  ],
})
export class RemindCustomer {}
```

```ts jobs.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

const openTickets: Record<string, number> = { Ada: 2, Grace: 1 };

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: openTickets[customer] ?? 0 };
  }
}

@Job({
  name: "draft-reminder",
  inputSchema: { customer: z.string(), open: z.number() },
  outputSchema: { message: z.string() },
})
export class DraftReminder extends JobContext {
  async execute({ customer, open }: { customer: string; open: number }) {
    return { message: `Hi ${customer}, you have ${open} open tickets.` };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets, DraftReminder } from "./jobs";
import { RemindCustomer } from "./remind-customer.workflow";

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

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

test("the first step gets the workflow's input", async ({ mcp }) => {
  const { result } = (await mcp.tools.call("execute_workflow", { name: "remind-customer", input: { customer: "Grace" } })).json();
  expect(result.stepResults.draft.outputs).toEqual({ message: "Hi Grace, you have 1 open tickets." });
});

test("the workflow's inputSchema isn't checked, but the job's is", async ({ mcp }) => {
  const call = await mcp.tools.call("execute_workflow", { name: "remind-customer", input: { customer: 7 } });
  expect(call).toBeSuccessful();
  expect(call.json().result.stepResults).toEqual({
    count: { state: "failed", outputs: {} },
    draft: { state: "skipped", outputs: {} },
  });
});
```

The second test runs the workflow with a number for `customer`. `execute_workflow` accepts it, and the `count` step fails when its job checks the input, after three attempts.

### Handling a failed step

By default, a failed step skips the steps that depend on it and fails the workflow. `continueOnError` lets its dependents run anyway, and `condition` skips a step on purpose:

<Examples title="Failed steps">

#### Example: Skipped dependents
`notify` fails, so `archive`, which depends on it, is skipped. `stats` doesn't depend on it and runs. The call succeeds; the workflow's `state` is `failed`.

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

@Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } })
export class NotifyTeam extends JobContext {
  async execute(): Promise<{ sent: boolean }> {
    throw new Error("Mail server busy");
  }
}

@Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } })
export class ArchiveTickets extends JobContext {
  async execute() {
    return { archived: 12 };
  }
}

@Job({ name: "daily-stats", inputSchema: {}, outputSchema: { closed: z.number() } })
export class DailyStats extends JobContext {
  async execute() {
    return { closed: 12 };
  }
}

@Workflow({
  name: "close-day",
  steps: [
    { id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 } },
    { id: "archive", jobName: "archive-tickets", dependsOn: ["notify"] },
    { id: "stats", jobName: "daily-stats" },
  ],
})
export class CloseDay {}

@App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam, ArchiveTickets, DailyStats], workflows: [CloseDay] })
export class HelpDeskApp {}
```

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

test("the workflow fails, and the call doesn't", async ({ mcp }) => {
  const call = await mcp.tools.call("execute_workflow", { name: "close-day" });
  expect(call).toBeSuccessful();
  expect(call.json().state).toBe("failed");
  expect(call.json().result.stepResults).toEqual({
    notify: { state: "failed", outputs: {} },
    archive: { state: "skipped", outputs: {} },
    stats: { state: "completed", outputs: { closed: 12 } },
  });
});
```

#### Example: continueOnError
With `continueOnError: true` on `notify`, `archive` runs, and can check how `notify` went. The workflow is `completed`.

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

@Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } })
export class NotifyTeam extends JobContext {
  async execute(): Promise<{ sent: boolean }> {
    throw new Error("Mail server busy");
  }
}

@Job({ name: "archive-tickets", inputSchema: { teamNotified: z.boolean() }, outputSchema: { archived: z.number(), note: z.string() } })
export class ArchiveTickets extends JobContext {
  async execute({ teamNotified }: { teamNotified: boolean }) {
    return { archived: 12, note: teamNotified ? "Team notified" : "Team not notified" };
  }
}

@Workflow({
  name: "close-day",
  steps: [
    { id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 }, continueOnError: true },
    {
      id: "archive",
      jobName: "archive-tickets",
      dependsOn: ["notify"],
      input: (steps) => ({ teamNotified: steps.get("notify").state === "completed" }),
    },
  ],
})
export class CloseDay {}

@App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam, ArchiveTickets], workflows: [CloseDay] })
export class HelpDeskApp {}
```

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

test("archive runs after notify fails", async ({ mcp }) => {
  const { state, result } = (await mcp.tools.call("execute_workflow", { name: "close-day" })).json();
  expect(state).toBe("completed");
  expect(result.stepResults.notify).toEqual({ state: "failed", outputs: {} });
  expect(result.stepResults.archive.outputs).toEqual({ archived: 12, note: "Team not notified" });
});
```

#### Example: condition
`escalate` only runs when there are breached tickets. Today there are none, so it's `skipped`, and `report`, which depends on it, still runs.

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

@Job({ name: "find-sla-breaches", inputSchema: {}, outputSchema: { ids: z.array(z.string()) } })
export class FindSlaBreaches extends JobContext {
  async execute() {
    return { ids: [] as string[] };
  }
}

@Job({ name: "escalate", inputSchema: { ids: z.array(z.string()) }, outputSchema: { paged: z.string() } })
export class Escalate extends JobContext {
  async execute({ ids }: { ids: string[] }) {
    return { paged: `On-call lead, about ${ids.join(", ")}` };
  }
}

@Job({ name: "write-report", inputSchema: { escalated: z.boolean() }, outputSchema: { line: z.string() } })
export class WriteReport extends JobContext {
  async execute({ escalated }: { escalated: boolean }) {
    return { line: escalated ? "Breaches escalated." : "No escalation needed." };
  }
}

@Workflow({
  name: "sla-check",
  steps: [
    { id: "breaches", jobName: "find-sla-breaches" },
    {
      id: "escalate",
      jobName: "escalate",
      dependsOn: ["breaches"],
      condition: (steps) => (steps.get("breaches").outputs.ids as string[]).length > 0,
      input: (steps) => ({ ids: steps.get("breaches").outputs.ids }),
    },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["escalate"],
      input: (steps) => ({ escalated: steps.get("escalate").state === "completed" }),
    },
  ],
})
export class SlaCheck {}

@App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, Escalate, WriteReport], workflows: [SlaCheck] })
export class HelpDeskApp {}
```

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

test("escalate is skipped, and report still runs", async ({ mcp }) => {
  const { state, result } = (await mcp.tools.call("execute_workflow", { name: "sla-check" })).json();
  expect(state).toBe("completed");
  expect(result.stepResults.escalate).toEqual({ state: "skipped", outputs: {} });
  expect(result.stepResults.report.outputs).toEqual({ line: "No escalation needed." });
});
```

### Running a workflow in the background

With `background: true`, `execute_workflow` returns a `runId` at once. The user who started the run reads its steps with `get_job_status`: in FrontMCP 1.8, `get_workflow_status` returns the run's state, but never its steps.

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

@Job({ name: "load-queue", inputSchema: { queue: z.string() }, outputSchema: { queue: z.string(), size: z.number() } })
export class LoadQueue extends JobContext {
  async execute({ queue }: { queue: string }) {
    await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query
    return { queue, size: queue.length };
  }
}

@Workflow({
  name: "queue-report",
  steps: [
    { id: "billing", jobName: "load-queue", input: { queue: "billing" } },
    { id: "login", jobName: "load-queue", dependsOn: ["billing"], input: { queue: "login" } },
  ],
})
export class QueueReport {}

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

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

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the call returns before the steps run", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_workflow", { name: "queue-report", background: true });
  expect(result.json()).toEqual({ runId: expect.any(String), state: "pending" });
});

test("an anonymous caller can't read its own run", async ({ mcp }) => {
  const { runId } = (await mcp.tools.call("execute_workflow", { name: "queue-report", background: true })).json();
  const status = await mcp.tools.call("get_workflow_status", { runId });
  expect(status).toBeError("SERVER_ERROR");
  expect(status.text()).toMatch(new RegExp(`^Run "${runId}" not found`));
});

test("the owner reads the steps with get_job_status", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asNour = { authContext: { user: { sub: "nour" } } };
  const started = await server.callTool("execute_workflow", { name: "queue-report", background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };

  const running = await server.callTool("get_workflow_status", { runId }, asNour);
  await sleep(350);
  const done = await server.callTool("get_workflow_status", { runId }, asNour);
  const job = await server.callTool("get_job_status", { runId }, asNour);
  await server.dispose();

  expect(running.structuredContent).toMatchObject({ workflowName: "queue-report", state: "running" });
  expect(done.structuredContent).toMatchObject({ state: "completed", stepResults: {} });
  expect(job.structuredContent).toMatchObject({
    jobName: "queue-report",
    state: "completed",
    result: {
      stepResults: {
        billing: { state: "completed", outputs: { queue: "billing", size: 7 } },
        login: { state: "completed", outputs: { queue: "login", size: 5 } },
      },
    },
  });
});
```

As with jobs, [nobody can read an anonymous caller's runs](https://frontmcp.dev/reference/sdk/job#who-can-read-a-run), so the Playground's Call tab can start a background run but not poll it. The last test signs in as `nour`. [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows) covers when to run a workflow in the background, and the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example starts one from a tool and keeps its report where another tool can read it.

### Seeing each step's attempts

A step's result doesn't say why it failed, but a [`JobHook`](https://frontmcp.dev/reference/sdk/job#jobhook) sees every attempt of every step, and the job's error when one fails. For a step, `ctx.state.workflow` is `{ name, stepId, runId }`: the workflow, the step's `id` and the workflow run's id. `ctx.state.runId` is `undefined`, since the workflow run keeps the record:

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

export const stepLog: string[] = [];

@Plugin({ name: "step-log" })
export class StepLogPlugin extends DynamicPlugin<object> {
  @JobHook.Did("finalize", { filter: (ctx) => ctx.state.workflow !== undefined })
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { workflow, attempt, flowError } = ctx.state;
    stepLog.push(`${workflow?.name}/${workflow?.stepId} attempt ${attempt}: ${flowError ? flowError.message : "completed"}`);
  }
}
```

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

@Job({ name: "close-stale", inputSchema: {}, outputSchema: { closed: z.number() } })
export class CloseStale extends JobContext {
  async execute() {
    return { closed: 4 };
  }
}

@Job({ name: "escalate", inputSchema: {}, outputSchema: { paged: z.boolean() } })
export class Escalate extends JobContext {
  async execute(): Promise<{ paged: boolean }> {
    throw new Error("The on-call rota is empty");
  }
}

@Workflow({
  name: "close-day",
  steps: [
    { id: "close", jobName: "close-stale" },
    { id: "escalate", jobName: "escalate", retry: { maxAttempts: 2, backoffMs: 10 } },
  ],
})
export class CloseDay {}

@App({ id: "help-desk", name: "Help Desk", jobs: [CloseStale, Escalate], workflows: [CloseDay], plugins: [StepLogPlugin] })
export class HelpDeskApp {}
```

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

test("the hook sees each step's attempts, and why they failed", async ({ mcp }) => {
  stepLog.length = 0;
  const run = (await mcp.tools.call("execute_workflow", { name: "close-day" })).json();
  expect(run.result.stepResults.escalate).toEqual({ outputs: {}, state: "failed" });
  expect([...stepLog].sort()).toEqual([
    "close-day/close attempt 1: completed",
    "close-day/escalate attempt 1: The on-call rota is empty",
    "close-day/escalate attempt 2: The on-call rota is empty",
  ]);
});
```

The two steps don't depend on each other, so they run together, and the test sorts the lines. The same hook sees `execute_job`'s runs too, without `workflow`; the filter leaves them out. (New in 1.9.4: before, steps ran outside any flow, and nothing but the server's log saw their errors.)

---

## Troubleshooting

### A step failed, and the result doesn't say why

A step's result is `{ outputs: {}, state: "failed" }` whatever went wrong: the job threw, its input didn't match, it timed out, its `jobName` doesn't exist, or its `input` function read a step that hadn't finished. The error only goes to the server's log; a [job hook](#seeing-each-steps-attempts) also sees the errors of the step's job. To see it, run the step's job by itself with `execute_job` and the same input:

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

@Job({ name: "escalate", inputSchema: { ids: z.array(z.string()) }, outputSchema: { paged: z.string() } })
export class Escalate extends JobContext {
  async execute({ ids }: { ids: string[] }): Promise<{ paged: string }> {
    this.fail(new PublicMcpError("Nobody is on call tonight.", "NO_ON_CALL"));
  }
}

@Workflow({ name: "sla-check", steps: [{ id: "escalate", jobName: "escalate", input: { ids: ["T-7"] }, retry: { maxAttempts: 1 } }] })
export class SlaCheck {}

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

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

test("the workflow only says the step failed", async ({ mcp }) => {
  const { result } = (await mcp.tools.call("execute_workflow", { name: "sla-check" })).json();
  expect(result.stepResults.escalate).toEqual({ state: "failed", outputs: {} });
});

test("the job alone says why", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "escalate", input: { ids: ["T-7"] } });
  expect(result).toBeError("NO_ON_CALL");
  expect(result.text()).toBe("Nobody is on call tonight.");
});
```

If the step's input comes from a function, return the values it would build from an earlier step, and pass those.

### A failing step takes three seconds

A step whose job has no `retry` is tried three times, waiting 1 and then 2 seconds, so it fails after 3 seconds. Set `retry` on the step, or on the job, to change that:

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

@Job({ name: "notify-team", inputSchema: {}, outputSchema: { sent: z.boolean() } })
export class NotifyTeam extends JobContext {
  async execute(): Promise<{ sent: boolean }> {
    throw new Error("Mail server busy");
  }
}

@Workflow({ name: "notify", steps: [{ id: "notify", jobName: "notify-team" }] })
export class Notify {}

@Workflow({ name: "notify-once", steps: [{ id: "notify", jobName: "notify-team", retry: { maxAttempts: 1 } }] })
export class NotifyOnce {}

@App({ id: "help-desk", name: "Help Desk", jobs: [NotifyTeam], workflows: [Notify, NotifyOnce] })
export class HelpDeskApp {}
```

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

test("by default, the step fails after 3 seconds", async ({ mcp }) => {
  const started = Date.now();
  const { state } = (await mcp.tools.call("execute_workflow", { name: "notify" })).json();
  expect(state).toBe("failed");
  expect(Date.now() - started).toBeGreaterThanOrEqual(3000);
});

test("with maxAttempts: 1, it fails at once", async ({ mcp }) => {
  const started = Date.now();
  const { state } = (await mcp.tools.call("execute_workflow", { name: "notify-once" })).json();
  expect(state).toBe("failed");
  expect(Date.now() - started).toBeLessThan(1000);
});
```

### A step ran even though a step before it failed

When a step fails, FrontMCP 1.8 skips the steps that depend on it, and then treats those as finished, so the steps after them run. Here `archive` fails, `report` is skipped, and `notify`, which depends on `report`, runs, with `{}` as the report. A `condition` that checks the step before it stops that:

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

@Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } })
export class ArchiveTickets extends JobContext {
  async execute(): Promise<{ archived: number }> {
    throw new Error("Archive is full");
  }
}

@Job({ name: "write-report", inputSchema: {}, outputSchema: { text: z.string() } })
export class WriteReport extends JobContext {
  async execute() {
    return { text: "12 tickets archived" };
  }
}

@Job({ name: "email-report", inputSchema: { text: z.string().optional() }, outputSchema: { sent: z.string() } })
export class EmailReport extends JobContext {
  async execute({ text }: { text?: string }) {
    return { sent: text ?? "(no report)" };
  }
}

const steps = [
  { id: "archive", jobName: "archive-tickets", retry: { maxAttempts: 1 } },
  { id: "report", jobName: "write-report", dependsOn: ["archive"] },
];

@Workflow({
  name: "unguarded",
  steps: [
    ...steps,
    { id: "notify", jobName: "email-report", dependsOn: ["report"], input: (s) => s.get("report").outputs },
  ],
})
export class Unguarded {}

@Workflow({
  name: "guarded",
  steps: [
    ...steps,
    {
      id: "notify",
      jobName: "email-report",
      dependsOn: ["report"],
      condition: (s) => s.get("report").state === "completed", // ✅ only after a real report
      input: (s) => s.get("report").outputs,
    },
  ],
})
export class Guarded {}

@App({ id: "help-desk", name: "Help Desk", jobs: [ArchiveTickets, WriteReport, EmailReport], workflows: [Unguarded, Guarded] })
export class HelpDeskApp {}
```

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

test("without a condition, notify runs after the failure", async ({ mcp }) => {
  const { result } = (await mcp.tools.call("execute_workflow", { name: "unguarded" })).json();
  expect(result.stepResults.report.state).toBe("skipped");
  expect(result.stepResults.notify).toEqual({ state: "completed", outputs: { sent: "(no report)" } });
});

test("with the condition, notify is skipped too", async ({ mcp }) => {
  const { result } = (await mcp.tools.call("execute_workflow", { name: "guarded" })).json();
  expect(result.stepResults.notify).toEqual({ state: "skipped", outputs: {} });
});
```

### `Step "…" depends on unknown step "…"` or `Workflow has a cycle involving step "…"`

A step's `dependsOn` names a step id that isn't in `steps`, or steps depend on each other in a loop. FrontMCP checks this when the workflow runs, not when the server starts, so the server starts fine and every `execute_workflow` call fails:

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

@Job({ name: "archive-tickets", inputSchema: {}, outputSchema: { archived: z.number() } })
export class ArchiveTickets extends JobContext {
  async execute() {
    return { archived: 12 };
  }
}

@Workflow({
  name: "close-day",
  steps: [
    { id: "archive", jobName: "archive-tickets", dependsOn: ["notify"] }, // 🚩 there's no "notify" step
  ],
})
export class CloseDay {}

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

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

test("the call fails with the step's name", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_workflow", { name: "close-day" });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_workflow" execution failed: Step "archive" depends on unknown step "notify"\n/);
});
```

Fix the id, or break the loop. A test that runs each workflow once catches this before your users do.

### `Workflow "…" timed out after …ms`

The workflow ran longer than its `timeout`. FrontMCP checks it each time a batch of steps finishes, so the step that was running finished first, and the call fails with `TOOL_EXECUTION_ERROR` instead of returning the steps' results:

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

@Job({ name: "load-queue", inputSchema: { queue: z.string() }, outputSchema: { queue: z.string() } })
export class LoadQueue extends JobContext {
  async execute({ queue }: { queue: string }) {
    await new Promise((resolve) => setTimeout(resolve, 60));
    return { queue };
  }
}

@Workflow({
  name: "queue-report",
  timeout: 50,
  steps: [
    { id: "billing", jobName: "load-queue", input: { queue: "billing" } },
    { id: "login", jobName: "load-queue", dependsOn: ["billing"], input: { queue: "login" } },
  ],
})
export class QueueReport {}

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

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

test("the call fails after the first step", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_workflow", { name: "queue-report" });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_workflow" execution failed: Workflow "queue-report" timed out after 50ms\n/);
});
```

Raise `timeout`, or run the workflow in the background. To limit one step, set the step's `timeout`: a step that runs too long fails, and the workflow carries on as for any failed step.

### `Job or workflow "…" not found or not permitted`

`execute_workflow` found no workflow by that name that the caller may run. Check that the name matches `@Workflow({ name })` (or its `id`, if it has one), that the class is in an app's `workflows`, and that its `permissions` let the caller run it. A step whose `jobName` is wrong doesn't cause this error: that step fails.

### `get_workflow_status` returns an empty `stepResults`

In FrontMCP 1.8, `get_workflow_status` never fills in `stepResults`. Call `get_job_status` with the same `runId`: its `result` is the workflow's result, steps included. [Running a workflow in the background](#running-a-workflow-in-the-background) shows both.

### A step's `input` function can't see the workflow's input

An `input` function only receives `steps`. The workflow's input goes to steps without an `input`. Let the first step take it and return what later steps need, and read that from its `outputs`, as in [Passing input to a workflow](#passing-input-to-a-workflow).
