Nightly Ticket Report
Every morning, a support team lead wants to know which of yesterday's tickets broke their SLA: which customers waited too long for a first reply. This example is a help desk server that builds that report. A tool starts a workflow in the background; the workflow collects the day's tickets, finds the SLA breaches, writes the report and emails the lead, with a job for each step; and a second tool reads the report once it's ready.
You will learn
- How to split slow work into jobs, and wire them into a workflow with
dependsOnand inputs built from earlier steps - How to start a workflow from your own tool, in the background, and hand its result back through another tool
- How a step retries a flaky dependency, and how a step is skipped when there's nothing to do
- Where jobs get their data from, and why the providers go on the server
- How to test all of it, background run included
The server
Here is the whole server. Open the Call tab: start_nightly_report has started a report for 2026-09-26 and returned at once, with the run still pending. Now call get_nightly_report with the same day to read it. Then open the Tests tab.
import { Workflow } from "@frontmcp/sdk";
@Workflow({
name: "nightly-report",
description: "Collect one day's tickets, find the SLA breaches, write the report and email the team lead",
steps: [
// No `input`: this step gets the workflow's input, { day }.
{ id: "collect", jobName: "collect-tickets" },
{
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) => ({
day: steps.get("collect").outputs.day,
total: (steps.get("collect").outputs.tickets as unknown[]).length,
breaches: steps.get("breaches").outputs.breaches,
}),
},
{
id: "notify",
jobName: "notify-lead",
dependsOn: ["report"],
// Only email the lead when something broke its SLA.
condition: (steps) => (steps.get("breaches").outputs.breaches as unknown[]).length > 0,
input: (steps) => steps.get("report").outputs,
// The report is saved by now; a failed email shouldn't fail the run.
continueOnError: true,
},
],
})
export class NightlyReport {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
In the Call tab, ask get_nightly_report for 2026-09-26: the report lists two tickets, T-12 and T-14. Ask for 2026-09-25 before starting it, and the tool tells the model what to do instead. You can also run a single step on its own: execute_job with { "name": "collect-tickets", "input": { "day": "2026-09-26" } } returns the step's result and its log line.
How it fits together
- A client calls
start_nightly_reportwith a day. The tool starts thenightly-reportworkflow in the background and returns at once, with the runpending. - The workflow runs each step once the steps it depends on have finished:
collectfinds the day's tickets,breachespicks the ones that broke their SLA, andreportwrites the report from both and saves it. - If any ticket broke its SLA,
notifyemails the team lead. The mail server is busy on the first try, so the step waits and tries again. - The client calls
get_nightly_reportwith the same day to read the saved report.
Why two tools, rather than letting the client call execute_workflow and poll get_job_status itself? get_job_status only answers the user who started a run, and an anonymous client, like the Playground's, can't read its own runs at all. So the workflow saves its result where a tool can read it, and the model gets two tools whose names and descriptions say what they're for.
The files
stores.ts: the data the jobs share
Three providers, all GLOBAL, so every job and tool sees the same instance:
TicketStorestands in for the help desk's database: nine tickets over four days, each with the hours until its first reply, ornullif nobody has replied yet.ReportStorekeeps finished reports by day.Outboxstands in for a mail server that's busy on every other try, so you can watch a step retry.
ticketSchema and breachSchema are Zod schemas the jobs use in their inputSchema and outputSchema, so one step's output and the next step's input agree on a shape, and each job checks the input it receives.
main.ts: the providers go on the server
@FrontMcp({
info: { name: "Help Desk", version: "1.0.0" },
apps: [HelpDeskApp],
providers: [TicketStore, ReportStore, Outbox],
})A job gets its providers from the server, not from the app it's declared in. Move the three providers into the app's providers and the tools still work, but the jobs that use them fail with Provider "TicketStore" is not available. See Using a provider.
help-desk.app.ts: tools, jobs and a workflow in one app
The app lists two tools, four jobs and a workflow. Declaring jobs is enough for FrontMCP to add execute_job, execute_workflow, get_job_status and get_workflow_status, so a client sees six tools, as the first test checks. Doing Work in the Background explains when work belongs in a job rather than a tool.
collect-tickets.job.ts: the first step
async execute({ day }: { day: string }) {
const tickets: Ticket[] = this.get(TicketStore).openedOn(day);
this.log(`Collected ${tickets.length} tickets opened on ${day}`);
return { day, tickets };
}A job looks like a tool: an input schema, an output schema, and an execute() that returns the result. It returns day as well as the tickets, so later steps can read the day from this step's outputs. this.log() lines are part of the result when you run the job alone with execute_job; a workflow's result doesn't keep them. retry gives a database that's briefly down two more chances. Your First Job covers each part.
find-sla-breaches.job.ts: a step that doesn't retry
The job compares each ticket's first reply with the hours promised for its priority, and keeps the late ones and the unanswered ones. It sets retry: { maxAttempts: 1 } because it's a calculation: the same input fails the same way every time. Without retry, a workflow step is tried three times, with waits of 1 and 2 seconds, even though execute_job would run the same job once. That's worth deciding on purpose for every job a workflow uses; see the @Workflow caveats.
write-report.job.ts: saving the result
The job turns the counts and breaches into a few lines of text, saves them in ReportStore, and returns them too, as the notify step's input. Saving is what makes the report readable later: a background run's result is only available to the user who started it, through get_job_status.
notify-lead.job.ts: retrying a flaky dependency
retry: { maxAttempts: 3, backoffMs: 200 },Outbox.send() throws on the first try of each run, so the step fails, waits 200 milliseconds, and succeeds on its second attempt. One of the tests measures the wait. Retrying an email is safe: at worst, the lead gets it twice. Running Jobs in the Background covers retries and what to retry.
nightly-report.workflow.ts: wiring the steps
Each step names its job with jobName, the steps it needs with dependsOn, and its input:
collecthas noinput, so it gets the workflow's input,{ day }, fromstart_nightly_report.breachesdepends oncollect, and itsinputfunction readssteps.get("collect").outputs.tickets.reportreads both earlier steps, so it lists both independsOn. A step can only read the steps it depends on: the others may not have finished yet.notifyhas acondition, so on a day without breaches it'sskipped, as a test checks. It also setscontinueOnError: if the mail server stays down through all three attempts, the step fails, but the run stillcompleted, because the report was already saved.
Chaining Jobs into Workflows introduces each of these, and the @Workflow reference lists every step option.
report.tools.ts: starting the report and reading it back
const run = await this.callTool("execute_workflow", { name: "nightly-report", input: { day }, background: true });start_nightly_report starts the workflow with this.callTool(), which calls another tool of the same server, as the same caller. It's the call a client would make, so it returns { runId, state: "pending" }, and the tool passes that on. Because the run belongs to the same caller, a signed-in client can poll it with get_job_status and that runId: the last test does, as nour.
get_nightly_report reads ReportStore. When there's no report, it fails with a PublicMcpError that tells the model what to do next. It says the same when a step failed and the report was never saved. The run's result wouldn't say why either: in FrontMCP 1.8, the reason a step failed only goes to the server's log.
nightly-report.test.ts
Most tests run the workflow with execute_workflow, which waits for every step, so they can check each step's outputs directly: the tickets, the breaches, the report's text, the retried email and the skipped one. One test goes through the tools, the way the Playground's anonymous client would, and polls get_nightly_report until the background run has saved the report. The last one signs in as nour with FrontMcpInstance.createDirect(), and polls the run itself with get_job_status.
All the tests but the last share one server, and every run saves a report, so the test that reads one through the tools uses a day that no other test reports on. Testing Your Server covers the test API.
Running it every night
FrontMCP 1.8 doesn't run anything on a schedule: a workflow's trigger is only a label. Something outside the server has to call start_nightly_report each night, like a scheduled task on your platform that connects as an MCP client.
This example also keeps everything in memory, which is fine in the Playground and not in production:
- Runs are in memory unless you set
@FrontMcp({ jobs: { enabled: true, store: { redis } } }), so a restart loses them. The Playground can't use Redis. ReportStoreandTicketStoreare your own providers: keep tickets and reports in your database.- Several server processes each have their own memory, so
get_nightly_reportcould ask a process that didn't build the report. A shared database solves that too.
Ideas to try
Each of these is a change to the Playground above. Add a test for each.
- Let
get_nightly_reporttell "still being built" apart from "never started": havestart_nightly_reportnote the day inReportStorebefore it starts the workflow. - Count the breaches by priority in a new
count-by-prioritystep that depends onbreaches. It can run at the same time asreport; makenotifywait for both. - Let whoever starts the report choose who gets the email. The day reaches
collectas the workflow's input, but aninputfunction can't see the workflow's input, so pass the address on throughcollect's outputs. - Test what happens when the mail server is down for good: make
Outboxalways throw, and check that the run iscompletedand thenotifystepfailed.