# Doing Work in the Background

> When a tool isn't enough. FrontMCP jobs for slow, flaky and long-running work, the tools clients run them with, background runs and retries, and workflows that chain jobs.

Source: https://frontmcp.dev/learn/background-work

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**
- [How to write a job, run it, and read what comes back](https://frontmcp.dev/learn/your-first-job)
- [How to run a job in the background, retry it, and check on it](https://frontmcp.dev/learn/running-jobs-in-the-background)
- [How to chain jobs into a workflow](https://frontmcp.dev/learn/chaining-jobs-into-workflows)

## 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:

```ts main.ts active
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 {}
```

```ts job.test.ts
import { test, expect } from "@frontmcp/testing";

test("registering a job adds FrontMCP's job tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name).sort();
  expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]);
});

test("execute_job runs the job and returns the run", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "high" } })).json();
  expect(run).toEqual({
    runId: expect.any(String),
    state: "completed",
    result: { breaches: ["T-1"] },
    logs: [expect.stringMatching(/\] 1 high-priority tickets are past their SLA$/)],
  });
});
```

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:

| Tool | What it does |
| --- | --- |
| `execute_job` | Runs a job by name, and returns the run: at the end of the call, or, with `background: true`, at once. |
| `get_job_status` | Reads a run by its id: its state, and its result or error. |
| `execute_workflow` | Runs a workflow, the same way. |
| `get_workflow_status` | Reads 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`.

Read more: [Your First Job](https://frontmcp.dev/learn/your-first-job)
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.

## 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:

```ts main.ts active
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 {}
```

```ts background.test.ts
import { test, expect } from "@frontmcp/testing";

test("a background run starts at once", async ({ mcp }) => {
  const started = await mcp.tools.call("execute_job", { name: "export-tickets", background: true });
  expect(started.json()).toEqual({ runId: expect.any(String), state: "pending" });
  expect(started.durationMs).toBeLessThan(200);
});

test("an anonymous caller can't read it afterwards", async ({ mcp }) => {
  const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", background: true })).json();
  const status = await mcp.tools.call("get_job_status", { runId });
  expect(status).toBeError("INVALID_INPUT");
  expect(status.text()).toBe(`Run "${runId}" not found`);
});
```

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.

Read more: [Running Jobs in the Background](https://frontmcp.dev/learn/running-jobs-in-the-background)
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.

## 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:

```ts main.ts active
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 {}
```

```ts workflow.test.ts
import { test, expect } from "@frontmcp/testing";

test("each step's output is in the result", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_workflow", { name: "nightly-ticket-report", input: { priority: "high" } })).json();
  expect(run).toMatchObject({
    state: "completed",
    result: {
      stepResults: {
        breaches: { state: "completed", outputs: { breaches: ["T-1"] } },
        report: { state: "completed", outputs: { report: "1 tickets broke their SLA: T-1." } },
      },
    },
  });
});
```

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.

Read more: [Chaining Jobs into Workflows](https://frontmcp.dev/learn/chaining-jobs-into-workflows)
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.

## Tool, job or workflow?

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

## What's next?

Start the chapter with [Your First Job](https://frontmcp.dev/learn/your-first-job). To see all three lessons in one server, read the [Nightly Ticket Report](https://frontmcp.dev/examples/nightly-ticket-report) example, and look up every option in the [`@Job`](https://frontmcp.dev/reference/sdk/job) and [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow) references. After this chapter, [Delegating to Agents](https://frontmcp.dev/learn/delegating-to-agents) covers handing a whole task to a model of your own, with its own tools and instructions.
