Your First Job
Every tool so far has done its work inside one call and answered. A job is work you hand to FrontMCP instead: a named piece of work with an input schema and an output schema, which FrontMCP runs when a client asks, records as a run, and can retry, run in the background, or chain with other jobs. This lesson writes one, runs it the way a client would, and looks at everything that comes back, including what happens when the input is wrong or the job fails.
You will learn
- How to write a job with
@JobandJobContext, and register it on an app - Which tools FrontMCP adds for jobs, and how a client runs one with
execute_job - What a run's result holds:
runId,state,resultandlogs - What the caller gets back when the input is wrong or the job throws
- When to write a job, and when a tool is the better choice
Writing a job
The help desk promises to answer high-priority tickets within 4 hours and normal ones within a day. Finding the open tickets that broke that promise reads every open ticket, it should run every night whether or not anyone is chatting with a model, and later in this chapter it becomes one step of a nightly report. That's work for a job. Open the Tests tab:
import { Job, JobContext, z } from "@frontmcp/sdk";
import { tickets } from "./store";
const SLA_HOURS = { high: 4, normal: 24 };
@Job({
name: "find-sla-breaches",
description: "Find the open tickets of one priority that have waited longer than their SLA allows.",
inputSchema: {
priority: z.enum(["high", "normal"]).describe("Which tickets to check"),
},
outputSchema: {
priority: z.enum(["high", "normal"]),
breaches: z.array(z.object({ id: z.string(), hoursOpen: z.number() })),
},
})
export class FindSlaBreaches extends JobContext {
async execute({ priority }: { priority: "high" | "normal" }) {
const open = tickets.filter((t) => t.status === "open" && t.priority === priority);
this.log(`Checking ${open.length} open ${priority}-priority tickets`);
const breaches = open
.filter((t) => t.hoursOpen > SLA_HOURS[priority])
.map((t) => ({ id: t.id, hoursOpen: t.hoursOpen }));
this.log(`Found ${breaches.length} past the ${SLA_HOURS[priority]}-hour SLA`);
return { priority, breaches };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A job looks a lot like a tool:
@Job({ name, description, inputSchema, outputSchema })describes it.nameis what a client asks for.inputSchemaandoutputSchemaare Zod shapes, as on a tool, and both are required: a job without anoutputSchemafails as soon as its class is decorated.JobContextis the base class, andexecute(input)does the work. It gets the input already checked againstinputSchema, and what it returns is the job's result.this.log(message)adds a line to the run's logs, with the time in front.jobs: [FindSlaBreaches]on@Appregisters it. That's all it takes: FrontMCP turns jobs on for any server with an app that has jobs or workflows.
How a client runs a job
The job isn't a tool, and it doesn't show up in tools/list. Open the Capabilities tab of the example above: registering one job added four tools of FrontMCP's own, and the call in the Call tab went through the first of them.
| Tool | What it does |
|---|---|
execute_job | Runs the job called name with input, and returns the run. background: true starts it and returns at once, which the next lesson covers. |
get_job_status | Reads a run by its runId. |
execute_workflow | Runs a workflow, a chain of jobs. See Chaining Jobs into Workflows. |
get_workflow_status | Reads a workflow's run. |
All four appear as soon as one app has a job, even if there are no workflows. FrontMCP also adds list_jobs, list_workflows, remove_job and remove_workflow, but leaves them out of tools/list. They still answer when a client calls them by name.
That leaves a model with a problem. It sees execute_job, described as "Execute a registered job by name", and nothing that says which names exist or what input they take. Tell it, in the server's instructions:
import { App, FrontMcp } from "@frontmcp/sdk";
import { FindSlaBreaches } from "./find-sla-breaches.job";
@App({ id: "help-desk", name: "Help Desk", jobs: [FindSlaBreaches] })
class HelpDeskApp {}
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
instructions:
"Help desk jobs, run with execute_job. " +
'find-sla-breaches, input { priority: "high" | "normal" }: lists open tickets that have waited longer than their SLA.',
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Clients read instructions from server/discover and usually add them to the model's context. list_jobs returns each job's name, description and input schema, but a model only calls a tool it has heard of, so don't rely on it.
From here on, examples that don't need a server of their own leave out main.ts. When an example exports jobs but no app, the Playground registers them in one for you, as it does tools.
What comes back
execute_job runs the job and answers when it's finished, with the run as structuredContent and as JSON text:
{
"runId": "5b0e3c1e-8a47-4c1e-9d0a-1f6c2e7b9a30",
"state": "completed",
"result": { "priority": "high", "breaches": [{ "id": "T-1", "hoursOpen": 6 }] },
"logs": [
"[2026-09-27T09:00:00.123Z] Checking 2 open high-priority tickets",
"[2026-09-27T09:00:00.124Z] Found 1 past the 4-hour SLA"
]
}
runIdnames this run, and it's new every time. It's whatget_job_statustakes.stateis"completed". A run that fails doesn't come back as a run at all, but as an error, below.resultis whatexecute()returned, checked againstoutputSchema.logsare thethis.log()lines, each starting with the time it was written in ISO format.
When the input is wrong
execute_job's own input schema takes any object as input. FrontMCP checks it against the job's inputSchema when it runs the job, and a mismatch fails the call. Here the model asked for a priority that doesn't exist, and a job name that doesn't either:
import { test, expect } from "@frontmcp/testing";
test("input that doesn't match the job's schema fails the call", async ({ mcp }) => {
const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches", input: { priority: "urgent" } });
expect(result).toBeError("TOOL_EXECUTION_ERROR");
expect(result.text()).toMatch(/^Tool "execute_job" execution failed: \[/);
expect(result.text()).toContain('"path": [\n "priority"\n ]');
});
test("leaving out `input` is the same as sending {}", async ({ mcp }) => {
const result = await mcp.tools.call("execute_job", { name: "find-sla-breaches" });
expect(result).toBeError("TOOL_EXECUTION_ERROR");
expect(result.text()).toContain("expected one of");
});
test("an unknown job name is refused", async ({ mcp }) => {
const result = await mcp.tools.call("execute_job", { name: "find-breaches", input: { priority: "high" } });
expect(result).toBeError("JOB_NOT_AUTHORIZED");
expect(result.text()).toBe('Job or workflow "find-breaches" not found or not permitted');
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A tool with the same schema would refuse "urgent" with INVALID_INPUT and Zod's message. A job's input is checked later, inside execute_job, so the call fails with TOOL_EXECUTION_ERROR: Tool "execute_job" execution failed: and then Zod's list of problems. That's the development message, which is what the Playground shows. In production, FrontMCP hides the message of an error like this, and the model only gets Internal FrontMCP error. Please contact support with error ID: …, with nothing to say what to fix.
An unknown name gets JOB_NOT_AUTHORIZED: Job or workflow "find-breaches" not found or not permitted. A job the caller isn't allowed to run, because of the job's permissions, gets exactly the same answer, so a caller can't find out which jobs exist by guessing. This message is kept in production.
So the model has to get the name and the input right the first time. Say both in instructions, as in the previous section, with the allowed values spelled out.
When a job fails
A job that throws fails the call. What the caller reads depends on what you throw, just as it does for a tool. This job copies a customer's plan from the CRM:
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() },
})
export class SyncCustomer extends JobContext {
async execute({ customerId }: { customerId: string }) {
const customer = await crm.find(customerId);
if (!customer) {
// ✅ A message the model can act on, kept in production
throw new PublicMcpError(`There's no customer ${customerId} in the CRM. Check the id with the user.`);
}
return { customerId, plan: customer.plan };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
PublicMcpError, thrown or passed tothis.fail(), fails the call with your message, in production too, and codePUBLIC_ERRORunless you pass it a code of your own. Use it when the model can do something about the failure, like asking the user for the right id.- Any other error fails it with
TOOL_EXECUTION_ERROR. In development the text isTool "execute_job" execution failed:with the error's message and stack; in production it'sInternal FrontMCP error. Please contact support with error ID: …, which keeps the address of your database out of the model's context.
Either way, the caller gets an error, not a run with state: "failed". Failed runs are recorded too, and the next lesson shows how to read one.
A job as a function
A job with no state of its own can be a function. job({...}) takes the same options as @Job and returns a function that takes the handler; register what it returns, as you would a class:
import { App, FrontMcp, job, z } from "@frontmcp/sdk";
const FormatTicketId = job({
name: "format-ticket-id",
description: "Turn a ticket number into the id customers see, like T-0042.",
inputSchema: { number: z.number().int().positive() },
outputSchema: { id: z.string() },
})(({ number }, ctx) => {
const id = `T-${String(number).padStart(4, "0")}`;
ctx.log(`Formatted ${number} as ${id}`);
return { id };
});
@App({ id: "help-desk", name: "Help Desk", jobs: [FormatTicketId] })
class HelpDeskApp {}
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The handler gets the job's context as its second argument, so ctx.log() writes to the run's logs, as this.log() does in a class. (Changed in 1.9.1: before, log() was protected, so ctx.log() didn't compile.)
Jobs or tools?
A job can do anything a tool can, so which should you write?
| Tool | Job | |
|---|---|---|
| How the model finds it | In tools/list, with its own description and schema | By a name you have to tell it, through execute_job |
| When it runs | When called, and the answer comes back in the same call | When execute_job is called, in the call or in the background |
| If it fails | The call fails | The call fails, or FrontMCP retries it first |
| Afterwards | Nothing is kept | A run with an id, a state and logs |
| In a workflow | No | Yes, as a step |
If the model should call it and use the answer right away, like searching tickets or closing one, write a tool: it gets a proper description and schema in tools/list, and errors the model can read. Write a job for work that takes a while, that should be retried when it fails, that should keep going after the call returns, or that is one step of something bigger. A nightly report is all four.
Recap
- A job is a class that extends
JobContext, decorated with@Job({ name, inputSchema, outputSchema }), or a function made withjob({...}). Register it with@App({ jobs: [...] }). - Jobs aren't in
tools/list. FrontMCP addsexecute_job,get_job_status,execute_workflowandget_workflow_status, and the model has to be told which job names exist, for example ininstructions. execute_job { name, input }runs the job and returns{ runId, state: "completed", result, logs }.this.log()lines start with the time.- A result that doesn't match
outputSchemafails withINVALID_OUTPUT, and fields it doesn't declare are dropped. Input that doesn't matchinputSchemafails withTOOL_EXECUTION_ERROR, whose message production hides. An unknown name isJOB_NOT_AUTHORIZED. - Throw
PublicMcpErrorfor failures the model can act on. Anything else becomesInternal FrontMCP errorin production.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 3
Register the job
count-open-tickets is written, but execute_job can't find it, and it doesn't log anything yet. Register it, and make it log how many open tickets it counted, like Counted 2 open tickets for Acme.
import { App, FrontMcp, Job, JobContext, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./store";
@Job({
name: "count-open-tickets",
description: "Count a customer's open tickets.",
inputSchema: { customer: z.string().describe("Customer name, like Acme") },
outputSchema: { customer: z.string(), open: z.number() },
})
class CountOpenTickets extends JobContext {
async execute({ customer }: { customer: string }) {
const open = tickets.filter((t) => t.customer === customer && t.status === "open").length;
return { customer, open };
}
}
@Tool({ name: "search_tickets", description: "Search tickets by title", inputSchema: { query: z.string() } })
class SearchTickets extends ToolContext {
async execute({ query }: { query: string }) {
return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets] })
class HelpDeskApp {}
@FrontMcp({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.