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
@Workflowandsteps, and run it withexecute_workflow - How to pass one step's output to the next
- Which steps run at the same time, and what
maxConcurrencydoes - What a failing step does to the rest, and how to change it with
retry,continueOnErrorandcondition - 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:
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:
| 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. |
retry, timeout | Override the job's own. See When a step fails and Timeouts. |
continueOnError, condition | Let 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:
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.
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:
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:
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-callhas noretryof 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 withoutretry, on the step or on its job, gets the defaults. - The failed step's direct dependents were skipped.
escalatenever ran, and its state is"skipped". - Everything else ran.
reportdoesn't depend onpage, so the report was written. - The workflow failed, but the call didn't.
execute_workflowreturned normally, withstate: "failed"in the run. A client has to checkstate, not just whether the call succeeded. - The reason is gone. A failed step's result is
{ outputs: {}, state: "failed" }, whatever went wrong.Pager service unavailableis only in the server's log. emailran. In 1.8 only the direct dependents of a failed step are skipped. A skipped step counts as done for the steps after it, soemail, which depends onescalate, 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:
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: truelets a step fail without failing the workflow. Its state is still"failed", but its dependents run, and the workflow can complete.condition: (steps) => booleandecides whether a step runs at all. It's called withstepsonce the step'sdependsOnare done, and when it returnsfalsethe 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:
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:
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:
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 }), orworkflow({...}), chains jobs. Register it with@App({ workflows }), next to its jobs, and run it withexecute_workflow { name, input }. The result'sstepResultshas each step'soutputsandstate.- A step's
inputis the workflow's input when left out, a fixed object, or a function ofsteps. Only read steps listed independsOnwithsteps.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
stateis"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 independsOn. continueOnErrorlets a step fail without failing the workflow, andconditionskips a step. Steps after either one run, so checksteps.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 withget_job_status:get_workflow_statushas 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.
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.