Nightly Ticket Report

Intermediate

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 dependsOn and 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.

Open
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

  1. A client calls start_nightly_report with a day. The tool starts the nightly-report workflow in the background and returns at once, with the run pending.
  2. The workflow runs each step once the steps it depends on have finished: collect finds the day's tickets, breaches picks the ones that broke their SLA, and report writes the report from both and saves it.
  3. If any ticket broke its SLA, notify emails the team lead. The mail server is busy on the first try, so the step waits and tries again.
  4. The client calls get_nightly_report with 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:

  • TicketStore stands in for the help desk's database: nine tickets over four days, each with the hours until its first reply, or null if nobody has replied yet.
  • ReportStore keeps finished reports by day.
  • Outbox stands 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

main.ts
@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

collect-tickets.job.ts
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

notify-lead.job.ts
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:

  • collect has no input, so it gets the workflow's input, { day }, from start_nightly_report.
  • breaches depends on collect, and its input function reads steps.get("collect").outputs.tickets.
  • report reads both earlier steps, so it lists both in dependsOn. A step can only read the steps it depends on: the others may not have finished yet.
  • notify has a condition, so on a day without breaches it's skipped, as a test checks. It also sets continueOnError: if the mail server stays down through all three attempts, the step fails, but the run still completed, 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

report.tools.ts
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.
  • ReportStore and TicketStore are your own providers: keep tickets and reports in your database.
  • Several server processes each have their own memory, so get_nightly_report could 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.

  1. Let get_nightly_report tell "still being built" apart from "never started": have start_nightly_report note the day in ReportStore before it starts the workflow.
  2. Count the breaches by priority in a new count-by-priority step that depends on breaches. It can run at the same time as report; make notify wait for both.
  3. Let whoever starts the report choose who gets the email. The day reaches collect as the workflow's input, but an input function can't see the workflow's input, so pass the address on through collect's outputs.
  4. Test what happens when the mail server is down for good: make Outbox always throw, and check that the run is completed and the notify step failed.