# @Job

> Declare a unit of work with validated input that clients run by name, while they wait or in the background, with retries.

Source: https://frontmcp.dev/reference/sdk/job

`@Job` declares a [job](https://frontmcp.dev/learn/your-first-job): a piece of work with validated input that a client starts by name, with the built-in `execute_job` tool. A job can run while the client waits, or [in the background](https://frontmcp.dev/learn/running-jobs-in-the-background) while the client does something else, and FrontMCP can retry it when it fails. Jobs are also the steps of a [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow).

```ts
@Job(options)
class MyJob extends JobContext {
  async execute(input) { /* ... */ }
}
```

---

## Reference

### `@Job(options)`

Apply `@Job` to a class that extends `JobContext`, and list the class in an app's `jobs` array. FrontMCP then adds the tools that run jobs to the server; you don't write a tool for each job.

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

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
  retry: { maxAttempts: 3, backoffMs: 500 },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Counting open tickets for ${customer}`);
    const open = await countOpenTickets(customer);
    return { customer, open };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";

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

[See more examples below.](#usage)

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | What clients pass to `execute_job`, and what a workflow step's `jobName` refers to. Unique within the server. If you also set `id`, the id replaces it. |
| `inputSchema` | object of Zod types | One Zod type per input field, like `{ customer: z.string() }`. `execute_job` checks the input against it before `execute()` runs. |
| `outputSchema` | object of Zod types, or any output schema a [tool](https://frontmcp.dev/reference/sdk/tool#options) accepts | The shape of the result. TypeScript checks `execute()`'s return type against it, and FrontMCP checks the result when the job runs: fields it doesn't declare are dropped, and a result that doesn't match fails the run with `INVALID_OUTPUT`, without a retry. See [Troubleshooting](#tool-output-validation-failed-output-does-not-match-outputschema-at-). (Changed in 1.9.2: before, the result wasn't checked at run time.) |

Optional:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `description` | `string` | | What the job does. `list_jobs` shows it. |
| `id` | `string` | `name` | A stable identifier. When set, it **replaces** `name`: clients, `list_jobs` and workflow steps all use the id. |
| `retry` | `JobRetryConfig` | none: one attempt | Run `execute()` again when it throws. See [`retry`](#retry). |
| `timeout` | `number` | `300000` (5 minutes) | How long the job may run, in milliseconds, **as a workflow step**. `execute_job` doesn't enforce it. |
| `tags` | `string[]` | `[]` | Labels to filter `list_jobs` by. |
| `labels` | `Record<string, string>` | `{}` | Key-value labels to filter `list_jobs` by. |
| `hideFromDiscovery` | `boolean` | `false` | Leave the job out of `list_jobs`. It still runs by name. |
| `permissions` | `JobPermission[]` | none: anyone may run it | Who may run and list the job. See [`permissions`](#permissions). |

#### `retry`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `maxAttempts` | `number` | `3` | Attempts in all, counting the first. |
| `backoffMs` | `number` | `1000` | How long to wait before the second attempt, in milliseconds. |
| `backoffMultiplier` | `number` | `2` | Each wait is this many times longer than the one before. |
| `maxBackoffMs` | `number` | `60000` | The longest any one wait can be. |

The wait after attempt *n* is `backoffMs × backoffMultiplier^(n−1)`, capped at `maxBackoffMs`. The defaults apply as soon as you set `retry`: `retry: {}` means three attempts, with waits of 1 and 2 seconds.

A job without `retry` runs once when a client calls `execute_job`, but three times, with the same waits, when it's a [workflow step](https://frontmcp.dev/reference/sdk/workflow#caveats).

#### `permissions`

Each entry is a rule for one action:

| Field | Type | Description |
| --- | --- | --- |
| `action` | `"execute" \| "list" \| "delete" \| "create" \| "read" \| "update"` | What the rule guards. FrontMCP 1.9.4 checks `execute` (when `execute_job` starts a run, again before each attempt, and for a workflow step that runs the job), `list` (`list_jobs`) and `delete` (`remove_job`). The other three are accepted and never checked. |
| `roles` | `string[]` | The caller needs at least one of these, in their `roles` claim. |
| `scopes` | `string[]` | The caller needs at least one of these, in their token's `scope` claim. |
| `custom` | `(authInfo) => boolean \| Promise<boolean>` | Your own check. It receives the caller's `authInfo`, with the token's claims in `authInfo.user`. For `execute`, it runs when the run starts and again before each attempt: four times for a job that runs three times. (Changed in 1.9.4: before, once per run.) |

A rule passes when each of its fields passes, and the caller needs every rule for the action. `list_jobs` leaves out jobs the caller can't list **or** can't run. A caller who isn't allowed to run a job gets the same error as for a job that doesn't exist, so they can't find out which jobs exist by guessing names. When the check before a later attempt refuses the caller, as a `custom` check can, the run fails at once, without a retry.

#### `execute(input)`

FrontMCP calls `execute()` once the input passes validation, and again for each retry.

- `input`: the validated input. Fields with a `.default()` are filled in, and fields that aren't in `inputSchema` are dropped. The same object is `this.input`.
- **Returns** the job's result: `result` in what `execute_job` returns, and `outputs` when the job is a workflow step, where later steps read it. Return an object.

To fail, throw, or call `this.fail()`. A failed attempt is retried if the job has `retry`, unless the result failed `outputSchema`.

#### `JobContext`

Inside `execute()`, `this` is the `JobContext`:

| Member | Description |
| --- | --- |
| `this.log(message)` | Adds a line to the run's `logs`, prefixed with the time: `[2026-09-27T09:00:00.000Z] message`. Only the lines of the attempt that succeeded are kept, and a failed run's `logs` is empty, but every line also goes to the server's log. |
| `this.input` | The validated input. |
| `this.get(token)`, `this.tryGet(token)` | [Providers](https://frontmcp.dev/reference/sdk/provider), from the job's app and from the server. See [Using a provider](#using-a-provider). (Changed in 1.9.2: before, a job only saw the server's.) |
| `this.fail(error)` | Ends the attempt with `error`. The message and code of a `PublicMcpError` reach the client as they are. |
| `this.auth` | The caller who started the run, as in [tools](https://frontmcp.dev/reference/sdk/auth): `user.sub`, `isAnonymous`, `roles`, `scopes`. |
| `this.callTool(name, args)` | Calls a tool of the server as the caller who started the run, on the `"job"` surface: a tool whose `availableWhen.surface` leaves out `"job"` answers `Tool "…" not found`. See [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool#where-it-works). |
| `this.context`, `this.fetch()` | The request's context, as in a tool. When `execute_job` runs the job while the client waits, it's the caller's; in the background, it's a copy with the same session, caller and trace, and a request id of its own. `this.fetch()` sends the tracing headers, `traceparent` and `x-request-id`. See [Calling another service from a job](#calling-another-service-from-a-job). [Context classes](https://frontmcp.dev/reference/sdk/contexts) compares every member. (Changed in 1.9.2: before, `this.context` threw in a job, and `this.fetch()` sent no tracing headers.) |
| `this.workerEnv` | On a Cloudflare Worker, the bindings of the request that ran `execute_job`, when it runs the job while the client waits. On a server from [`create()`](https://frontmcp.dev/reference/sdk/create#call-options), the `workerEnv` of the `executeJob()` call, or else of the server, in the background too. `undefined` elsewhere. (Since 1.9; before, always `undefined`. Changed in 1.9.4: before, a server from `create()` had none.) |
| `this.metadata` | The job's options. |
| `this.attempt` | The attempt number, from 1: `2` on the first retry. See [Retrying a job that fails](#retrying-a-job-that-fails). (Changed in 1.9.2: before, it was always `1`.) |
| `this.progress(progress, total?, message?)` | Meant to send a progress notification to the session that started the run. For the Playground's caller it sends nothing and returns `false`. |
| `this.respond(value)` | Ends the attempt with `value` as the job's result, as returning it does, and `value` is checked against `outputSchema` the same way. (Changed in 1.9.2: before, `value` replaced the whole `execute_job` response.) |

### `job(options)(handler)`

The function form takes the same options. `handler(input, ctx)` receives the validated input and the `JobContext`. List the result in `jobs` like a class.

```ts
import { job, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

export const countOpenTickets = job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})(async ({ customer }, ctx) => {
  return { customer, open: ctx.get(TicketStore).openFor(customer).length };
});
```

`ctx.log()` and `ctx.progress()` work in a handler. `fail()` is `protected`, so TypeScript rejects `ctx.fail()` with error `TS2445`: throw a `PublicMcpError` instead, which fails the attempt the same way. (Changed in 1.9.1: before, `log()` and `progress()` were `protected` too.)

### `JobHook`

Each attempt of a job runs the [`jobs:execute-job` flow](https://frontmcp.dev/reference/server/flows#jobsexecute-job): a run `execute_job` starts, inline or in the background, each retry, and each workflow step. `JobHook` hooks it, as [`ToolHook`](https://frontmcp.dev/reference/sdk/hooks#decorator-sets-and-their-flows) hooks tool calls, and `FlowCtxOf<"jobs:execute-job">` types the hook's `ctx`. `@frontmcp/sdk` also exports the flow's class as the type `ExecuteJobFlow`. A hook runs once per attempt: three times for a job that succeeds on its third. (New in 1.9.4: before, jobs ran through no flow.)

| Stage | Group | What it does |
| --- | --- | --- |
| `parseInput` | first | Sets `job`, `attempt`, `runId`, `workflow`, `authInfo` and `input`. |
| `checkJobAuthorization` | first | Checks the job's `execute` [`permissions`](#permissions) against the caller. A refusal fails the run with `JOB_NOT_AUTHORIZED`, without a retry. |
| `validateInput` | first | Parses `input` with `inputSchema` into `parsedInput`. |
| `createJobContext` | first | Builds the job's instance, the `JobContext`, as `jobContext`. |
| `execute` | execute | Runs `execute()`. |
| `validateOutput` | execute | Parses the result with `outputSchema`, and sets `answer`: `{ result, logs }`. |
| `updateRunState` | always | Records the attempt on the run: `completed`, `retrying` or `failed`. |
| `finalize` | always | Answers with `answer`. |

`ctx.state` has:

| Field | What it is |
| --- | --- |
| `job` | The job: `name`, and `metadata`, its options. |
| `attempt` | The attempt number, from 1. |
| `runId` | The run's id. `undefined` for a workflow step, whose workflow run keeps the record. |
| `workflow` | For a workflow step, `{ name, stepId, runId }`: the workflow, the step's `id` and the workflow run's id. `undefined` otherwise. |
| `authInfo` | The caller who started the run. |
| `input`, `parsedInput` | The job's input as given, and once `inputSchema` has parsed it. A hook before `validateInput` can replace `input`. |
| `jobContext` | The job's instance, from `createJobContext` on. |
| `answer` | `{ result, logs }`, from `validateOutput` on. |
| `flowError` | In the `always` stages, the error that failed the attempt. |

- **Where hooks go.** A hook on a plugin or a provider of an app runs for that app's jobs; on `@FrontMcp`, for every job. On a `@Job` class, it runs for that job's attempts only: as an instance method from `createJobContext` on, with the job's context as `this`, or as a `static` method on any stage, with the class as `this`. A hook for another flow on a `@Job` class stops the server from starting. See [Hooking every attempt of a job](#hooking-every-attempt-of-a-job).
- **A hook that throws fails the attempt**, and the job's `retry` applies, unless the error is a `JobNotAuthorizedError`.
- **`ctx.respond({ result, logs })`** answers for the job, from a cache say: the stages that haven't run are skipped, `execute()` included, apart from the `always` group, and the run completes with `result` and `logs`. `result` isn't checked against `outputSchema`.

### Built-in tools

When any app declares a job or a workflow, FrontMCP adds these tools to the server:

| Tool | In `tools/list` | Arguments | Returns |
| --- | --- | --- | --- |
| `execute_job` | Yes | `name`, `input?` (an object, default `{}`), `background?` (default `false`) | Without `background`: waits for the job, then `{ runId, state, result, logs }`. With `background: true`: `{ runId, state: "pending" }` at once. |
| `get_job_status` | Yes | `runId` | `{ runId, jobName, state, result?, error?, startedAt, completedAt?, attempt, logs }`. Times are milliseconds since 1970. It also reads a workflow's run, whose `result` holds the step results. Only the caller who started the run can read it: see [who can read a run](#who-can-read-a-run). |
| `list_jobs` | No | `query?`, `tags?`, `labels?` | `{ jobs: [{ name, description?, tags, labels, inputSchema? }], count }`. `query` matches the name or description; `tags` needs any one of them; `labels` needs all of them. |
| `remove_job` | No | `name` | Removes a job registered at run time with `register_job`. For a `@Job` class it fails with `Job "…" is not a dynamic job and cannot be removed`. |

`execute_workflow` and `get_workflow_status` come with them; see [`@Workflow`](https://frontmcp.dev/reference/sdk/workflow#built-in-tools). Tools that aren't in `tools/list` can still be called by name.

A fifth job tool, `register_job`, registers a job from a script sent by the client. It exists only on servers that set `jobs: { enabled: true, allowDynamicRegistration: true }`, and isn't in `tools/list` there either. It runs code a client sent you, so leave it off unless you know you need it.

#### `@FrontMcp({ jobs })`

The server's `jobs` option controls the subsystem. You don't need it to use jobs.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | on when an app declares jobs or workflows | Required when you set `jobs`. `false` leaves the tools out even when apps declare jobs; `true` adds them even when none do. |
| `store` | `{ redis?, keyPrefix? }` | memory | Where runs are kept. By default, in the server's memory: they're lost on restart, and each server process has its own. `redis` keeps them in Redis for a day. It takes the server's [`redis`](https://frontmcp.dev/reference/deployment/redis#options) fields, `host`, `port`, `password`, `db` and `tls`, or a `url`, and the fields beside a `url` only fill in what it leaves out: one that contradicts it stops the server at startup. (Changed in 1.9.3: before, only `host` and `port` reached Redis, and `password`, `db` and `tls` were dropped, those in a `url` too.) `keyPrefix` defaults to `"mcp:jobs:"`. |
| `allowDynamicRegistration` | `boolean` | `false` | Adds `register_job` and `register_workflow`. |

The Playground always uses the memory store: Redis needs Node. The `redis` fields were checked in Node against a Redis that needs a password. Without the right password, the server doesn't start: `NOAUTH Authentication required.` when there's none, `WRONGPASS invalid username-password pair` when it's wrong. A field that contradicts the `url` stops it with a `ZodError` that names the field, like `redis password contradicts redis.url`.

#### Run states

| State | Meaning |
| --- | --- |
| `pending` | Created, not started yet. A background call returns this. |
| `running` | An attempt is running. |
| `retrying` | An attempt failed, and the job is waiting before the next one. |
| `completed` | `result` holds the result. |
| `failed` | Every attempt failed. `error` holds the last error's `message` and `name`. |

#### Who can read a run

A run belongs to the user who started it, identified by their `sub`. `get_job_status` answers only that user; everyone else gets `Run "<id>" not found`, as if the run didn't exist.

An anonymous caller on protocol 2026-07-28 gets a new identity on every request (`anon:` and a random id), so **it can never read its own runs**. That includes the Playground. To let clients poll background runs, [authenticate them](https://frontmcp.dev/learn/authenticating-clients). The examples below poll as a signed-in user, in-process, with `FrontMcpInstance.createDirect()`.

#### Caveats

- The class **must extend `JobContext`**, and like [`@Tool`](https://frontmcp.dev/reference/sdk/tool#caveats), `@Job` checks when TypeScript compiles that `execute()`'s parameter matches `inputSchema` and its return type matches `outputSchema`.
- **Hooks run once per attempt.** A [`JobHook`](#jobhook) sees every retry and every workflow step as an attempt of its own. Since 1.9.4: before, jobs ran through no flow, so a hook on the `execute_job` tool's call missed retries, background attempts and workflow steps, and a hook on a `@Job` class stopped the server from starting.
- A result that **doesn't match `outputSchema` isn't retried**: the run fails at once, whatever `retry` says.
- `timeout` is **only enforced for workflow steps**. A job started with `execute_job` runs until it finishes.
- **Bad input isn't an `INVALID_INPUT` error.** `execute_job` fails with `TOOL_EXECUTION_ERROR` and the Zod issues (in production, only "Internal FrontMCP error"), and a job with `retry` retries it, waits included.
- **Runs live in memory** unless you configure a Redis `store`: restarting the server loses them, and several processes each have their own.

---

## Usage

### Running a job and reading its result

Call `execute_job` with the job's `name` and its `input`. It waits for the job and returns the run: its id, its state, the result, and the lines the job logged.

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

const tickets = [
  { id: "T-1", customer: "Ada", status: "open" },
  { id: "T-2", customer: "Grace", status: "closed" },
  { id: "T-3", customer: "Ada", status: "open" },
];

@Job({
  name: "count-open-tickets",
  description: "Count a customer's open tickets",
  inputSchema: { customer: z.string().min(1) },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Counting open tickets for ${customer}`);
    const open = tickets.filter((t) => t.customer === customer && t.status === "open").length;
    return { customer, open };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";

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

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

test("the job tools are added to the server", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t) => t.name);
  expect(names).toEqual(["execute_job", "execute_workflow", "get_job_status", "get_workflow_status"]);
});

test("execute_job returns the result and the logs", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } });
  expect(result).toBeSuccessful();
  expect(result.json()).toEqual({
    runId: expect.any(String),
    state: "completed",
    result: { customer: "Ada", open: 2 },
    logs: [expect.stringMatching(/^\[\d{4}-\d\d-\d\dT[\d:.]+Z\] Counting open tickets for Ada$/)],
  });
});

test("list_jobs isn't listed, but can be called", async ({ mcp }) => {
  const result = await mcp.tools.call("list_jobs", {});
  expect(result.json()).toMatchObject({ count: 1, jobs: [{ name: "count-open-tickets", description: "Count a customer's open tickets" }] });
});
```

Open the **Tests** tab. The app declares no tools, yet the server has four: FrontMCP added them for the job.

### Failing

A job fails when `execute()` throws, and `execute_job` returns an `isError` result. What the client reads depends on how the job failed:

<Examples title="Failures">

#### Example: Bad input
Input that doesn't match `inputSchema` fails with `TOOL_EXECUTION_ERROR`, and the text lists each Zod issue with its path. In production, the client only gets "Internal FrontMCP error".

```ts help-desk.app.ts
import { App, Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string().min(1) },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: 2 };
  }
}

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

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

test("the Zod issues are in the text", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "" } });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_job" execution failed: \[/);
  expect(result.text()).toMatch(/"path": \[\s*"customer"\s*\]/);
});
```

#### Example: A thrown error
A plain `Error` fails with `TOOL_EXECUTION_ERROR`. In development the text has the message and the stack; in production, only "Internal FrontMCP error" and an error ID.

```ts help-desk.app.ts
import { App, Job, JobContext, z } from "@frontmcp/sdk";

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }): Promise<{ customer: string; open: number }> {
    throw new Error("The ticket database is down");
  }
}

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

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

test("the message follows the tool's", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Tool "execute_job" execution failed: The ticket database is down\n/);
});
```

#### Example: A public error
`this.fail()` with a `PublicMcpError` sends your message and code as they are, in development and in production. Use it for failures the model can act on.

```ts help-desk.app.ts
import { App, Job, JobContext, PublicMcpError, z } from "@frontmcp/sdk";

const customers = ["Ada", "Grace"];

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    if (!customers.includes(customer)) {
      this.fail(new PublicMcpError(`There's no customer ${customer}.`, "CUSTOMER_NOT_FOUND"));
    }
    return { customer, open: 2 };
  }
}

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

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

test("the client reads the message and the code", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Zed" } });
  expect(result).toBeError("CUSTOMER_NOT_FOUND");
  expect(result.text()).toBe("There's no customer Zed.");
});
```

### Retrying a job that fails

With `retry`, a failed attempt is tried again after a wait. Here the mail server is busy two times out of three, so the job succeeds on its third attempt, after waiting 100 and then 200 milliseconds:

```ts notify-agent.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";
import { sendEmail } from "./mail";

@Job({
  name: "notify-agent",
  description: "Email a support agent about a ticket",
  inputSchema: { agent: z.string(), ticket: z.string() },
  outputSchema: { sent: z.boolean() },
  retry: { maxAttempts: 3, backoffMs: 100 },
})
export class NotifyAgent extends JobContext {
  async execute({ agent, ticket }: { agent: string; ticket: string }) {
    this.log(`Attempt ${this.attempt}: emailing ${agent} about ${ticket}`);
    sendEmail(agent, `${ticket} needs you`);
    return { sent: true };
  }
}
```

```ts mail.ts
let calls = 0;

// Stands in for a mail server that's busy two times out of three.
export function sendEmail(to: string, subject: string) {
  calls += 1;
  if (calls % 3 !== 0) throw new Error("Mail server busy");
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { NotifyAgent } from "./notify-agent.job";

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

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

test("the third attempt succeeds, after two waits", async ({ mcp }) => {
  const started = Date.now();
  const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-1" } });
  expect(Date.now() - started).toBeGreaterThanOrEqual(300);
  expect(result.json()).toMatchObject({ state: "completed", result: { sent: true } });
});

test("only the successful attempt's logs are kept, and it knows it's the third", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-2" } });
  expect(result.json().logs).toEqual([expect.stringMatching(/\] Attempt 3: emailing nour about T-2$/)]);
});
```

If the last attempt fails too, the call fails with that attempt's error. `this.attempt` says which attempt is running. Only retry work that's safe to repeat: here, a second email is better than none, but a job that charges a card needs to know whether the first attempt went through.

### Running a job in the background

With `background: true`, `execute_job` returns a `runId` at once, and the job runs after the call has returned. The caller that started it polls `get_job_status` with the `runId` until the run is `completed` or `failed`.

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

@Job({
  name: "export-tickets",
  description: "Export a customer's tickets as CSV",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), csv: z.string() },
})
export class ExportTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    this.log(`Exporting tickets for ${customer}`);
    await new Promise((resolve) => setTimeout(resolve, 100)); // stands in for a slow query
    return { customer, csv: "id,title\nT-1,Cannot log in\nT-3,Login link expired" };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { ExportTickets } from "./export-tickets.job";

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

```ts background.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

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

test("a background run returns at once", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "export-tickets", input: { customer: "Ada" }, background: true });
  expect(result.json()).toEqual({ runId: expect.any(String), state: "pending" });
});

test("an anonymous caller can't read its own run", async ({ mcp }) => {
  const { runId } = (await mcp.tools.call("execute_job", { name: "export-tickets", input: { customer: "Ada" }, 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 polls until the run completes", async () => {
  // createDirect() runs the server in-process, as the user you name.
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asNour = { authContext: { user: { sub: "nour" } } };
  const started = await server.callTool("execute_job", { name: "export-tickets", input: { customer: "Ada" }, background: true }, asNour);
  const { runId } = started.structuredContent as { runId: string };

  const first = await server.callTool("get_job_status", { runId }, asNour);
  await sleep(250);
  const last = await server.callTool("get_job_status", { runId }, asNour);
  const asSam = server.callTool("get_job_status", { runId }, { authContext: { user: { sub: "sam" } } });
  await expect(asSam).rejects.toThrow(`Run "${runId}" not found`);
  await server.dispose();

  expect(first.structuredContent).toMatchObject({ runId, jobName: "export-tickets", state: "running", logs: [] });
  expect(last.structuredContent).toMatchObject({
    state: "completed",
    result: { customer: "Ada" },
    attempt: 1,
    startedAt: expect.any(Number),
    completedAt: expect.any(Number),
  });
});
```

Without the TC39 `AsyncContext`, FrontMCP's [browser build](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser) serves one request at a time and runs a background job before the next one, so there the first poll already finds the run `completed`. The Playground provides a minimal `AsyncContext`, so here the run is still `running` at first, as on Node.

The Call tab shows the `pending` run. Calling `get_job_status` with its `runId` from the Call tab fails with "not found": the Playground's caller is anonymous, and [nobody can read an anonymous caller's runs](#who-can-read-a-run), not even that caller. The last test signs in as `nour`, who can, and `sam`, who can't. [Running Jobs in the Background](https://frontmcp.dev/learn/running-jobs-in-the-background) walks through polling.

### Using a provider

A job gets providers with `this.get()`, from its app and from the server, as the app's tools do. Both get the same instance, so a tool and a job share data:

```ts count-open.job.ts active
import { Job, JobContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: this.get(TicketStore).openFor(customer).length };
  }
}
```

```ts close-ticket.tool.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";
import { TicketStore } from "./ticket-store";

@Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    this.get(TicketStore).close(id);
    return { id, status: "closed" };
  }
}
```

```ts ticket-store.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets = [
    { id: "T-1", customer: "Ada", status: "open" },
    { id: "T-2", customer: "Grace", status: "closed" },
    { id: "T-3", customer: "Ada", status: "open" },
  ];

  openFor(customer: string) {
    return this.tickets.filter((t) => t.customer === customer && t.status === "open");
  }

  close(id: string) {
    const ticket = this.tickets.find((t) => t.id === id);
    if (ticket) ticket.status = "closed";
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket } from "./close-ticket.tool";
import { CountOpenTickets } from "./count-open.job";
import { TicketStore } from "./ticket-store";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [CloseTicket],
  jobs: [CountOpenTickets],
  providers: [TicketStore], // for the app's tools and its jobs
})
export class HelpDeskApp {}

@FrontMcp({ info: { name: "Help Desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class HelpDeskServer {}
```

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

test("the job sees what the tool changed", async ({ mcp }) => {
  const count = async () =>
    (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } })).json().result.open;
  expect(await count()).toBe(2);
  await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(await count()).toBe(1);
});
```

A provider in `@FrontMcp({ providers })` works too, and is shared with every app. A provider on another app isn't available to the job: see [Troubleshooting](#provider--is-not-available-in-a-job). (Changed in 1.9.2: before, a job only saw the server's providers.)

### Calling another service from a job

`this.fetch()` works in a job as in a tool, and sends the request's tracing headers, so the other service's logs tie its request to the call that started the job. A background run keeps the caller's trace:

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

@Job({
  name: "sync-customer",
  description: "Copy a customer's plan from the CRM",
  inputSchema: { customerId: z.string() },
  outputSchema: { customerId: z.string(), plan: z.string() },
})
export class SyncCustomer extends JobContext {
  async execute({ customerId }: { customerId: string }) {
    const response = await this.fetch(`https://crm.example/customers/${customerId}`);
    const { plan } = await response.json();
    return { customerId, plan };
  }
}
```

```ts crm.example.ts
// Stands in for the CRM, which the Playground can't reach. It answers requests for
// crm.example in-process, and keeps the tracing headers of each. A real server
// doesn't need this file.
export const received: { traceparent: string | null; requestId: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.host !== "crm.example") return realFetch(input, init);
  const headers = new Headers(init?.headers);
  received.push({ traceparent: headers.get("traceparent"), requestId: headers.get("x-request-id") });
  return Response.json({ plan: "business" });
};
```

```ts help-desk.app.ts
import "./crm.example";
import { App } from "@frontmcp/sdk";
import { SyncCustomer } from "./sync-customer.job";

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

```ts fetch.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { received } from "./crm.example";
import { HelpDeskApp } from "./help-desk.app";

const traced = { traceparent: expect.stringMatching(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/), requestId: expect.any(String) };

test("the CRM gets the tracing headers", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "sync-customer", input: { customerId: "C-1" } });
  expect(result.json().result).toEqual({ customerId: "C-1", plan: "business" });
  expect(received.at(-1)).toEqual(traced);
});

test("a background run sends them too", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asNour = { authContext: { user: { sub: "nour" } } };
  const before = received.length;
  await server.callTool("execute_job", { name: "sync-customer", input: { customerId: "C-1" }, background: true }, asNour);
  await new Promise((resolve) => setTimeout(resolve, 100));
  await server.dispose();
  expect(received.slice(before)).toEqual([traced]);
});
```

In the background, `this.context` is a copy of the starting call's: the same session, caller and trace, and a request id of its own, since the call it came from has already returned. (Changed in 1.9.2: before, a job had no request context, `this.context` threw, and `this.fetch()` sent no tracing headers.)

### Restricting who can run a job

`permissions` limits a job to callers with a role, a scope, or anything a function can check. Anyone else gets the same error as for a job that doesn't exist, and `list_jobs` doesn't show them the job:

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

@Job({
  name: "export-invoices",
  description: "Export a month's invoices",
  inputSchema: { month: z.string().regex(/^\d{4}-\d\d$/) },
  outputSchema: { month: z.string(), invoices: z.number() },
  permissions: [{ action: "execute", roles: ["billing"] }],
})
export class ExportInvoices extends JobContext {
  async execute({ month }: { month: string }) {
    return { month, invoices: 42 };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { ExportInvoices } from "./export-invoices.job";

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

```ts permissions.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

test("an anonymous caller is refused, as if the job didn't exist", async ({ mcp }) => {
  const refused = await mcp.tools.call("execute_job", { name: "export-invoices", input: { month: "2026-08" } });
  expect(refused).toBeError("JOB_NOT_AUTHORIZED");
  expect(refused.text()).toBe('Job or workflow "export-invoices" not found or not permitted');

  const missing = await mcp.tools.call("execute_job", { name: "export-payroll" });
  expect(missing.text()).toBe('Job or workflow "export-payroll" not found or not permitted');
});

test("list_jobs doesn't show it to them", async ({ mcp }) => {
  expect((await mcp.tools.call("list_jobs", {})).json()).toEqual({ jobs: [], count: 0 });
});

test("someone in billing can run it", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  const result = await server.callTool(
    "execute_job",
    { name: "export-invoices", input: { month: "2026-08" } },
    { authContext: { user: { sub: "li", roles: ["billing"] } } },
  );
  await server.dispose();
  expect(result.structuredContent).toMatchObject({ state: "completed", result: { month: "2026-08", invoices: 42 } });
});
```

### Hooking every attempt of a job

A [`JobHook`](#jobhook) runs for each attempt of a job, so a plugin can audit, time or cache job runs the way it hooks tool calls. `ctx.state.attempt` tells the attempts apart, and `ctx.state.flowError` is set in the `always` stages when the attempt failed.

<Examples title="Job hooks">

#### Example: Seeing each attempt
The plugin records how each attempt ended. The mail server is busy two times out of three, so one call makes three attempts:

```ts attempt-log.plugin.ts active
import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk";

export const attempts: string[] = [];

@Plugin({ name: "attempt-log" })
export class AttemptLogPlugin extends DynamicPlugin<object> {
  @JobHook.Did("finalize")
  async record(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { job, attempt, flowError } = ctx.state;
    attempts.push(`${job?.name} attempt ${attempt}: ${flowError ? flowError.message : "completed"}`);
  }
}
```

```ts notify-agent.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";
import { sendEmail } from "./mail";

@Job({
  name: "notify-agent",
  description: "Email a support agent about a ticket",
  inputSchema: { agent: z.string(), ticket: z.string() },
  outputSchema: { sent: z.boolean() },
  retry: { maxAttempts: 3, backoffMs: 50 },
})
export class NotifyAgent extends JobContext {
  async execute({ agent, ticket }: { agent: string; ticket: string }) {
    sendEmail(agent, `${ticket} needs you`);
    return { sent: true };
  }
}
```

```ts mail.ts
let calls = 0;

// Stands in for a mail server that's busy two times out of three.
export function sendEmail(to: string, subject: string) {
  calls += 1;
  if (calls % 3 !== 0) throw new Error("Mail server busy");
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { AttemptLogPlugin } from "./attempt-log.plugin";
import { NotifyAgent } from "./notify-agent.job";

@App({ id: "help-desk", name: "Help Desk", jobs: [NotifyAgent], plugins: [AttemptLogPlugin] })
export class HelpDeskApp {}
```

```ts attempts.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { attempts } from "./attempt-log.plugin";
import { HelpDeskApp } from "./help-desk.app";

test("the hook runs once per attempt", async ({ mcp }) => {
  attempts.length = 0;
  await mcp.tools.call("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-2" } });
  expect(attempts).toEqual([
    "notify-agent attempt 1: Mail server busy",
    "notify-agent attempt 2: Mail server busy",
    "notify-agent attempt 3: completed",
  ]);
});

test("a background run's attempts too", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  attempts.length = 0;
  const asNour = { authContext: { user: { sub: "nour" } } };
  await server.callTool("execute_job", { name: "notify-agent", input: { agent: "nour", ticket: "T-3" }, background: true }, asNour);
  await new Promise((resolve) => setTimeout(resolve, 300));
  await server.dispose();
  expect(attempts).toHaveLength(3);
  expect(attempts.at(-1)).toBe("notify-agent attempt 3: completed");
});
```

#### Example: Answering from a cache
A hook can answer for the job with `ctx.respond({ result, logs })`. This one saves each report the job builds, and answers the next request for the same day from it, without running `execute()`:

```ts report-cache.plugin.ts active
import { DynamicPlugin, FlowCtxOf, JobHook, Plugin } from "@frontmcp/sdk";

const reports = new Map<string, unknown>();

@Plugin({ name: "report-cache" })
export class ReportCachePlugin extends DynamicPlugin<object> {
  @JobHook.Will("execute", { filter: (ctx) => ctx.state.job?.name === "build-daily-report" })
  async answerFromCache(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { day } = ctx.state.parsedInput as { day: string };
    if (reports.has(day)) ctx.respond({ result: reports.get(day), logs: [`Report for ${day} from the cache`] });
  }

  @JobHook.Did("validateOutput", { filter: (ctx) => ctx.state.job?.name === "build-daily-report" })
  async save(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { day } = ctx.state.parsedInput as { day: string };
    reports.set(day, ctx.state.answer?.result);
  }
}
```

```ts build-daily-report.job.ts
import { Job, JobContext, z } from "@frontmcp/sdk";

export const built: string[] = [];

@Job({
  name: "build-daily-report",
  description: "Count the tickets opened and closed on one day.",
  inputSchema: { day: z.string() },
  outputSchema: { day: z.string(), opened: z.number(), closed: z.number() },
})
export class BuildDailyReport extends JobContext {
  async execute({ day }: { day: string }) {
    built.push(day);
    this.log(`Read every ticket of ${day}`);
    return { day, opened: 12, closed: 9 };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { BuildDailyReport } from "./build-daily-report.job";
import { ReportCachePlugin } from "./report-cache.plugin";

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

```ts cache.test.ts
import { test, expect } from "@frontmcp/testing";
import { built } from "./build-daily-report.job";

test("the second run of a day is answered from the cache", async ({ mcp }) => {
  built.length = 0;
  const run = () => mcp.tools.call("execute_job", { name: "build-daily-report", input: { day: "2026-09-25" } });
  const first = (await run()).json();
  const second = (await run()).json();
  expect(built).toEqual(["2026-09-25"]);
  expect(first.logs).toEqual([expect.stringMatching(/\] Read every ticket of 2026-09-25$/)]);
  expect(second).toEqual({
    runId: expect.any(String),
    state: "completed",
    result: { day: "2026-09-25", opened: 12, closed: 9 },
    logs: ["Report for 2026-09-25 from the cache"],
  });
});
```

The answer becomes the run's result, which `get_job_status` returns too. It isn't checked against `outputSchema`, so cache only results that passed it, as `save` does after `validateOutput`.

#### Example: On the job class
A `@Job` class can declare hooks for its own attempts. Stages before `createJobContext` run before the job's instance exists, so a hook on them is a `static` method, with the class as `this`. Here a static hook tidies the input before `inputSchema` checks it, and an instance hook adds a line to the run's logs:

```ts count-open.job.ts active
import { FlowCtxOf, Job, JobContext, JobHook, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", customer: "Ada", status: "open" },
  { id: "T-3", customer: "Ada", status: "open" },
];

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.enum(["Ada", "Grace"]) },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  @JobHook.Will("validateInput")
  static tidyCustomer(ctx: FlowCtxOf<"jobs:execute-job">) {
    const { customer } = ctx.state.input as { customer: string };
    const name = customer.trim();
    ctx.state.set("input", { customer: name.charAt(0).toUpperCase() + name.slice(1).toLowerCase() });
  }

  @JobHook.Did("execute")
  async noteAttempt() {
    this.log(`Counted on attempt ${this.attempt}`);
  }

  async execute({ customer }: { customer: string }) {
    return { customer, open: tickets.filter((t) => t.customer === customer && t.status === "open").length };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";

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

```ts class-hooks.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Job, JobContext, JobHook, z } from "@frontmcp/sdk";

test("the static hook tidies the input, and the instance hook logs", async ({ mcp }) => {
  const run = (await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "  ada " } })).json();
  expect(run.result).toEqual({ customer: "Ada", open: 2 });
  expect(run.logs).toEqual([expect.stringMatching(/\] Counted on attempt 1$/)]);
});

test("an instance hook before `createJobContext` stops the server", async () => {
  @Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() } })
  class TidyCountJob extends JobContext {
    @JobHook.Will("validateInput")
    async tidyCustomer() {}

    async execute() {
      return { open: 2 };
    }
  }
  @App({ id: "help-desk", name: "Help Desk", jobs: [TidyCountJob] })
  class HelpDeskApp {}

  const starting = FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
  await expect(starting).rejects.toThrow(
    "Job \"TidyCountJob\" declares hooks that would never run: tidyCustomer() (will 'validateInput' of jobs:execute-job): runs before 'createJobContext' builds the instance it runs on; declare it as a static method to run it without an instance.",
  );
});
```

A plugin or a provider on an app hooks that app's jobs only. To hook every job a server runs, register the plugin on `@FrontMcp`. [Hooks](https://frontmcp.dev/reference/sdk/hooks) covers `Will`, `Did`, `Around` and `Stage`, which work on `JobHook` as on every flow.

### Writing a job as a function

For a small job that doesn't log, the function form is shorter. The handler gets the `JobContext` as its second argument, for `ctx.get()` and `ctx.auth`.

```ts help-desk.app.ts
import { App, job, z } from "@frontmcp/sdk";

export const shout = job({
  name: "shout",
  inputSchema: { text: z.string() },
  outputSchema: { text: z.string() },
})(async ({ text }) => {
  return { text: text.toUpperCase() };
});

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

---

## Troubleshooting

### `Job or workflow "…" not found or not permitted`

`execute_job` found no job by that name that the caller may run. Check, in order:

1. The name is spelled as in `@Job({ name })`. If the job also sets `id`, use the `id`.
2. The class is in an app's `jobs` array, and the app is in `@FrontMcp({ apps })`.
3. The job's `permissions` let this caller run it. The error is the same on purpose, so a caller can't tell a job they may not run from one that doesn't exist. Call `list_jobs` as that caller to see what they can run.

### `Run "…" not found`

`get_job_status` only answers the caller who started the run, and says "not found" to everyone else. An anonymous caller on protocol 2026-07-28, like the Playground's, can't read its own runs either, because each of its requests has a new identity. See [who can read a run](#who-can-read-a-run). Also check that the `runId` is from this server since its last restart: runs live in memory by default.

### `Provider "…" is not available` in a job

A job resolves providers from its own app and from the server, so a provider registered on another app is missing:

```ts help-desk.app.ts
import { App, FrontMcp, Job, JobContext, Provider, z } from "@frontmcp/sdk";

@Provider({ name: "TicketStore" })
export class TicketStore {
  openFor(customer: string) {
    return customer === "Ada" ? ["T-1", "T-3"] : [];
  }
}

@Job({
  name: "count-open-tickets",
  inputSchema: { customer: z.string() },
  outputSchema: { customer: z.string(), open: z.number() },
})
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    return { customer, open: this.get(TicketStore).openFor(customer).length };
  }
}

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

// 🚩 TicketStore is on another app
@App({ id: "tickets", name: "Tickets", providers: [TicketStore] })
export class TicketsApp {}

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

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

test("the job can't find the other app's provider", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toContain('Provider "TicketStore" is not available: not found in local or parent registries');
});
```

Register the provider on the job's app, or on the server with `@FrontMcp({ providers })` to share it between apps, as in [Using a provider](#using-a-provider). Before 1.9.2, a job didn't see its own app's providers either, so servers written for 1.9.1 often have them on `@FrontMcp`, where they still work.

### `Tool "execute_job" execution failed: [` followed by a list

The input didn't match the job's `inputSchema`. Each entry in the list has the field's `path` and a `message`. See [Bad input](#failing). If the job has `retry`, the bad input was retried too, so the call took as long as every attempt and wait.

### A job runs past its `timeout`

`execute_job` doesn't enforce `timeout`; only a [workflow step](https://frontmcp.dev/reference/sdk/workflow#options) does. A job started with `execute_job` runs until `execute()` returns:

```ts help-desk.app.ts
import { App, Job, JobContext, z } from "@frontmcp/sdk";

@Job({ name: "export-tickets", inputSchema: {}, outputSchema: { rows: z.number() }, timeout: 50 })
export class ExportTickets extends JobContext {
  async execute() {
    await new Promise((resolve) => setTimeout(resolve, 150));
    return { rows: 3 };
  }
}

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

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

test("the job finishes after its timeout", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "export-tickets", input: {} });
  expect(result.json()).toMatchObject({ state: "completed", result: { rows: 3 } });
});
```

If a job must stop, give the slow work inside `execute()` its own deadline.

### `Tool output validation failed (output does not match outputSchema at …)`

The job returned something `outputSchema` doesn't describe, and the text names the first field that doesn't match. `execute_job` fails with `INVALID_OUTPUT`, a background run fails with `InvalidOutputError` in its `error`, and the job isn't retried, even with `retry`. Fields the schema doesn't declare are dropped instead:

```ts help-desk.app.ts
import { App, Job, JobContext, z } from "@frontmcp/sdk";

export let attempts = 0;

// Stands in for rows from a database you don't control: "open" comes back as text.
const rows: Record<string, Record<string, unknown>> = {
  Ada: { open: "2", internalNote: "VIP" },
  Grace: { open: 0, internalNote: "Prefers email" },
};

@Job({ name: "count-open-tickets", inputSchema: { customer: z.string() }, outputSchema: { open: z.number() }, retry: { maxAttempts: 3, backoffMs: 10 } })
export class CountOpenTickets extends JobContext {
  async execute({ customer }: { customer: string }) {
    attempts += 1;
    return rows[customer] as { open: number };
  }
}

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

```ts output.test.ts
import { test, expect } from "@frontmcp/testing";
import { attempts } from "./help-desk.app";

test("a value of the wrong type fails the run, without a retry", async ({ mcp }) => {
  const before = attempts;
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Ada" } });
  expect(result).toBeError("INVALID_OUTPUT");
  expect(result.text()).toBe("Tool output validation failed (output does not match outputSchema at open)");
  expect(attempts - before).toBe(1);
});

test("a field the schema doesn't declare is dropped", async ({ mcp }) => {
  const result = await mcp.tools.call("execute_job", { name: "count-open-tickets", input: { customer: "Grace" } });
  expect(result.json().result).toEqual({ open: 0 });
});
```

TypeScript catches most mismatches when `execute()` returns a typed value. For data you don't control, build the result from the fields you mean to send, or parse it with the schema yourself, `z.object({ open: z.number() }).parse(row)`, and fail with a [`PublicMcpError`](#failing) when it doesn't parse. (Changed in 1.9.2: before, FrontMCP didn't check the result, and sent it as it was.)

### `Job "…" declares hooks that would never run`

The server doesn't start, with `InvalidHookFlowError`: a `@Job` class has an instance method hook on a stage before `createJobContext`, where the job's instance doesn't exist yet. The message names the method and the stage, and says to `declare it as a static method`. Make it `static`, and read what it needs from its `ctx` argument instead of `this`, as [On the job class](#hooking-every-attempt-of-a-job) does. `Job "…" has hooks for unsupported flows` means a hook on the class is for another flow, like a `ToolHook`: move it to a plugin or a provider.

### TypeScript says `Unable to resolve signature of class decorator`

`@Job` checks the class against its options, like [`@Tool`](https://frontmcp.dev/reference/sdk/tool#typescript-says-unable-to-resolve-signature-of-class-decorator). Error `TS1238` on the decorator line says which check failed:

- `'execute() parameter error': "Parameter type does not match the input schema."`: `execute()`'s parameter type disagrees with `inputSchema`.
- `'execute() return type error': "The method's return type is not assignable to the expected output schema type."`: `execute()` returns something `outputSchema` doesn't describe.
- `'Job class error': "Class must extend JobContext"`: the class doesn't extend `JobContext`.

A `@Job` without `outputSchema` doesn't compile either, and fails when the class is loaded with a Zod issue at the path `outputSchema`.
