@Workflow
@Workflow declares a workflow: a set of steps, each of which runs a 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.
@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.
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 {}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, and the whole server in the 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. 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 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. |
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 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. 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 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
- Every step whose
dependsOnsteps have all finished (completed or skipped) is ready. Up tomaxConcurrencyof them start together, as one batch. - The next batch starts when the whole batch has finished.
- When a step fails, the steps that depend on it directly are
skipped. Steps that don't depend on it keep running. - When every step has a result, the workflow is
failedif any step failed withoutcontinueOnError, andcompletedotherwise.
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.
Each attempt of a step runs the job's jobs:execute-job flow, as execute_job does, so a 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. (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:
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:
| 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. |
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 too. - Steps retry by default. A step whose job has no
retryis tried three times, so a failing step takes 3 seconds before it counts as failed.execute_jobruns 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'soutputs. Guard it with acondition. dependsOnmistakes are found when the workflow runs, not when the server starts.- A function
inputdoesn't get the workflow's input. Only steps withoutinputdo. Pass it through a step. - The workflow's
inputSchemaisn't checked, andtriggerandwebhookdon't do anything. - Jobs'
logsaren'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
permissionsthe caller doesn't meet fails. - A step's job calls tools on the
"job"surface, as it does fromexecute_job: a tool whoseavailableWhen.surfaceleaves out"job"isn't found, andgetCallSurface()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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Without the TC39 AsyncContext, FrontMCP's browser build 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, 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
Failed steps
Example 1 of 3
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
As with jobs, nobody can read an anonymous caller's runs, 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 covers when to run a workflow in the background, and the 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 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:
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"}`);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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.