# Chaining Jobs into Workflows

> How to chain FrontMCP jobs into a workflow with @Workflow, pass each step's output to the next, run independent steps in parallel, and decide what a failing step does to the rest.

Source: https://frontmcp.dev/learn/chaining-jobs-into-workflows

The help desk's nightly report is three jobs: collect the day's tickets, find the ones that broke their SLA, and write the report. A model could run them one by one with `execute_job`, copying each result into the next call. A **workflow** does that on the server: it names the jobs, says which ones need which, and says what each one gets as input. FrontMCP runs the steps in order, runs the ones that don't depend on each other at the same time, and returns what every step produced.

**You will learn**
- How to declare a workflow with `@Workflow` and `steps`, and run it with `execute_workflow`
- How to pass one step's output to the next
- Which steps run at the same time, and what `maxConcurrency` does
- What a failing step does to the rest, and how to change it with `retry`, `continueOnError` and `condition`
- How to run a workflow in the background, and read its steps afterwards

## One job at a time

With three jobs and no workflow, the report takes three round trips through the model. It calls `execute_job` for `collect-tickets`, reads the list of tickets, copies all of it into a call to `check-sla`, then copies the day, the count and the breaches into a call to `write-report`. Every ticket passes through the model's context twice, the model can copy something wrong at each step, and nothing happens on the server while the model is thinking. And if the report should be written every night, there may be no model around at all.

## Declaring a workflow

`@Workflow` lists the steps. Each step runs one job, and says which steps it depends on and where its input comes from. Open the **Tests** tab:

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

@Workflow({
  name: "nightly-ticket-report",
  description: "Collect a day's tickets, find the ones that broke their SLA, and write the report.",
  steps: [
    // No `input`: the step gets the workflow's input, { day }.
    { id: "collect", jobName: "collect-tickets" },
    {
      id: "breaches",
      jobName: "check-sla",
      dependsOn: ["collect"],
      input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }),
    },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["collect", "breaches"],
      input: (steps) => ({
        day: steps.get("collect").outputs.day,
        total: (steps.get("collect").outputs.tickets as Ticket[]).length,
        breaches: steps.get("breaches").outputs.breaches,
      }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

const ticket = z.object({ id: z.string(), priority: z.enum(["high", "normal"]), hoursToFirstReply: z.number() });
const SLA_HOURS = { high: 4, normal: 24 };

@Job({
  name: "collect-tickets",
  description: "Collect the tickets opened on one day.",
  inputSchema: { day: z.string().describe("The day, like 2026-09-26") },
  outputSchema: { day: z.string(), tickets: z.array(ticket) },
})
export class CollectTickets extends JobContext {
  async execute({ day }: { day: string }) {
    const tickets = ticketsByDay[day] ?? [];
    this.log(`Collected ${tickets.length} tickets for ${day}`);
    return { day, tickets };
  }
}

@Job({
  name: "check-sla",
  description: "Find the tickets whose first reply came later than their SLA allows.",
  inputSchema: { tickets: z.array(ticket) },
  outputSchema: { breaches: z.array(z.string()) },
})
export class CheckSla extends JobContext {
  async execute({ tickets }: { tickets: Ticket[] }) {
    return { breaches: tickets.filter((t) => t.hoursToFirstReply > SLA_HOURS[t.priority]).map((t) => t.id) };
  }
}

@Job({
  name: "write-report",
  description: "Write the nightly SLA report.",
  inputSchema: { day: z.string(), total: z.number(), breaches: z.array(z.string()) },
  outputSchema: { report: z.string() },
})
export class WriteReport extends JobContext {
  async execute({ day, total, breaches }: { day: string; total: number; breaches: string[] }) {
    const which = breaches.length ? `: ${breaches.join(", ")}` : "";
    return { report: `${day}: ${breaches.length} of ${total} tickets broke their SLA${which}.` };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CheckSla, CollectTickets, WriteReport } from "./jobs";
import { NightlyTicketReport } from "./nightly-report.workflow";

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

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

```ts store.ts
export type Ticket = { id: string; priority: "high" | "normal"; hoursToFirstReply: number };

export const ticketsByDay: Record<string, Ticket[]> = {
  "2026-09-26": [
    { id: "T-1", priority: "high", hoursToFirstReply: 6 },
    { id: "T-2", priority: "normal", hoursToFirstReply: 30 },
    { id: "T-3", priority: "high", hoursToFirstReply: 2 },
    { id: "T-4", priority: "normal", hoursToFirstReply: 5 },
  ],
  "2026-09-27": [{ id: "T-5", priority: "normal", hoursToFirstReply: 3 }],
};
```

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

test("the report names the tickets that broke their SLA", async ({ mcp }) => {
  const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } });
  expect(run).toBeSuccessful();
  expect(run.json()).toMatchObject({
    runId: expect.any(String),
    state: "completed",
    result: {
      workflowName: "nightly-ticket-report",
      state: "completed",
      stepResults: {
        collect: { state: "completed", outputs: { day: "2026-09-26" } },
        breaches: { state: "completed", outputs: { breaches: ["T-1", "T-2"] } },
        report: { state: "completed", outputs: { report: "2026-09-26: 2 of 4 tickets broke their SLA: T-1, T-2." } },
      },
    },
  });
});

test("a day without breaches, and no logs in the result", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-27" } })).json();
  expect(run.result.stepResults.report.outputs.report).toBe("2026-09-27: 0 of 1 tickets broke their SLA.");
  expect(run.logs).toBeUndefined();
});
```

Change `day` in the Call tab to `2026-09-27` and call it again. Each step takes:

| Field | What it does |
| --- | --- |
| `id` | The step's name within this workflow. Other steps use it in `dependsOn` and `steps.get()`. |
| `jobName` | The name of the job the step runs. If no job has that name, the step fails when the workflow runs. |
| `dependsOn` | The steps that must be done before this one starts. Without it, the step starts right away. |
| `input` | What the job gets. See [below](#passing-data-between-steps). |
| `retry`, `timeout` | Override the job's own. See [When a step fails](#when-a-step-fails) and [Timeouts](#timeouts). |
| `continueOnError`, `condition` | Let a step fail, or skip it. See [Optional steps](#optional-steps). |

A workflow is registered like a job, with `workflows: [...]` on `@App`, and its jobs go in `jobs`. (Most examples below leave `main.ts` out, and the Playground registers their jobs and workflows for you.) The class itself stays empty: everything is in the decorator. A client runs it with `execute_workflow`, which takes `name`, `input` and `background`, like `execute_job`, and answers with the run:

```json
{
  "runId": "0c8f1a52-…",
  "state": "completed",
  "result": {
    "workflowName": "nightly-ticket-report",
    "state": "completed",
    "stepResults": {
      "collect": { "outputs": { "day": "2026-09-26", "tickets": [/* … */] }, "state": "completed" },
      "breaches": { "outputs": { "breaches": ["T-1", "T-2"] }, "state": "completed" },
      "report": { "outputs": { "report": "2026-09-26: 2 of 4 tickets broke their SLA: T-1, T-2." }, "state": "completed" }
    },
    "startedAt": 1790467200000,
    "completedAt": 1790467200004
  }
}
```

`stepResults` has every step's `outputs`, what its job returned, and its `state`. There are no logs: the steps' `this.log()` lines only go to the server's log. So the model gets the whole report in one call, and so does a client that runs the workflow every night with no model involved.

## Passing data between steps

A step's `input` can be three things:

| `input` | The job gets |
| --- | --- |
| Left out | The workflow's input: the `input` passed to `execute_workflow`, or `{}`. |
| An object | That object, on every run. |
| A function | What the function returns. FrontMCP calls it just before the step starts, with `steps`. |

`steps.get(id)` returns a finished step's `{ outputs, state }`. It's the only thing an input function gets: the workflow's own input isn't passed to it, which is why `collect-tickets` returns the `day` it was given, for `write-report` to use. A workflow can also declare an `inputSchema`, but in 1.8 nothing checks `execute_workflow`'s input against it; each job checks its own.

> **Pitfall: Depend on every step you read**
`steps.get()` only knows steps that have finished. If `report` reads `breaches` but only lists `collect` in `dependsOn`, the two start together as soon as `collect` is done, and `report`'s input function runs before `breaches` has an answer:

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["collect"], // 🚩 reads "breaches" too
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

@Job({ name: "collect-tickets", inputSchema: { day: z.string() }, outputSchema: { tickets: z.array(z.string()) } })
export class CollectTickets extends JobContext {
  async execute({ day }: { day: string }) {
    return { tickets: ["T-1", "T-2", "T-3"] };
  }
}

@Job({ name: "check-sla", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute({ tickets }: { tickets: string[] }) {
    return { breaches: tickets.slice(0, 1) };
  }
}

@Job({ name: "write-report", inputSchema: { breaches: z.array(z.string()) }, outputSchema: { report: z.string() } })
export class WriteReport extends JobContext {
  async execute({ breaches }: { breaches: string[] }) {
    return { report: `${breaches.length} tickets broke their SLA.` };
  }
}
```

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

test("a step that reads a step it doesn't depend on fails", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } })).json();
  expect(run.state).toBe("failed");
  expect(run.result.stepResults.breaches.state).toBe("completed");
  expect(run.result.stepResults.report).toEqual({ outputs: {}, state: "failed" });
});
```

`report` fails, and the result doesn't say why. The reason is only in the server's log (the **Logs** tab): `Step "breaches" has not completed yet or does not exist`. Add `"breaches"` to `dependsOn` and it works.

## Steps that run at the same time

Steps that don't depend on each other don't wait for each other. The report now also counts tickets per customer, which has nothing to do with the SLA check, so both depend only on `collect` and run side by side. Paging the on-call agent needs the breaches, and nothing else:

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] }, // 100 ms
    { id: "customers", jobName: "count-by-customer", dependsOn: ["collect"] }, // 300 ms
    { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] },
    { id: "report", jobName: "write-report", dependsOn: ["breaches", "customers"] },
  ],
})
export class NightlyTicketReport {}

@Workflow({
  name: "one-at-a-time",
  maxConcurrency: 1,
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "customers", jobName: "count-by-customer" },
  ],
})
export class OneAtATime {}
```

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

// Each job records when it starts and ends, and takes as long as its real work would.
@Job({ name: "collect-tickets", inputSchema: {}, outputSchema: {} })
export class CollectTickets extends JobContext {
  async execute() {
    await work("collect-tickets", 10);
    return {};
  }
}

@Job({ name: "check-sla", inputSchema: {}, outputSchema: {} })
export class CheckSla extends JobContext {
  async execute() {
    await work("check-sla", 100);
    return {};
  }
}

@Job({ name: "count-by-customer", inputSchema: {}, outputSchema: {} })
export class CountByCustomer extends JobContext {
  async execute() {
    await work("count-by-customer", 300);
    return {};
  }
}

@Job({ name: "page-on-call", inputSchema: {}, outputSchema: {} })
export class PageOnCall extends JobContext {
  async execute() {
    await work("page-on-call", 10);
    return {};
  }
}

@Job({ name: "write-report", inputSchema: {}, outputSchema: {} })
export class WriteReport extends JobContext {
  async execute() {
    await work("write-report", 10);
    return {};
  }
}
```

```ts timeline.ts
export const timeline: string[] = [];

export async function work(job: string, ms: number) {
  timeline.push(`start ${job}`);
  await new Promise((resolve) => setTimeout(resolve, ms));
  timeline.push(`end ${job}`);
}
```

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

test("steps run in batches", async ({ mcp }) => {
  timeline.length = 0;
  const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} });
  const at = (event: string) => timeline.indexOf(event);

  // check-sla and count-by-customer run at the same time...
  expect(at("start count-by-customer")).toBeLessThan(at("end check-sla"));
  // ...so the whole run takes about 300 ms, not 400.
  expect(run.durationMs).toBeLessThan(400);
  // 🚩 page-on-call only needs check-sla, but waits for count-by-customer too.
  expect(at("start page-on-call")).toBeGreaterThan(at("end count-by-customer"));
});

test("with `maxConcurrency: 1`, steps run one at a time, in order", async ({ mcp }) => {
  timeline.length = 0;
  await mcp.tools.call("execute_workflow", { name: "one-at-a-time", input: {} });
  expect(timeline).toEqual(["start check-sla", "end check-sla", "start count-by-customer", "end count-by-customer"]);
});
```

FrontMCP runs a workflow in **batches**. It takes every step whose `dependsOn` are all done, starts up to `maxConcurrency` of them at once (5 by default), and waits until the whole batch has finished before it looks for the next steps. So `check-sla` and `count-by-customer` overlap, and the run takes about 300 ms instead of 400. But `page-on-call` could start after 100 ms, when `check-sla` is done, and instead it waits for `count-by-customer`: a batch is as slow as its slowest step.

`maxConcurrency` on `@Workflow` caps the batch. Steps beyond it wait for the next batch, in the order you declared them, and `maxConcurrency: 1` runs the steps one at a time. Lower it when the steps share something that can't take much at once, like a rate-limited API.

## When a step fails

The pager service is down tonight, so `page-on-call` fails. Two steps come after it: marking the late tickets as escalated, and emailing their customers that they were. The call in this example takes three seconds:

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] },
    { id: "escalate", jobName: "mark-escalated", dependsOn: ["page"] },
    { id: "email", jobName: "email-customers", dependsOn: ["escalate"] },
    { id: "report", jobName: "write-report", dependsOn: ["breaches"] },
  ],
})
export class NightlyTicketReport {}
```

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

export const pagerCalls: string[] = [];
export const outbox: string[] = [];

@Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute() {
    return { breaches: ["T-1", "T-2"] };
  }
}

@Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.string() } })
export class PageOnCall extends JobContext {
  async execute() {
    pagerCalls.push(new Date().toISOString());
    throw new Error("Pager service unavailable");
  }
}

@Job({ name: "mark-escalated", inputSchema: {}, outputSchema: { escalated: z.number() } })
export class MarkEscalated extends JobContext {
  async execute() {
    return { escalated: 2 };
  }
}

@Job({ name: "email-customers", inputSchema: {}, outputSchema: { sent: z.number() } })
export class EmailCustomers extends JobContext {
  async execute() {
    outbox.push("Your ticket was escalated to our on-call agent.");
    return { sent: 1 };
  }
}

@Job({ name: "write-report", inputSchema: {}, outputSchema: { report: z.string() } })
export class WriteReport extends JobContext {
  async execute() {
    return { report: "2 tickets broke their SLA." };
  }
}
```

```ts failure.test.ts
import { test, expect } from "@frontmcp/testing";
import { outbox, pagerCalls } from "./jobs";

test("what one failed step does to the rest", async ({ mcp }) => {
  pagerCalls.length = 0;
  outbox.length = 0;
  const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} });

  // The call succeeds; the run inside it failed.
  expect(call).toBeSuccessful();
  const run = call.json();
  expect(run.state).toBe("failed");

  // page-on-call was tried 3 times, 1 s and then 2 s apart, and the error isn't in the result.
  expect(pagerCalls).toHaveLength(3);
  expect(call.durationMs).toBeGreaterThanOrEqual(3_000);
  expect(run.result.stepResults.page).toEqual({ outputs: {}, state: "failed" });

  // The step that depends on it is skipped, and the ones that don't depend on it still run.
  expect(run.result.stepResults.escalate.state).toBe("skipped");
  expect(run.result.stepResults.report.state).toBe("completed");

  // 🚩 A step that depends on the skipped step runs: customers were told about an escalation that never happened.
  expect(run.result.stepResults.email.state).toBe("completed");
  expect(outbox).toHaveLength(1);
});
```

A lot happened in those three seconds:

- **The step was retried.** `page-on-call` has no `retry` of its own, and as a job, [it would run once](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job). As a workflow step it's tried three times, waiting 1 s and then 2 s, because in 1.8 a step without `retry`, on the step or on its job, gets the defaults.
- **The failed step's direct dependents were skipped.** `escalate` never ran, and its state is `"skipped"`.
- **Everything else ran.** `report` doesn't depend on `page`, so the report was written.
- **The workflow failed, but the call didn't.** `execute_workflow` returned normally, with `state: "failed"` in the run. A client has to check `state`, not just whether the call succeeded.
- **The reason is gone.** A failed step's result is `{ outputs: {}, state: "failed" }`, whatever went wrong. `Pager service unavailable` is only in the server's log.
- **`email` ran.** In 1.8 only the direct dependents of a failed step are skipped. A skipped step counts as done for the steps after it, so `email`, which depends on `escalate`, ran, and emailed customers about an escalation that never happened.

The fix for the last one is to list, in `dependsOn`, every step a step needs to have *succeeded*, not just the one right before it. A step with a failed step in its `dependsOn` is skipped. And a step that should fail fast gets a `retry` of its own:

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], retry: { maxAttempts: 1 } }, // ✅ fail at once
    { id: "escalate", jobName: "mark-escalated", dependsOn: ["page"] },
    { id: "email", jobName: "email-customers", dependsOn: ["page", "escalate"] }, // ✅ needs both to succeed
    { id: "report", jobName: "write-report", dependsOn: ["breaches"] },
  ],
})
export class NightlyTicketReport {}
```

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

export const pagerCalls: string[] = [];
export const outbox: string[] = [];

@Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute() {
    return { breaches: ["T-1", "T-2"] };
  }
}

@Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.string() } })
export class PageOnCall extends JobContext {
  async execute() {
    pagerCalls.push(new Date().toISOString());
    throw new Error("Pager service unavailable");
  }
}

@Job({ name: "mark-escalated", inputSchema: {}, outputSchema: { escalated: z.number() } })
export class MarkEscalated extends JobContext {
  async execute() {
    return { escalated: 2 };
  }
}

@Job({ name: "email-customers", inputSchema: {}, outputSchema: { sent: z.number() } })
export class EmailCustomers extends JobContext {
  async execute() {
    outbox.push("Your ticket was escalated to our on-call agent.");
    return { sent: 1 };
  }
}

@Job({ name: "write-report", inputSchema: {}, outputSchema: { report: z.string() } })
export class WriteReport extends JobContext {
  async execute() {
    return { report: "2 tickets broke their SLA." };
  }
}
```

```ts failure.test.ts
import { test, expect } from "@frontmcp/testing";
import { outbox, pagerCalls } from "./jobs";

test("the pager is tried once, and no customer is emailed", async ({ mcp }) => {
  pagerCalls.length = 0;
  outbox.length = 0;
  const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} });
  const { stepResults } = call.json().result;

  expect(pagerCalls).toHaveLength(1);
  expect(call.durationMs).toBeLessThan(500);
  expect(stepResults.escalate.state).toBe("skipped");
  expect(stepResults.email.state).toBe("skipped");
  expect(outbox).toEqual([]);
  expect(stepResults.report.state).toBe("completed");
});
```

A step's `retry` takes the same fields as a [job's](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job), and replaces the job's own for that step. Set it on every step whose job has no `retry`, unless three tries over three seconds is what you want.

## Optional steps

Two options make a step optional, in different ways:

- **`continueOnError: true`** lets a step fail without failing the workflow. Its state is still `"failed"`, but its dependents run, and the workflow can complete.
- **`condition: (steps) => boolean`** decides whether a step runs at all. It's called with `steps` once the step's `dependsOn` are done, and when it returns `false` the step is `"skipped"`.

Posting the report to the team's chat is nice to have, and the chat service is down. Paging on-call only makes sense when something broke:

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    {
      id: "page",
      jobName: "page-on-call",
      dependsOn: ["breaches"],
      condition: (steps) => (steps.get("breaches").outputs.breaches as string[]).length > 0,
      input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }),
    },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["breaches", "page"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }),
    },
    {
      id: "post",
      jobName: "post-to-chat",
      dependsOn: ["report"],
      input: (steps) => ({ text: steps.get("report").outputs.report }),
      continueOnError: true,
      retry: { maxAttempts: 1 },
    },
  ],
})
export class NightlyTicketReport {}
```

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

const breachesByDay: Record<string, string[]> = { "2026-09-26": ["T-1", "T-2"], "2026-09-27": [] };
export const pages: string[][] = [];

@Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute({ day }: { day: string }) {
    return { breaches: breachesByDay[day] ?? [] };
  }
}

@Job({ name: "page-on-call", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { paged: z.boolean() } })
export class PageOnCall extends JobContext {
  async execute({ tickets }: { tickets: string[] }) {
    pages.push(tickets);
    return { paged: true };
  }
}

@Job({
  name: "write-report",
  inputSchema: { breaches: z.array(z.string()), paged: z.boolean() },
  outputSchema: { report: z.string() },
})
export class WriteReport extends JobContext {
  async execute({ breaches, paged }: { breaches: string[]; paged: boolean }) {
    return { report: `${breaches.length} tickets broke their SLA. On-call was ${paged ? "" : "not "}paged.` };
  }
}

@Job({ name: "post-to-chat", inputSchema: { text: z.string() }, outputSchema: { posted: z.boolean() } })
export class PostToChat extends JobContext {
  async execute() {
    throw new Error("Chat service unavailable");
  }
}
```

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

const run = async (mcp: any, day: string) =>
  (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json();

test("with no breaches, on-call isn't paged, and the report still runs", async ({ mcp }) => {
  pages.length = 0;
  const { state, result } = await run(mcp, "2026-09-27");
  expect(state).toBe("completed");
  expect(result.stepResults.page).toEqual({ outputs: {}, state: "skipped" });
  expect(result.stepResults.report.outputs.report).toBe("0 tickets broke their SLA. On-call was not paged.");
  expect(pages).toEqual([]);
});

test("with breaches, on-call is paged", async ({ mcp }) => {
  const { result } = await run(mcp, "2026-09-26");
  expect(result.stepResults.page.state).toBe("completed");
  expect(result.stepResults.report.outputs.report).toBe("2 tickets broke their SLA. On-call was paged.");
});

test("a failed chat post doesn't fail the workflow", async ({ mcp }) => {
  const { state, result } = await run(mcp, "2026-09-26");
  expect(result.stepResults.post.state).toBe("failed");
  expect(state).toBe("completed");
});
```

`report` depends on `page`, which is skipped on a quiet night, and runs anyway: a skipped step, whether a `condition` skipped it or a failure did, counts as done for the steps after it. So a step after an optional one has to handle both cases. `report` does that in its input function: `steps.get("page").state` is `"completed"`, `"failed"` or `"skipped"`.

## Timeouts

A step can't run forever. It gets the job's `timeout`, 5 minutes unless the job sets one, or the step's own `timeout`, and when that runs out the step fails. The job itself keeps going, since nothing stops it, and in 1.8 `execute_job` doesn't apply `timeout` at all:

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

export const exports: string[] = [];

@Job({
  name: "export-tickets",
  description: "Export every ticket as CSV.",
  inputSchema: {},
  outputSchema: { rows: z.number() },
  timeout: 200,
})
export class ExportTickets extends JobContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 500)); // takes longer than its timeout
    exports.push(new Date().toISOString());
    return { rows: 5 };
  }
}

@Workflow({
  name: "nightly-export",
  steps: [{ id: "export", jobName: "export-tickets", retry: { maxAttempts: 1 } }],
})
export class NightlyExport {}
```

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

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

test("as a step, the job fails after 200 ms, and keeps running", async ({ mcp }) => {
  exports.length = 0;
  const call = await mcp.tools.call("execute_workflow", { name: "nightly-export" });
  expect(call.json().result.stepResults.export).toEqual({ outputs: {}, state: "failed" });
  expect(call.durationMs).toBeLessThan(400);

  await wait(400);
  expect(exports).toHaveLength(1);
});

test("with execute_job, the timeout doesn't apply", async ({ mcp }) => {
  const call = await mcp.tools.call("execute_job", { name: "export-tickets" });
  expect(call.json()).toMatchObject({ state: "completed", result: { rows: 5 } });
  expect(call.durationMs).toBeGreaterThanOrEqual(500);
});
```

Since a timed-out job isn't stopped, a step with a `timeout` and a `retry` can start its next attempt while the last one is still running. As with [retries](https://frontmcp.dev/learn/running-jobs-in-the-background#retrying-a-flaky-job), make such a job safe to run twice.

## Running a workflow in the background

`execute_workflow` takes `background: true`, like `execute_job`, and returns `{ runId, state: "pending" }` at once. The same rule decides who can read the run: [only the caller who started it](https://frontmcp.dev/learn/running-jobs-in-the-background#who-can-read-a-run), which under 2026-07-28 excludes anonymous callers. And in 1.8, reading it with `get_workflow_status` leaves out the steps:

```ts status.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("get_workflow_status has no steps; get_job_status has them all", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const started = await server.callTool("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" }, background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };
  await wait(100);

  const workflowStatus = (await server.callTool("get_workflow_status", { runId }, asNour)).structuredContent;
  const jobStatus = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any;
  await server.dispose();

  expect(workflowStatus).toMatchObject({ runId, workflowName: "nightly-ticket-report", state: "completed", stepResults: {} });
  expect(jobStatus).toMatchObject({ runId, jobName: "nightly-ticket-report", state: "completed" });
  expect(jobStatus.result.stepResults.report.outputs).toEqual({ report: "2026-09-26: 2 tickets broke their SLA." });
});

test("`trigger` is only a label", async ({ mcp }) => {
  const { workflows } = (await mcp.tools.call("list_workflows", {})).json();
  expect(workflows).toEqual([expect.objectContaining({ name: "nightly-ticket-report", trigger: "webhook", stepCount: 2 })]);
  const run = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day: "2026-09-26" } });
  expect(run.json().state).toBe("completed");
});
```

```ts main.ts
import { App, FrontMcp, workflow } from "@frontmcp/sdk";
import { CheckSla, WriteReport } from "./jobs";

// A workflow can also be written as a function, like a job.
const NightlyTicketReport = workflow({
  name: "nightly-ticket-report",
  trigger: "webhook",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "report", jobName: "write-report", dependsOn: ["breaches"], input: (steps) => steps.get("breaches").outputs },
  ],
});

@App({ id: "help-desk", name: "Help Desk", jobs: [CheckSla, WriteReport], workflows: [NightlyTicketReport] })
class HelpDeskApp {}

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

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

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

@Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { day: z.string(), breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute({ day }: { day: string }) {
    return { day, breaches: ["T-1", "T-2"] };
  }
}

@Job({ name: "write-report", inputSchema: { day: z.string(), breaches: z.array(z.string()) }, outputSchema: { report: z.string() } })
export class WriteReport extends JobContext {
  async execute({ day, breaches }: { day: string; breaches: string[] }) {
    return { report: `${day}: ${breaches.length} tickets broke their SLA.` };
  }
}
```

`get_workflow_status` returns the run's `state`, times and name, with `stepResults` always empty in 1.8. A workflow's run is stored like a job's, and `get_job_status` with the same `runId` returns all of it: `jobName` is the workflow's name, and `result` is what `execute_workflow` would have returned inline, `stepResults` included. Until that's fixed, read workflow runs with `get_job_status`.

> **Note**
`trigger` on `@Workflow` takes `"manual"` (the default), `"webhook"` or `"event"`, and `webhook` takes a path, a secret and methods. In 1.8 they're labels: `list_workflows` reports `trigger`, and nothing else reads them. No route listens for webhooks, and no event starts a workflow, so every workflow runs through `execute_workflow`, whatever its `trigger` says.

## Recap

- `@Workflow({ name, steps })`, or `workflow({...})`, chains jobs. Register it with `@App({ workflows })`, next to its jobs, and run it with `execute_workflow { name, input }`. The result's `stepResults` has each step's `outputs` and `state`.
- A step's `input` is the workflow's input when left out, a fixed object, or a function of `steps`. Only read steps listed in `dependsOn` with `steps.get(id)`, and note that the function doesn't get the workflow's input.
- Steps run in batches of up to `maxConcurrency` (5). Independent steps overlap, and each batch waits for its slowest step.
- A failed step is retried 3 times by default, its direct dependents are skipped, the rest run, and the run's `state` is `"failed"` in a successful call. The error isn't in the result, and in 1.8 steps after a skipped step still run: list every step you need in `dependsOn`.
- `continueOnError` lets a step fail without failing the workflow, and `condition` skips a step. Steps after either one run, so check `steps.get(id).state`.
- Steps time out after their job's `timeout` (5 minutes by default), but the job keeps running. In 1.8, read background workflow runs with `get_job_status`: `get_workflow_status` has no steps.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Hand the breaches to the report
The report always says "0 of 0 tickets broke their SLA", because its step has a fixed input. Make `report` run after `collect` and `breaches`, and give it the day, the number of tickets and the breaches from those steps.

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) },
    { id: "report", jobName: "write-report", input: { day: "", total: 0, breaches: [] } },
  ],
})
export class NightlyTicketReport {}
```

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"], input: (steps) => ({ tickets: steps.get("collect").outputs.tickets }) },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["collect", "breaches"],
      input: (steps) => ({
        day: steps.get("collect").outputs.day,
        total: (steps.get("collect").outputs.tickets as unknown[]).length,
        breaches: steps.get("breaches").outputs.breaches,
      }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

const ticket = z.object({ id: z.string(), priority: z.enum(["high", "normal"]), hoursToFirstReply: z.number() });
const SLA_HOURS = { high: 4, normal: 24 };

@Job({
  name: "collect-tickets",
  inputSchema: { day: z.string() },
  outputSchema: { day: z.string(), tickets: z.array(ticket) },
})
export class CollectTickets extends JobContext {
  async execute({ day }: { day: string }) {
    return { day, tickets: ticketsByDay[day] ?? [] };
  }
}

@Job({
  name: "check-sla",
  inputSchema: { tickets: z.array(ticket) },
  outputSchema: { breaches: z.array(z.string()) },
})
export class CheckSla extends JobContext {
  async execute({ tickets }: { tickets: Ticket[] }) {
    return { breaches: tickets.filter((t) => t.hoursToFirstReply > SLA_HOURS[t.priority]).map((t) => t.id) };
  }
}

@Job({
  name: "write-report",
  inputSchema: { day: z.string(), total: z.number(), breaches: z.array(z.string()) },
  outputSchema: { report: z.string() },
})
export class WriteReport extends JobContext {
  async execute({ day, total, breaches }: { day: string; total: number; breaches: string[] }) {
    return { report: `${day}: ${breaches.length} of ${total} tickets broke their SLA.` };
  }
}
```

```ts store.ts
export type Ticket = { id: string; priority: "high" | "normal"; hoursToFirstReply: number };

export const ticketsByDay: Record<string, Ticket[]> = {
  "2026-09-26": [
    { id: "T-1", priority: "high", hoursToFirstReply: 6 },
    { id: "T-2", priority: "normal", hoursToFirstReply: 30 },
    { id: "T-3", priority: "high", hoursToFirstReply: 2 },
    { id: "T-4", priority: "normal", hoursToFirstReply: 5 },
  ],
  "2026-09-27": [{ id: "T-5", priority: "normal", hoursToFirstReply: 3 }],
};
```

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

const report = async (mcp: any, day: string) =>
  (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json().result.stepResults.report;

test("the 2026-09-26 report counts 2 of 4 tickets", async ({ mcp }) => {
  expect(await report(mcp, "2026-09-26")).toEqual({ state: "completed", outputs: { report: "2026-09-26: 2 of 4 tickets broke their SLA." } });
});

test("the 2026-09-27 report counts 0 of 1", async ({ mcp }) => {
  expect((await report(mcp, "2026-09-27")).outputs.report).toBe("2026-09-27: 0 of 1 tickets broke their SLA.");
});
```

**Hint:**
A step's `input` can be a function of `steps`. Which steps must be done before it can read them?

**Solution:**
`dependsOn: ["collect", "breaches"]` makes `report` wait for both, and its input function reads their outputs with `steps.get()`. The day comes from `collect`'s output, not from the workflow's input, because an input function only gets `steps`; that's why `collect-tickets` returns the `day` it was given.

### Challenge: Check both at once
Counting tickets per customer doesn't need the SLA check, but the workflow runs one after the other, and the run takes more than 600 ms. Make the two run at the same time. The report still needs both.

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] },
    { id: "customers", jobName: "count-by-customer", dependsOn: ["breaches"] },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["customers"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, customers: steps.get("customers").outputs.customers }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "collect", jobName: "collect-tickets" },
    { id: "breaches", jobName: "check-sla", dependsOn: ["collect"] },
    { id: "customers", jobName: "count-by-customer", dependsOn: ["collect"] },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["breaches", "customers"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, customers: steps.get("customers").outputs.customers }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

const slow = () => new Promise((resolve) => setTimeout(resolve, 300));

@Job({ name: "collect-tickets", inputSchema: {}, outputSchema: {} })
export class CollectTickets extends JobContext {
  async execute() {
    return {};
  }
}

@Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.number() } })
export class CheckSla extends JobContext {
  async execute() {
    await slow();
    return { breaches: 2 };
  }
}

@Job({ name: "count-by-customer", inputSchema: {}, outputSchema: { customers: z.number() } })
export class CountByCustomer extends JobContext {
  async execute() {
    await slow();
    return { customers: 3 };
  }
}

@Job({ name: "write-report", inputSchema: { breaches: z.number(), customers: z.number() }, outputSchema: { report: z.string() } })
export class WriteReport extends JobContext {
  async execute({ breaches, customers }: { breaches: number; customers: number }) {
    return { report: `${breaches} SLA breaches across ${customers} customers.` };
  }
}
```

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

test("the report still has both numbers", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} })).json();
  expect(run.state).toBe("completed");
  expect(run.result.stepResults.report.outputs.report).toBe("2 SLA breaches across 3 customers.");
});

test("the run takes less than 500 ms", async ({ mcp }) => {
  const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} });
  expect(call.durationMs).toBeLessThan(500);
});
```

**Hint:**
A step starts in the first batch after every step in its `dependsOn` is done. Which step does `count-by-customer` really need?

**Solution:**
`customers` now depends on `collect`, like `breaches`, so the two are ready together and run in the same batch. `report` has to list both in `dependsOn`: before, it got `breaches` through `customers`, and without it, it would start alongside `breaches` and fail to read it.

### Challenge: Keep the report when paging fails
The pager is down, so `page` fails, and with it the whole workflow: the report is skipped, and the call takes three seconds of retries. Make the workflow complete with a report that says on-call wasn't paged, and make a failed page fail at once.

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "page", jobName: "page-on-call", dependsOn: ["breaches"] },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["breaches", "page"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    { id: "page", jobName: "page-on-call", dependsOn: ["breaches"], continueOnError: true, retry: { maxAttempts: 1 } },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["breaches", "page"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches, paged: steps.get("page").state === "completed" }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

@Job({ name: "check-sla", inputSchema: {}, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute() {
    return { breaches: ["T-1", "T-2"] };
  }
}

@Job({ name: "page-on-call", inputSchema: {}, outputSchema: { paged: z.boolean() } })
export class PageOnCall extends JobContext {
  async execute() {
    throw new Error("Pager service unavailable");
  }
}

@Job({
  name: "write-report",
  inputSchema: { breaches: z.array(z.string()), paged: z.boolean() },
  outputSchema: { report: z.string() },
})
export class WriteReport extends JobContext {
  async execute({ breaches, paged }: { breaches: string[]; paged: boolean }) {
    return { report: `${breaches.length} tickets broke their SLA. On-call was ${paged ? "" : "not "}paged.` };
  }
}
```

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

test("the workflow completes, and says on-call wasn't paged", async ({ mcp }) => {
  const call = await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: {} });
  const run = call.json();
  expect(run.state).toBe("completed");
  expect(run.result.stepResults.page.state).toBe("failed");
  expect(run.result.stepResults.report.outputs.report).toBe("2 tickets broke their SLA. On-call was not paged.");
  expect(call.durationMs).toBeLessThan(1_000);
});
```

**Hint:**
Two step options: one lets a step fail without taking its dependents down, and one says how many times to try it.

**Solution:**
`continueOnError: true` keeps `page`'s state `"failed"` but lets `report` run, and the workflow completes. `report`'s input function already checks `steps.get("page").state`, so the report says what happened. `retry: { maxAttempts: 1 }` stops the step from getting the default three tries, which took three seconds.

### Challenge: Only page when something broke
On-call gets paged every night, even when no ticket broke its SLA. Skip the `page` step when `check-sla` finds no breaches, and keep paging when it finds some.

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    {
      id: "page",
      jobName: "page-on-call",
      dependsOn: ["breaches"],
      input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "check-sla" },
    {
      id: "page",
      jobName: "page-on-call",
      dependsOn: ["breaches"],
      condition: (steps) => (steps.get("breaches").outputs.breaches as string[]).length > 0,
      input: (steps) => ({ tickets: steps.get("breaches").outputs.breaches }),
    },
  ],
})
export class NightlyTicketReport {}
```

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

const breachesByDay: Record<string, string[]> = { "2026-09-26": ["T-1", "T-2"], "2026-09-27": [] };
export const pages: string[][] = [];

@Job({ name: "check-sla", inputSchema: { day: z.string() }, outputSchema: { breaches: z.array(z.string()) } })
export class CheckSla extends JobContext {
  async execute({ day }: { day: string }) {
    return { breaches: breachesByDay[day] ?? [] };
  }
}

@Job({ name: "page-on-call", inputSchema: { tickets: z.array(z.string()) }, outputSchema: { paged: z.boolean() } })
export class PageOnCall extends JobContext {
  async execute({ tickets }: { tickets: string[] }) {
    pages.push(tickets);
    return { paged: true };
  }
}
```

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

const run = async (mcp: any, day: string) =>
  (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { day } })).json();

test("a night without breaches skips `page`", async ({ mcp }) => {
  pages.length = 0;
  const { state, result } = await run(mcp, "2026-09-27");
  expect(state).toBe("completed");
  expect(result.stepResults.page.state).toBe("skipped");
  expect(pages).toEqual([]);
});

test("a night with breaches still pages on-call", async ({ mcp }) => {
  const { result } = await run(mcp, "2026-09-26");
  expect(result.stepResults.page.state).toBe("completed");
});
```

**Hint:**
A step can decide whether to run. It gets the same `steps` an input function gets.

**Solution:**
`condition` is called once `breaches` is done, and returning `false` marks `page` as `"skipped"` without running its job, so nobody is paged. The workflow still completes: a skipped step isn't a failure. If another step depended on `page`, it would still run, so it would have to check `steps.get("page").state`.
