# Running Jobs in the Background

> How to start a FrontMCP job in the background and check on it with get_job_status, what states a run goes through, how to retry a flaky job with backoff, who may read a run, and where runs are stored.

Source: https://frontmcp.dev/learn/running-jobs-in-the-background

Run inline, a job holds its call open until it's done, and a model waits the whole time. With `background: true`, `execute_job` answers at once with a run id, the job keeps working on the server, and `get_job_status` reports on it later. This lesson starts jobs that way, retries the ones that fail now and then, and shows who can read a run afterwards. In FrontMCP 1.9.4, under MCP 2026-07-28, that rules out anonymous callers, so the lesson also shows how to get results to them another way.

**You will learn**
- How to start a job in the background, and read its run with `get_job_status`
- What states a run goes through, and what a failed run looks like
- How to retry a flaky job with backoff, and what gets retried
- Who can read a run, and how to hand results to anonymous callers
- Where runs are stored, and how to keep them in Redis

> **Note**
Jobs aren't MCP tasks. A job is FrontMCP's own unit of work, which clients start with the `execute_job` tool and read with `get_job_status`. An MCP task is a protocol feature: a client asks for an ordinary tool call to run as a task, and checks back on it later. FrontMCP supports both; tasks are in [Background tasks](https://frontmcp.dev/reference/server/tasks).

## A job that takes a while

Exporting the ticket database reads it a page at a time, and each page takes a fifth of a second. Run inline, the call takes as long as the export. The call in this example starts it in the background instead. Open the **Tests** tab:

```ts export-tickets.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";
import { readPage } from "./database";

@Job({
  name: "export-tickets",
  description: "Export every support ticket as CSV. Reads the database a page at a time, so it takes a while.",
  inputSchema: {},
  outputSchema: { rows: z.number(), csv: z.string() },
})
export class ExportTickets extends JobContext {
  async execute() {
    const rows: string[] = [];
    for (let page = 1; page <= 5; page++) {
      rows.push(...(await readPage(page)));
      this.log(`Read page ${page} of 5`);
    }
    return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") };
  }
}
```

```ts database.ts
// Stands in for a slow database: 5 pages, a fifth of a second each.
export async function readPage(page: number) {
  await new Promise((resolve) => setTimeout(resolve, 200));
  return [`T-${page},Ticket ${page},open`];
}
```

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

test("inline, the call waits for the whole export", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "export-tickets" });
  expect(result.json()).toMatchObject({ state: "completed", result: { rows: 5 } });
  expect(result.durationMs).toBeGreaterThanOrEqual(1_000);
});

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

With `background: true`, `execute_job` returns `{ runId, state: "pending" }` and nothing else: no result, no logs. The job carries on after the call has returned. A client reads the run later by passing that `runId` to `get_job_status`.

## Who can read a run

Copy the `runId` from the Call tab, and call `get_job_status` with it. It fails with `Run "…" not found`, although the run is right there. Open the **Tests** tab:

```ts status.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const asNour = { authContext: { user: { sub: "nour" } } };
const asSam = { authContext: { user: { sub: "sam" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("an anonymous caller can't read the run it started", 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`);
});

test("a signed-in caller can read their own run, and nobody else can", async () => {
  // createDirect() calls the server in-process, as the user you name.
  const server = await FrontMcpInstance.createDirect(config);
  const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };

  const mine = await server.callTool("get_job_status", { runId }, asNour);
  await expect(server.callTool("get_job_status", { runId }, asSam)).rejects.toThrow(`Run "${runId}" not found`);

  await wait(1_200);
  await server.dispose();
  expect(mine.structuredContent).toMatchObject({ runId, jobName: "export-tickets", state: "running" });
});
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.job";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
};

@FrontMcp(config)
export default class Server {}
```

```ts export-tickets.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

const readPage = async (page: number) => {
  await new Promise((resolve) => setTimeout(resolve, 200));
  return [`T-${page},Ticket ${page},open`];
};

@Job({
  name: "export-tickets",
  description: "Export every support ticket as CSV.",
  inputSchema: {},
  outputSchema: { rows: z.number(), csv: z.string() },
})
export class ExportTickets extends JobContext {
  async execute() {
    const rows: string[] = [];
    for (let page = 1; page <= 5; page++) {
      rows.push(...(await readPage(page)));
      this.log(`Read page ${page} of 5`);
    }
    return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") };
  }
}
```

A run holds the job's input and result, which can be anybody's data, so FrontMCP only lets the caller who started a run read it. It records the caller's `sub` with the run, and `get_job_status` answers only a caller with the same `sub`. Anyone else gets `Run "…" not found` with code `INVALID_INPUT`, exactly what they'd get for a run that doesn't exist, so a run id that leaks tells a stranger nothing.

That's where anonymous callers lose out. Under MCP 2026-07-28 [each anonymous request is a new caller](https://frontmcp.dev/learn/authenticating-clients#a-server-anyone-can-call), with a new `anon:` id, so the request that asks for the status is never the one that started the run. The Playground's calls are anonymous, which is why yours failed. The second test calls the server in-process with [`createDirect()`](https://frontmcp.dev/learn/running-frontmcp-anywhere#calling-your-tools-without-a-client-createdirect), as the signed-in user `nour`: she can read her run while it works, and `sam` can't.

> **Pitfall: Anonymous callers can't read their runs**
On a public server under 2026-07-28, `get_job_status` and `get_workflow_status` never find a run for an anonymous caller. The job still runs, and anything it changes still changes; the caller just can't read the result. Either have callers [authenticate](https://frontmcp.dev/learn/authenticating-clients), so each has a `sub` of their own, or give the result another way out, as [further down](#handing-results-to-anonymous-callers) shows. With `auth: { mode: "static" }`, the caller is derived from the key, so every client that shares a key can read the others' runs, and a client with another key can't. The Playground can't send a key, so that one you'd try on a real server.

## What a run looks like over time

For the rest of this lesson, the tests read runs as `nour`. `get_job_status` returns the run's record:

```ts states.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a run is `running` with no logs, then `completed` with all of them", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const status = async (runId: string) =>
    (await server.callTool("get_job_status", { runId }, asNour)).structuredContent as any;

  const started = await server.callTool("execute_job", { name: "export-tickets", background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };

  await wait(500); // two pages in
  const during = await status(runId);
  await wait(800);
  const after = await status(runId);
  await server.dispose();

  expect(during).toEqual({ runId, jobName: "export-tickets", state: "running", startedAt: expect.any(Number), attempt: 1, logs: [] });
  expect(after).toMatchObject({ state: "completed", result: { rows: 5 }, attempt: 1, completedAt: expect.any(Number) });
  expect(after.logs).toHaveLength(5);
  expect(after.logs[4]).toMatch(/\] Read page 5 of 5$/);
});
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.job";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
};

@FrontMcp(config)
export default class Server {}
```

```ts export-tickets.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

const readPage = async (page: number) => {
  await new Promise((resolve) => setTimeout(resolve, 200));
  return [`T-${page},Ticket ${page},open`];
};

@Job({
  name: "export-tickets",
  description: "Export every support ticket as CSV.",
  inputSchema: {},
  outputSchema: { rows: z.number(), csv: z.string() },
})
export class ExportTickets extends JobContext {
  async execute() {
    const rows: string[] = [];
    for (let page = 1; page <= 5; page++) {
      rows.push(...(await readPage(page)));
      this.log(`Read page ${page} of 5`);
    }
    return { rows: rows.length, csv: ["id,title,status", ...rows].join("\n") };
  }
}
```

| Field | Meaning |
| --- | --- |
| `runId`, `jobName` | Which run, of which job. |
| `state` | Where the run is; see below. |
| `result` | What `execute()` returned, once the run has completed. |
| `error` | `{ message, name }` of the error, once the run has failed. |
| `startedAt`, `completedAt` | Milliseconds since 1970. `completedAt` is set when the run completes or fails. |
| `attempt` | Counts attempts, when the job [retries](#retrying-a-flaky-job). |
| `logs` | The `this.log()` lines, **once the run has completed**. Until then it's empty. |

A run's `state` is one of:

| `state` | When |
| --- | --- |
| `"pending"` | Created, not started yet. It's what `execute_job` returns for a background run. |
| `"running"` | The job is working. |
| `"retrying"` | An attempt failed, and the run is waiting to try again. |
| `"completed"` | The job returned. `result` is set. |
| `"failed"` | The last attempt threw. `error` is set. |

The empty `logs` in `during` matter: in 1.9.4 a client can't watch a run's progress. FrontMCP saves the logs when the run ends, and only if it succeeds. A client learns how far a run got from `state` alone, so poll it every few seconds, not in a tight loop.

**Deep dive: What about this.progress()?**
`JobContext` has `this.progress(progress, total, message)`, as tools do. For a tool it sends a progress notification on the call's stream. For a job it looks for the caller's session, and under 2026-07-28 there isn't one, so it sends nothing and returns `false`:

```ts main.ts active
import { App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk";

@Job({ name: "export-tickets", inputSchema: {}, outputSchema: { delivered: z.array(z.boolean()) } })
class ExportTickets extends JobContext {
  async execute() {
    const delivered: boolean[] = [];
    for (let page = 1; page <= 3; page++) {
      delivered.push(await this.progress(page, 3, `Read page ${page} of 3`));
    }
    return { delivered };
  }
}

@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 progress.test.ts
import { test, expect } from "@frontmcp/testing";

test("no progress reaches the caller", async ({ mcp }) => {
  const progress = mcp.notifications.collectProgress();
  const run = (await mcp.tools.call("execute_job", { name: "export-tickets" })).json();
  expect(run.result.delivered).toEqual([false, false, false]);
  expect(progress.all).toEqual([]);
});
```

If a user should see how far a long export got, have the job write its progress somewhere a tool can read, the same way as [results for anonymous callers](#handing-results-to-anonymous-callers).

## Retrying a flaky job

The CRM the help desk syncs with drops requests now and then: here, the first two tries for each customer fail. `retry` tells FrontMCP to run the job again when it throws, waiting a little longer each time:

```ts sync-customer.job.ts active
import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM into the help desk.",
  inputSchema: { customerId: z.string().describe("Customer id, like C-1") },
  outputSchema: { customerId: z.string(), plan: z.string() },
  retry: { maxAttempts: 3, backoffMs: 200 },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    this.log(`Attempt ${this.attempt}: asking the CRM for ${customerId}`);
    const customer = await crm.find(customerId);
    if (!customer) throw new PublicMcpError(`There's no customer ${customerId} in the CRM.`);
    return { customerId, plan: customer.plan };
  }
}
```

```ts crm.ts
// Stands in for a CRM that drops the first two requests for each customer.
export const requests: Record<string, number> = {};

export const crm = {
  async find(id: string) {
    requests[id] = (requests[id] ?? 0) + 1;
    if (requests[id] <= 2) throw new Error("CRM timed out");
    return id.startsWith("C-4") ? undefined : { id, plan: "business" };
  },
};
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { SyncCustomer } from "./sync-customer.job";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
};

@FrontMcp(config)
export default class Server {}
```

```ts retry.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
import { requests } from "./crm";

const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("the third try works, after waiting 200 ms and then 400 ms", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-2" } });
  expect(result.json()).toMatchObject({ state: "completed", result: { customerId: "C-2", plan: "business" } });
  expect(requests["C-2"]).toBe(3);
  expect(result.durationMs).toBeGreaterThanOrEqual(600);
});

test("only the last attempt's logs are kept", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-3" } })).json();
  expect(run.logs).toEqual([expect.stringMatching(/\] Attempt 3: asking the CRM for C-3$/)]);
});

test("between attempts, the run is `retrying`", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const started = await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-5" }, background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };
  await wait(100);
  const during = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent;
  await wait(700);
  const after = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent;
  await server.dispose();
  expect(during).toMatchObject({ state: "retrying", attempt: 1 });
  expect(after).toMatchObject({ state: "completed", attempt: 3 });
});

test("a mistake is retried too", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-404" } });
  expect(result).toBeError("PUBLIC_ERROR");
  expect(requests["C-404"]).toBe(3);
});
```

The first try fails, FrontMCP waits 200 ms, the second fails, it waits 400 ms, and the third works. Take `retry` out and run the tests again: the job runs once, and the call fails with the CRM's first timeout. `retry` takes:

| Field | Default | Meaning |
| --- | --- | --- |
| `maxAttempts` | `3` | How many times to run the job in all, counting the first. |
| `backoffMs` | `1000` | How long to wait before the first retry, in milliseconds. |
| `backoffMultiplier` | `2` | What each wait is multiplied by for the next one. |
| `maxBackoffMs` | `60000` | The longest FrontMCP waits between two attempts. |

The defaults apply once `retry` is set, so `retry: {}` means three attempts, waiting 1 and then 2 seconds. Without `retry`, a job runs once. An inline call waits through every retry, so keep `maxAttempts` and the waits small for jobs a model runs inline, and run slow-to-retry jobs in the background.

A few things about retries in 1.9.4 are easy to trip over:

- **Almost everything is retried.** The last test asks for customer `C-404`, which the CRM doesn't have. Retrying can't fix that, but the job throws, so FrontMCP tries three times before the caller hears about it. Input that doesn't match `inputSchema` is retried as well. Keep `maxAttempts` low when a job can fail for reasons that won't go away. The one failure that isn't retried is a result that doesn't match [`outputSchema`](https://frontmcp.dev/learn/your-first-job#what-comes-back).
- **Logs are per attempt.** A run keeps the logs of its last attempt only.
- **`this.attempt` is the attempt number**, from 1, as the second test's log line shows. (Changed in 1.9.2: before, it was always `1`.)
- **Hooks see every attempt.** Each attempt runs the `jobs:execute-job` flow, so a plugin's [`JobHook`](https://frontmcp.dev/reference/sdk/job#hooking-every-attempt-of-a-job) sees each retry and its error, while the caller only hears about the last one. (New in 1.9.4.)

> **Pitfall: A retried job runs again from the top**
Each attempt runs `execute()` from the beginning, so anything the job did before it failed happens again. A job that emails a customer and then fails on the next line emails them once per attempt. Before you add `retry`, make the job safe to run twice: do the part that can fail first, or check whether the work was already done. The [second challenge](#try-some-challenges) is this bug.

## What a failed run looks like

When the last attempt fails, the run's `state` becomes `"failed"`, with the error:

```ts failed.test.ts active
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

const asNour = { authContext: { user: { sub: "nour" } } };
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("a failed run keeps the error, and none of the logs", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const started = await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-404" }, background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };
  await wait(400);
  const status = (await server.callTool("get_job_status", { runId }, asNour)).structuredContent;
  await server.dispose();

  expect(status).toEqual({
    runId,
    jobName: "sync-customer",
    state: "failed",
    error: { message: "There's no customer C-404 in the CRM.", name: "PublicMcpError" },
    startedAt: expect.any(Number),
    completedAt: expect.any(Number),
    attempt: 3,
    logs: [],
  });
});
```

```ts sync-customer.job.ts
import { Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk";

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM into the help desk.",
  inputSchema: { customerId: z.string() },
  outputSchema: { customerId: z.string(), plan: z.string() },
  retry: { maxAttempts: 3, backoffMs: 50 },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    this.log(`Asking the CRM for ${customerId}`);
    if (customerId !== "C-1") throw new PublicMcpError(`There's no customer ${customerId} in the CRM.`);
    return { customerId, plan: "business" };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { SyncCustomer } from "./sync-customer.job";

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

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
};

@FrontMcp(config)
export default class Server {}
```

- **`error`** has the last attempt's `message`, as thrown, and `name`, the error's class.
- **`logs`** is empty: in 1.9.4 a failed run keeps none of its logs. `this.log()` also writes each line to the server's own log, tagged `[job:sync-customer]`, so that's where to look when a run fails. In the Playground, it's the **Logs** tab.
- **`attempt`** is the number of attempts made. (Changed in 1.9.2: before, it was one less.)

## Handing results to anonymous callers

A job can share state with tools, as tools share it with each other: through a [provider](https://frontmcp.dev/learn/sharing-state-with-providers) or a module. So when callers can't read runs, the job can put its result where a tool can read it. The nightly report job saves each day's report, and `get_daily_report` returns it:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetDailyReport],
  jobs: [BuildDailyReport],
  providers: [ReportStore], // one store, for the job and the tool
})
class HelpDeskApp {}

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

```ts reports.ts
import { Job, JobContext, Provider, Tool, ToolContext, z } from "@frontmcp/sdk";

@Provider({ name: "ReportStore" })
export class ReportStore {
  readonly reports = new Map<string, { opened: number; closed: number }>();
}

@Job({
  name: "build-daily-report",
  description: "Count the tickets opened and closed on one day, and save the report.",
  inputSchema: { day: z.string().describe("The day, like 2026-09-26") },
  outputSchema: { day: z.string() },
})
export class BuildDailyReport extends JobContext {
  async execute({ day }: { day: string }) {
    await new Promise((resolve) => setTimeout(resolve, 300)); // reading every ticket
    this.get(ReportStore).reports.set(day, { opened: 12, closed: 9 });
    return { day };
  }
}

@Tool({
  name: "get_daily_report",
  description: "Get the ticket report for one day. The build-daily-report job makes it; if it isn't ready, try again in a minute.",
  inputSchema: { day: z.string().describe("The day, like 2026-09-26") },
  annotations: { readOnlyHint: true },
})
export class GetDailyReport extends ToolContext {
  async execute({ day }: { day: string }) {
    const report = this.get(ReportStore).reports.get(day);
    return report ? { day, ready: true, ...report } : { day, ready: false };
  }
}
```

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

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

test("an anonymous caller starts the report, and reads it with the tool", async ({ mcp }) => {
  await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" }, background: true });
  expect((await mcp.tools.call("get_daily_report", { day: "2026-09-25" })).json()).toEqual({ day: "2026-09-25", ready: false });

  await wait(400);
  expect((await mcp.tools.call("get_daily_report", { day: "2026-09-25" })).json()).toEqual({
    day: "2026-09-25",
    ready: true,
    opened: 12,
    closed: 9,
  });
});
```

Press **Call** with `get_daily_report` and `{ "day": "2026-09-26" }` a moment after the example starts: the report is there. The job and the tool see the same `ReportStore` because it's registered once, on their app. The report is keyed by day, not by run, because a job doesn't know its run's id. Anyone can read it, too, so keep this for results that every caller may see.

A job finds providers on its own app and on the server, as a tool does. (Changed in 1.9.2: before, a job only found the server's.) A provider on another app isn't there for it: the [third challenge](#try-some-challenges) starts from that mistake.

## Where runs are kept

By default FrontMCP keeps runs in memory, in the server process. That's what the Playground uses, and it's fine on one machine, with two limits: runs are lost when the server restarts, and a server behind a load balancer only knows the runs it started itself, so `get_job_status` fails whenever the request lands on another instance. For those, keep runs in Redis:

```ts main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  jobs: {
    enabled: true,
    store: {
      redis: { provider: "redis", host: "redis.internal", port: 6379, password: process.env.REDIS_PASSWORD },
      keyPrefix: "help-desk:jobs:", // the default is "mcp:jobs:"
    },
  },
})
export default class Server {}
```

The Playground can't connect to Redis, so this part is for a real server. It was checked on one, against a Redis that needs a password. What 1.9.4 does with it:

- **`enabled: true` is required** once you write a `jobs` option. `jobs: { enabled: false }` turns jobs off: FrontMCP adds none of the job tools, even with jobs registered.
- **It needs the `ioredis` package.** If FrontMCP can't load it, it logs `Failed to create Redis job state store, falling back to memory` and carries on in memory.
- **It connects the way the server's [`redis`](https://frontmcp.dev/reference/deployment/redis#options) option does**: with `host`, `port`, `password`, `db` and `tls`, or with a `url` such as `redis://:<password>@redis.internal:6379`. Beside a `url`, those fields only fill in what it leaves out, and one that contradicts it stops the server at startup. So does a missing password (`NOAUTH Authentication required.`) or a wrong one (`WRONGPASS invalid username-password pair`). (Changed in 1.9.3: before, only `host` and `port` reached Redis, so a Redis that needs a password or TLS couldn't hold runs.)
- **Runs in Redis expire after 24 hours.** Runs in memory stay until the server restarts. The sets FrontMCP keeps beside the runs, `<keyPrefix>idx:jobId:<job>` and `<keyPrefix>idx:sessionId:<session>`, never expire, and keep every run's id, so they grow for as long as the server starts runs.

## Recap

- `execute_job { name, input, background: true }` returns `{ runId, state: "pending" }` at once, and the job keeps running. `get_job_status { runId }` returns the run: `state`, `result` or `error`, `attempt`, times and `logs`.
- A run goes `pending`, `running`, and `completed` or `failed`, with `retrying` between attempts. Logs appear only when a run completes; a failed run keeps none.
- Only the caller who started a run can read it. Under 2026-07-28 an anonymous caller is new on every request, so it can never read its runs: have callers sign in, or have the job save its result where a tool can read it.
- `retry: { maxAttempts, backoffMs, backoffMultiplier, maxBackoffMs }` runs a failed job again, waiting longer each time. Every error is retried, and each attempt starts from the top, so make retried jobs safe to run twice.
- A job finds providers on its own app and on the server, so a job and a tool can share one.
- Runs are in memory by default. `jobs: { enabled: true, store: { redis } }` keeps them in Redis, connected like the server's `redis` option, password and TLS included.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Retry the CRM sync
The CRM drops the first two requests for each customer, so `sync-customer` fails every first call. Make FrontMCP try up to 3 times, waiting 100 ms before the first retry and 200 ms before the second.

```ts sync-customer.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM into the help desk.",
  inputSchema: { customerId: z.string() },
  outputSchema: { customerId: z.string(), plan: z.string() },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    const customer = await crm.find(customerId);
    return { customerId, plan: customer.plan };
  }
}
```

```ts sync-customer.job.ts solution
import { Job, JobContext, z } from "@frontmcp/sdk";
import { crm } from "./crm";

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM into the help desk.",
  inputSchema: { customerId: z.string() },
  outputSchema: { customerId: z.string(), plan: z.string() },
  retry: { maxAttempts: 3, backoffMs: 100 },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    const customer = await crm.find(customerId);
    return { customerId, plan: customer.plan };
  }
}
```

```ts crm.ts
// Stands in for a CRM that drops the first two requests for each customer.
export const requests: Record<string, number> = {};

export const crm = {
  async find(id: string) {
    requests[id] = (requests[id] ?? 0) + 1;
    if (requests[id] <= 2) throw new Error("CRM timed out");
    return { id, plan: "business" };
  },
};
```

```ts retry.test.ts hidden
import { test, expect } from "@frontmcp/testing";
import { requests } from "./crm";

test("syncing C-2 succeeds on the third try", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-2" } });
  expect(result).toBeSuccessful();
  expect(result.json().result).toEqual({ customerId: "C-2", plan: "business" });
  expect(requests["C-2"]).toBe(3);
});

test("the retries wait 100 ms and then 200 ms", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-3" } });
  expect(result.durationMs).toBeGreaterThanOrEqual(300);
  expect(result.durationMs).toBeLessThan(1_000);
});
```

**Hint:**
`@Job` has a `retry` option. Its multiplier already doubles each wait, so you only need to set the first one.

**Solution:**
`retry: { maxAttempts: 3, backoffMs: 100 }` runs the job up to three times. The first wait is `backoffMs`, and each wait after that is multiplied by `backoffMultiplier`, which is `2` by default: 100 ms, then 200 ms. `retry: {}` would also get the sync through, but with the default waits of 1 and 2 seconds, so a model running the job inline would wait three seconds for it.

### Challenge: Send the reminder once
`remind-customer` emails a customer about an unpaid invoice, then marks the invoice as reminded in billing. Billing times out on the first try, so the job is retried, and customers get two emails. Fix the job so a successful run sends exactly one email. The retry must stay.

```ts remind-customer.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";
import { billing, outbox } from "./services";

@Job({
  name: "remind-customer",
  description: "Email a customer about an unpaid invoice, and record that they were reminded.",
  inputSchema: { invoiceId: z.string() },
  outputSchema: { invoiceId: z.string(), reminded: z.boolean() },
  retry: { maxAttempts: 3, backoffMs: 50 },
})
export class RemindCustomer extends JobContext {
  async execute({ invoiceId }: { invoiceId: string }) {
    outbox.push(`Invoice ${invoiceId} is still unpaid.`);
    await billing.markReminded(invoiceId);
    return { invoiceId, reminded: true };
  }
}
```

```ts remind-customer.job.ts solution
import { Job, JobContext, z } from "@frontmcp/sdk";
import { billing, outbox } from "./services";

@Job({
  name: "remind-customer",
  description: "Email a customer about an unpaid invoice, and record that they were reminded.",
  inputSchema: { invoiceId: z.string() },
  outputSchema: { invoiceId: z.string(), reminded: z.boolean() },
  retry: { maxAttempts: 3, backoffMs: 50 },
})
export class RemindCustomer extends JobContext {
  async execute({ invoiceId }: { invoiceId: string }) {
    await billing.markReminded(invoiceId); // the part that can fail goes first
    outbox.push(`Invoice ${invoiceId} is still unpaid.`);
    return { invoiceId, reminded: true };
  }
}
```

```ts services.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

export const outbox: string[] = [];
const attempts: Record<string, number> = {};

// Stands in for billing: the first request for each invoice times out.
export const billing = {
  async markReminded(invoiceId: string) {
    attempts[invoiceId] = (attempts[invoiceId] ?? 0) + 1;
    if (attempts[invoiceId] === 1) throw new Error("billing timed out");
  },
};

@Tool({ name: "list_outbox", description: "List the emails sent to customers.", inputSchema: {} })
export class ListOutbox extends ToolContext {
  async execute() {
    return { emails: outbox };
  }
}
```

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

test("the reminder succeeds despite billing's first timeout", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "remind-customer", input: { invoiceId: "INV-7" } });
  expect(result.json()).toMatchObject({ state: "completed", result: { invoiceId: "INV-7", reminded: true } });
});

test("the customer gets exactly one email", async ({ mcp }) => {
  await mcp.tools.call("execute_job", { name: "remind-customer", input: { invoiceId: "INV-8" } });
  const { emails } = (await mcp.tools.call("list_outbox", {})).json();
  expect(emails.filter((e: string) => e.includes("INV-8"))).toHaveLength(1);
});
```

**Hint:**
Each attempt runs `execute()` from the top. Which line runs on every attempt, even the ones that fail?

**Solution:**
The email went out before the call to billing, so every attempt that billing failed had already sent one. Calling billing first means a failed attempt has sent nothing, and the email goes out once, on the attempt that gets past billing. When a job has two side effects, put the one that can fail first, or check whether the work was already done before doing it again.

### Challenge: Let the job find the report store
`build-daily-report` should save each day's report in `ReportStore`, for `get_daily_report` to return. The job is in the help desk's app, and the tool in the reporting team's. But the job fails, and the report never appears. Fix it so that after the job runs, the tool returns the report.

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports";

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

@App({ id: "reports", name: "Reports", tools: [GetDailyReport], providers: [ReportStore] })
class ReportsApp {}

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

```ts main.ts solution
import { App, FrontMcp } from "@frontmcp/sdk";
import { BuildDailyReport, GetDailyReport, ReportStore } from "./reports";

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

@App({ id: "reports", name: "Reports", tools: [GetDailyReport] })
class ReportsApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp, ReportsApp],
  providers: [ReportStore], // on the server, for both apps
})
export default class Server {}
```

```ts reports.ts
import { Job, JobContext, Provider, Tool, ToolContext, z } from "@frontmcp/sdk";

@Provider({ name: "ReportStore" })
export class ReportStore {
  readonly reports = new Map<string, { opened: number; closed: number }>();
}

@Job({
  name: "build-daily-report",
  description: "Count the tickets opened and closed on one day, and save the report.",
  inputSchema: { day: z.string() },
  outputSchema: { day: z.string() },
})
export class BuildDailyReport extends JobContext {
  async execute({ day }: { day: string }) {
    this.get(ReportStore).reports.set(day, { opened: 12, closed: 9 });
    return { day };
  }
}

@Tool({
  name: "get_daily_report",
  description: "Get the ticket report for one day, if the build-daily-report job has made it.",
  inputSchema: { day: z.string() },
  annotations: { readOnlyHint: true },
})
export class GetDailyReport extends ToolContext {
  async execute({ day }: { day: string }) {
    const report = this.get(ReportStore).reports.get(day);
    return report ? { day, ready: true, ...report } : { day, ready: false };
  }
}
```

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

test("`build-daily-report` completes", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" } });
  expect(result).toBeSuccessful();
});

test("`get_daily_report` returns the report the job saved", async ({ mcp }) => {
  await mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-24" } });
  const report = (await mcp.tools.call("get_daily_report", { day: "2026-09-24" })).json();
  expect(report).toEqual({ day: "2026-09-24", ready: true, opened: 12, closed: 9 });
});
```

**Hint:**
Read the error in the Call tab. Where does a job look for providers, and where is `ReportStore`?

**Solution:**
A job's `this.get()` finds providers on its own app and on the server. `ReportStore` was on the `reports` app, which only that app's tools and jobs see, so the job failed with `Provider "ReportStore" is not available`. Registered on the server, it's one instance that both apps find, so the tool returns what the job saved. Putting the job and the tool in one app, with the store, works too.
