@Job
@Job declares a 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 while the client does something else, and FrontMCP can retry it when it fails. Jobs are also the steps of a @Workflow.
@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.
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 };
}
}import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open.job";
@App({ id: "help-desk", name: "Help Desk", jobs: [CountOpenTickets] })
export class HelpDeskApp {}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 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. (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. |
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. |
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.
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 ininputSchemaare dropped. The same object isthis.input.- Returns the job's result:
resultin whatexecute_jobreturns, andoutputswhen 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, from the job's app and from the server. See 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: 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(). |
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. Context classes 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(), 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. (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.
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: a run execute_job starts, inline or in the background, each retry, and each workflow step. JobHook hooks it, as ToolHook 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 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@Jobclass, it runs for that job's attempts only: as an instance method fromcreateJobContexton, with the job's context asthis, or as astaticmethod on any stage, with the class asthis. A hook for another flow on a@Jobclass stops the server from starting. See Hooking every attempt of a job. - A hook that throws fails the attempt, and the job's
retryapplies, unless the error is aJobNotAuthorizedError. ctx.respond({ result, logs })answers for the job, from a cache say: the stages that haven't run are skipped,execute()included, apart from thealwaysgroup, and the run completes withresultandlogs.resultisn't checked againstoutputSchema.
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. |
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. 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 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. The examples below poll as a signed-in user, in-process, with FrontMcpInstance.createDirect().
Caveats
- The class must extend
JobContext, and like@Tool,@Jobchecks when TypeScript compiles thatexecute()'s parameter matchesinputSchemaand its return type matchesoutputSchema. - Hooks run once per attempt. A
JobHooksees 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 theexecute_jobtool's call missed retries, background attempts and workflow steps, and a hook on a@Jobclass stopped the server from starting. - A result that doesn't match
outputSchemaisn't retried: the run fails at once, whateverretrysays. timeoutis only enforced for workflow steps. A job started withexecute_jobruns until it finishes.- Bad input isn't an
INVALID_INPUTerror.execute_jobfails withTOOL_EXECUTION_ERRORand the Zod issues (in production, only "Internal FrontMCP error"), and a job withretryretries 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.
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
Failures
Example 1 of 3
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".
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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.
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Without the TC39 AsyncContext, FrontMCP's browser build 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, not even that caller. The last test signs in as nour, who can, and sam, who can't. 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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. (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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Hooking every attempt of a job
A 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.
Job hooks
Example 1 of 3
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:
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"}`);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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.
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
- The name is spelled as in
@Job({ name }). If the job also setsid, use theid. - The class is in an app's
jobsarray, and the app is in@FrontMcp({ apps }). - The job's
permissionslet 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. Calllist_jobsas 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. 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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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. 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. 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 does. A job started with execute_job runs until execute() returns:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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:
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 {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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 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 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. 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 withinputSchema.'execute() return type error': "The method's return type is not assignable to the expected output schema type.":execute()returns somethingoutputSchemadoesn't describe.'Job class error': "Class must extend JobContext": the class doesn't extendJobContext.
A @Job without outputSchema doesn't compile either, and fails when the class is loaded with a Zod issue at the path outputSchema.