> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Jobs

> Background tasks with @Job decorator, queues, and scheduled execution

Jobs are **typed, executable units of work** with strict input/output schemas, automatic retries, timeouts, permission checks, and background execution support. They are designed for operations that need reliability guarantees beyond what a simple tool call provides.

<Info>
  Jobs extend the FrontMCP execution model with persistent state tracking, retry logic, and DAG-based composition via [Workflows](/frontmcp/servers/workflows).
</Info>

<Tip>
  **Nx users:** Scaffold with `nx g @frontmcp/nx:job my-job --project my-app`. See [Job Generator](/frontmcp/nx-plugin/generators/job).
</Tip>

## Why Jobs?

Jobs fill the gap between lightweight tool calls and full workflow orchestration:

| Aspect             | Tool                    | Job                                            | Workflow                       |
| ------------------ | ----------------------- | ---------------------------------------------- | ------------------------------ |
| **Purpose**        | Execute a single action | Execute a reliable unit of work                | Orchestrate multiple jobs      |
| **Retries**        | None                    | Automatic with exponential backoff             | Per-step retry overrides       |
| **Background**     | No                      | Yes (with `runId` polling)                     | Yes (with `runId` polling)     |
| **State tracking** | None                    | `pending` / `running` / `completed` / `failed` | Per-step state tracking        |
| **Timeout**        | None                    | Configurable (default: 5 min)                  | Configurable (default: 10 min) |
| **Permissions**    | Auth providers          | RBAC with roles, scopes, custom guards         | Inherits from job permissions  |

Jobs are ideal for:

* **Data processing** — ETL pipelines, file parsing, batch operations
* **External integrations** — API calls that may fail and need retries
* **Long-running operations** — background tasks with progress reporting
* **Auditable actions** — operations that need execution logs and state tracking

***

## Creating Jobs

### Class Style

Use class decorators for jobs that need dependency injection, lifecycle hooks, or complex logic:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { Job, JobContext } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

@Job({
  name: 'analyze-text',
  description: 'Analyze text and return sentiment and key phrases',
  inputSchema: {
    text: z.string().describe('Text to analyze'),
    language: z.string().default('en').describe('Language code'),
  },
  outputSchema: {
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    keyPhrases: z.array(z.string()),
    confidence: z.number(),
  },
})
class AnalyzeTextJob extends JobContext {
  async execute(input: { text: string; language: string }) {
    this.log('Starting text analysis');
    const nlp = this.get(NlpServiceToken);

    const result = await nlp.analyze(input.text, input.language);

    this.log(`Analysis complete: ${result.sentiment}`);
    return {
      sentiment: result.sentiment,
      keyPhrases: result.keyPhrases,
      confidence: result.confidence,
    };
  }
}
```

### Function Style

For simpler jobs, use the functional builder:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { job } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

const GreetJob = job({
  name: 'greet',
  description: 'Generate a personalized greeting',
  inputSchema: {
    name: z.string(),
    formal: z.boolean().default(false),
  },
  outputSchema: {
    message: z.string(),
  },
})((input, ctx) => {
  ctx.log(`Generating greeting for ${input.name}`);
  const prefix = input.formal ? 'Dear' : 'Hello';
  return { message: `${prefix} ${input.name}!` };
});
```

***

## Registering Jobs

Add jobs to your app via the `jobs` array:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { App } from '@frontmcp/sdk';

@App({
  id: 'text-processing',
  name: 'Text Processing',
  jobs: [AnalyzeTextJob, GreetJob],
})
class TextProcessingApp {}
```

### Loading from npm or Remote Servers

Mix local jobs with those loaded from npm or proxied from remote servers:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { App, Job } from '@frontmcp/sdk';

@App({
  id: 'text-processing',
  name: 'Text Processing',
  jobs: [
    AnalyzeTextJob,                                                 // Local class
    Job.esm('@acme/jobs@^1.0.0', 'cleanup'),                       // Single job from npm
    Job.remote('https://api.example.com/mcp', 'sync-data'),        // Single job from remote
  ],
})
class TextProcessingApp {}
```

<Info>
  `Job.esm()` and `Job.remote()` load individual jobs. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
</Info>

To enable the jobs system on your server, configure the top-level `jobs` option:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { FrontMcp } from '@frontmcp/sdk';

@FrontMcp({
  info: { name: 'My Server', version: '1.0.0' },
  apps: [TextProcessingApp],
  jobs: {
    enabled: true,
    store: {
      redis: { provider: 'redis', host: 'localhost', port: 6379 },
      keyPrefix: 'mcp:jobs:',
    },
  },
})
export default class MyServer {}
```

<Tip>
  **Auto-enabled by `@App({ jobs })`** (issue #408). Declaring any `@App({ jobs: [...] })` (or `workflows: [...]`) brings the jobs subsystem up with in-memory stores by default — no `@FrontMcp({ jobs: { enabled: true } })` required. The SDK registers four MCP tools for job management: `list_jobs`, `execute_job`, `get_job_status`, and `remove_job` (plus parallel `*_workflow` tools). `register_job` / `register_workflow` are added only when `jobs.allowDynamicRegistration` is `true`. Hyphen aliases (`list-jobs`, `execute-job`, …) keep working with a deprecation log line for one release. Set `@FrontMcp({ jobs })` explicitly to configure persistent storage or to opt out via `jobs: { enabled: false }`.
</Tip>

***

## Input & Output Schemas

Jobs require both input and output schemas using Zod:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Job({
  name: 'process-order',
  inputSchema: {
    orderId: z.string().describe('Order ID'),
    items: z.array(z.object({
      productId: z.string(),
      quantity: z.number().min(1),
    })),
    priority: z.enum(['low', 'normal', 'high']).default('normal'),
  },
  outputSchema: {
    orderId: z.string(),
    status: z.enum(['processed', 'failed']),
    totalAmount: z.number(),
    processedAt: z.string(),
  },
})
```

***

## Configuration

| Field               | Type                     | Default  | Description                                    |
| ------------------- | ------------------------ | -------- | ---------------------------------------------- |
| `name`              | `string`                 | —        | **Required.** Unique job identifier            |
| `description`       | `string`                 | —        | Human-readable description                     |
| `inputSchema`       | `ZodShape`               | —        | **Required.** Zod schema for input validation  |
| `outputSchema`      | `ZodShape`               | —        | **Required.** Zod schema for output validation |
| `id`                | `string`                 | `name`   | Stable identifier for tracking                 |
| `timeout`           | `number`                 | `300000` | Maximum execution time in ms (5 min)           |
| `retry`             | `JobRetryConfig`         | —        | Retry configuration (see below)                |
| `tags`              | `string[]`               | —        | Categorization tags                            |
| `labels`            | `Record<string, string>` | —        | Fine-grained key-value labels                  |
| `hideFromDiscovery` | `boolean`                | `false`  | Hide from `list_jobs`                          |
| `permissions`       | `JobPermission[]`        | —        | RBAC permission rules                          |

***

## Retry Configuration

Jobs support automatic retries with exponential backoff:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Job({
  name: 'fetch-external-data',
  inputSchema: { url: z.string().url() },
  outputSchema: { data: z.unknown() },
  retry: {
    maxAttempts: 5,
    backoffMs: 2000,
    backoffMultiplier: 2,
    maxBackoffMs: 30000,
  },
})
class FetchDataJob extends JobContext {
  async execute(input: { url: string }) {
    this.log(`Attempt ${this.attempt}: Fetching ${input.url}`);
    const response = await this.fetch(input.url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return { data: await response.json() };
  }
}
```

| Field               | Type     | Default | Description                    |
| ------------------- | -------- | ------- | ------------------------------ |
| `maxAttempts`       | `number` | `3`     | Maximum retry attempts         |
| `backoffMs`         | `number` | `1000`  | Initial backoff delay in ms    |
| `backoffMultiplier` | `number` | `2`     | Backoff multiplier per attempt |
| `maxBackoffMs`      | `number` | `60000` | Maximum backoff delay in ms    |

The backoff schedule for defaults: 1s, 2s, 4s (capped at `maxBackoffMs`).

***

## Permissions

Jobs support RBAC-style permission checks:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Job({
  name: 'delete-user-data',
  inputSchema: { userId: z.string() },
  outputSchema: { deleted: z.boolean() },
  permissions: [
    { action: 'execute', roles: ['admin', 'data-officer'] },
    { action: 'execute', scopes: ['data:delete'] },
  ],
})
```

| Field    | Type                                                                | Description                                     |
| -------- | ------------------------------------------------------------------- | ----------------------------------------------- |
| `action` | `'create' \| 'read' \| 'update' \| 'delete' \| 'execute' \| 'list'` | Permission action type                          |
| `roles`  | `string[]`                                                          | Required roles (at least one must match)        |
| `scopes` | `string[]`                                                          | Required OAuth scopes (at least one must match) |
| `custom` | `(authInfo) => boolean \| Promise<boolean>`                         | Custom guard function                           |

When no permissions are defined, the job is accessible to all authenticated users.
Once a rule targets an action, **every** rule for that action must pass; within a
single rule, `roles` and `scopes` are any-of.

Rules are enforced in `JobExecutionManager`, the choke point every caller path
goes through, so background runs and workflow steps are covered too. `list_jobs`
hides a job the caller could not run, and a denial is indistinguishable from
"not found" so the response cannot be used to enumerate restricted job names.

Roles and scopes are read from the caller's verified token. When the server
declares `authorities.claimsMapping`, that mapping is authoritative; otherwise
the fallback chain is `user.roles` → the `roles` claim → the authorization's
scopes.

<Warning>
  **Security (GHSA-58v2-gpcc-jmqv, fixed in 1.7.2)** — before 1.7.2 `permissions`
  was validated and stored but never evaluated, so any caller who could reach
  `execute_job` could run every job regardless of its declared rules. Servers on
  1.7.1 or earlier should treat every job as reachable by every caller.
</Warning>

***

## Background Execution

Jobs can run in background mode, returning a `runId` for status polling:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
// Via the execute_job MCP tool
const result = await client.callTool('execute_job', {
  name: 'analyze-text',
  input: { text: 'Hello world', language: 'en' },
  background: true,
});
// result: { runId: 'run-abc-123', state: 'running' }

// Poll for status
const status = await client.callTool('get_job_status', {
  runId: 'run-abc-123',
});
// status: { runId: 'run-abc-123', state: 'completed', result: { ... }, logs: [...] }
```

### Via DirectClient

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
const { runId } = await client.executeJob('analyze-text', {
  text: 'Hello world',
}, { background: true });

// Poll for completion
const status = await client.getJobStatus(runId);
```

***

## Progress Reporting

Jobs can report progress and log messages during execution:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Job({
  name: 'batch-import',
  inputSchema: {
    records: z.array(z.record(z.string(), z.unknown())),
  },
  outputSchema: {
    imported: z.number(),
    failed: z.number(),
  },
})
class BatchImportJob extends JobContext {
  async execute(input: { records: Record<string, unknown>[] }) {
    const total = input.records.length;
    let imported = 0;
    let failed = 0;

    for (let i = 0; i < total; i++) {
      this.log(`Processing record ${i + 1}/${total}`);
      await this.progress(i + 1, total, `Importing record ${i + 1}`);

      try {
        await this.importRecord(input.records[i]);
        imported++;
      } catch {
        failed++;
      }
    }

    return { imported, failed };
  }
}
```

| Method                             | Signature                                                               | Description                          |
| ---------------------------------- | ----------------------------------------------------------------------- | ------------------------------------ |
| `this.log(message)`                | `log(message: string): void`                                            | Append a timestamped log entry       |
| `this.progress(pct, total?, msg?)` | `progress(pct: number, total?: number, msg?: string): Promise<boolean>` | Send progress notification to client |
| `this.getLogs()`                   | `getLogs(): readonly string[]`                                          | Retrieve all log entries             |
| `this.attempt`                     | `get attempt(): number`                                                 | Current retry attempt (1-based)      |

***

## Job Stores

Jobs use two stores for persistence:

### State Store

Tracks execution state (`JobRunRecord`): run ID, state, input, result, error, logs, timing.

### Definition Store

Persists dynamic job definitions registered at runtime via the `register_job` tool (opt-in — see [Dynamic registration](#dynamic-registration-is-opt-in)).

### Memory (Default)

Suitable for development. Data is lost on restart.

### Redis

For production, configure Redis storage:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  jobs: {
    enabled: true,
    store: {
      redis: { provider: 'redis', host: 'localhost', port: 6379 },
      keyPrefix: 'mcp:jobs:',
    },
  },
})
```

***

## MCP Tools

When jobs are enabled, the following MCP tools are automatically registered:

| Tool             | Description                                            |
| ---------------- | ------------------------------------------------------ |
| `list_jobs`      | List registered jobs with optional tag/label filtering |
| `execute_job`    | Execute a job (inline or background)                   |
| `get_job_status` | Get execution status by `runId`                        |
| `register_job`   | Register a dynamic job at runtime (opt-in, see below)  |
| `remove_job`     | Remove a dynamic job (`hideFromDiscovery: true`)       |

Hyphen aliases (`list-jobs`, `execute-job`, …) still resolve with a deprecation log line for one release — agents and code that hardcoded the old form keep working.

### Dynamic registration is opt-in

`register_job` and `register_workflow` take a **raw script string** and register it
as an executable job. Since 1.7.2 they are not registered at all unless you ask
for them:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  jobs: { enabled: true, allowDynamicRegistration: true },
})
```

<Warning>
  Enabling this lets any caller who can reach the tool list author and run code on
  the server. Leave it off unless an agent is genuinely meant to write jobs, and
  gate the surrounding surface with `permissions` when you do.
</Warning>

`get_job_status` and `get_workflow_status` return runs started by the calling
subject only — a run record carries the job's inputs and results, so a guessed
`runId` reads as "not found".

***

## Best Practices

**Do:**

* Define clear input and output schemas with `.describe()` on each field
* Use retries for operations that call external services
* Set appropriate timeouts based on expected execution time
* Use background mode for long-running operations
* Log meaningful progress messages for debugging

**Don't:**

* Use jobs for simple, synchronous operations (use tools instead)
* Set `maxAttempts` too high for non-idempotent operations
* Skip output schemas — they enable validation and type safety
* Forget to handle the retry `attempt` number in your logic

## Next Steps

<CardGroup cols={2}>
  <Card title="Workflows" icon="diagram-project" href="/frontmcp/servers/workflows">
    Compose jobs into multi-step pipelines
  </Card>

  <Card title="JobContext" icon="briefcase" href="/frontmcp/sdk-reference/contexts/job-context">
    Context class API reference
  </Card>

  <Card title="@Job" icon="at" href="/frontmcp/sdk-reference/decorators/job">
    Decorator reference
  </Card>

  <Card title="JobRegistry" icon="database" href="/frontmcp/sdk-reference/registries/job-registry">
    Registry API reference
  </Card>
</CardGroup>
