Doing Work in the Background

Intermediate

Every tool in this course so far does its work while the client waits, and answers in the same call. That's right for most tools. Some work doesn't fit: an export that takes minutes, a sync with a CRM that fails now and then and should just be tried again, a nightly report made of several steps, or anything that should keep going after the call has returned. FrontMCP has jobs and workflows for this. This chapter covers both with FrontMCP 1.8, and points out where 1.8 behaves differently from its docs.

In this chapter

Your first job

A job is written like a tool: a class with an input schema, an output schema and an execute() method. What's different is how it runs. A client doesn't call a job directly. It calls execute_job with the job's name, and FrontMCP runs the job, records the run, and returns the result with the job's logs:

Open
import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", priority: "high", status: "open", hoursOpen: 6 },
  { id: "T-2", priority: "normal", status: "open", hoursOpen: 30 },
  { id: "T-3", priority: "high", status: "open", hoursOpen: 2 },
];

@Job({
  name: "find-sla-breaches",
  description: "Find the open tickets of one priority that have waited longer than their SLA allows.",
  inputSchema: { priority: z.enum(["high", "normal"]) },
  outputSchema: { breaches: z.array(z.string()) },
})
class FindSlaBreaches extends JobContext {
  async execute({ priority }: { priority: "high" | "normal" }) {
    const limit = priority === "high" ? 4 : 24;
    const breaches = tickets.filter((t) => t.priority === priority && t.hoursOpen > limit).map((t) => t.id);
    this.log(`${breaches.length} ${priority}-priority tickets are past their SLA`);
    return { breaches };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Open the Capabilities tab: the job isn't a tool. Registering it with @App({ jobs }) added four tools of FrontMCP's own, the same four for any server with jobs or workflows:

ToolWhat it does
execute_jobRuns a job by name, and returns the run: at the end of the call, or, with background: true, at once.
get_job_statusReads a run by its id: its state, and its result or error.
execute_workflowRuns a workflow, the same way.
get_workflow_statusReads a workflow's run.

Nothing in tools/list says which jobs exist, so a server has to tell the model, for example in its instructions.

Ready to learn this topic?

Learn how to write and register a job, how a model finds out which jobs there are, what execute_job returns, what happens when the input is wrong or the job throws, and when to write a job instead of a tool.

Read Your First Job

Running jobs in the background

With background: true, execute_job answers straight away with a run id, and the job carries on. get_job_status reads the run later, but only for the caller who started it, and under MCP 2026-07-28 an anonymous caller is a new caller on every request. The Playground's calls are anonymous, so they can start a run but never read it:

Open
import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "export-tickets",
  description: "Export every support ticket as CSV. Takes a while.",
  inputSchema: {},
  outputSchema: { csv: z.string() },
})
class ExportTickets extends JobContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 1_000)); // reading the whole database
    return { csv: "id,title,status\nT-1,Cannot log in,open" };
  }
}

@App({ id: "help-desk", name: "Help Desk", jobs: [ExportTickets] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Call get_job_status with the runId from the Call tab to see it for yourself. The run is there, and a caller who signed in could read it.

Ready to learn this topic?

Learn what states a run goes through, how to retry a flaky job and what gets retried, who can read a run and how to get results to anonymous callers anyway, and how to keep runs in Redis.

Read Running Jobs in the Background

Chaining jobs into workflows

A workflow chains jobs. Each step names a job, the steps it depends on, and its input, which can be built from the outputs of earlier steps. FrontMCP runs the steps in order, and steps that don't depend on each other at the same time:

Open
import { App, FrontMcp, Job, JobContext, Workflow, z } from "@frontmcp/sdk";

@Job({
  name: "find-sla-breaches",
  inputSchema: { priority: z.enum(["high", "normal"]) },
  outputSchema: { breaches: z.array(z.string()) },
})
class FindSlaBreaches extends JobContext {
  async execute({ priority }: { priority: "high" | "normal" }) {
    return { breaches: priority === "high" ? ["T-1"] : ["T-2"] };
  }
}

@Job({
  name: "write-report",
  inputSchema: { breaches: z.array(z.string()) },
  outputSchema: { report: z.string() },
})
class WriteReport extends JobContext {
  async execute({ breaches }: { breaches: string[] }) {
    return { report: `${breaches.length} tickets broke their SLA: ${breaches.join(", ")}.` };
  }
}

@Workflow({
  name: "nightly-ticket-report",
  steps: [
    { id: "breaches", jobName: "find-sla-breaches" },
    {
      id: "report",
      jobName: "write-report",
      dependsOn: ["breaches"],
      input: (steps) => ({ breaches: steps.get("breaches").outputs.breaches }),
    },
  ],
})
class NightlyTicketReport {}

@App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches, WriteReport], workflows: [NightlyTicketReport] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The first step has no input of its own, so it gets the workflow's, { priority: "high" }. The second waits for it, and builds its input from its output.

Ready to learn this topic?

Learn how to pass data between steps, which steps run at the same time, what a failing step does to the others, how to make a step optional, and how to read a workflow that ran in the background.

Read Chaining Jobs into Workflows

Tool, job or workflow?

If the work…Write
Answers the model right away, like a search or closing a ticketA tool
Takes a while, should be retried when it fails, or should keep going after the callA job
Is several jobs, where some need what others produceA workflow of jobs

What's next?

Start the chapter with Your First Job. To see all three lessons in one server, read the Nightly Ticket Report example, and look up every option in the @Job and @Workflow references. After this chapter, Delegating to Agents covers handing a whole task to a model of your own, with its own tools and instructions.