Chaining Jobs into Workflows

IntermediateMCP 2026-07-28

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

FieldWhat it does
idThe step's name within this workflow. Other steps use it in dependsOn and steps.get().
jobNameThe name of the job the step runs. If no job has that name, the step fails when the workflow runs.
dependsOnThe steps that must be done before this one starts. Without it, the step starts right away.
inputWhat the job gets. See below.
retry, timeoutOverride the job's own. See When a step fails and Timeouts.
continueOnError, conditionLet a step fail, or skip it. See 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:

{
  "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:

inputThe job gets
Left outThe workflow's input: the input passed to execute_workflow, or {}.
An objectThat object, on every run.
A functionWhat 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.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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. 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

A step's retry takes the same fields as a job's, 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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, 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, which under 2026-07-28 excludes anonymous callers. And in 1.8, reading it with get_workflow_status leaves out the steps:

Open
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");
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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 1 of 4

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.

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.