---
title: "Jobs Integration"
description: "Define typed job contracts and call existing Trigger.dev tasks or Inngest functions through one Farm API."
canonical_url: "https://mcp.farmjs.dev/docs/integrations/jobs"
markdown_url: "https://mcp.farmjs.dev/docs/integrations/jobs.md"
last_updated: "2026-10-20"
x_farming_labs_generated_preamble: true
---

# Jobs Integration
URL: /docs/integrations/jobs
LLM index: /llms.txt
Description: Define typed job contracts and call existing Trigger.dev tasks or Inngest functions through one Farm API.

# Jobs Integration

The Jobs integration gives a Farm app typed trigger, batch, status, scheduling, cancellation, and metadata routes over Trigger.dev or Inngest.

It is currently a control-plane adapter. Farm sends work to a task or function that already exists in the selected provider and reads provider status back.

## Define the task contract

**src/lib/jobs.ts**

```ts
import { defineTasks, task } from "@farm.js/jobs";

export const tasks = defineTasks({
  sendWelcomeEmail: task({
    id: "send-welcome-email",
    description: "Send the first email after signup.",
    async run(input: { userId: string }) {
      return {
        messageId: `msg_${input.userId}`,
      };
    },
  }),
});
```

The object key, `id`, and `run` return type have different jobs:

| Field                         | Purpose                                                                               |
| ----------------------------- | ------------------------------------------------------------------------------------- |
| `sendWelcomeEmail` object key | Creates the `api.jobs.sendWelcomeEmail` caller namespace.                             |
| `id`                          | Selects the provider-side task or event name. Defaults to the kebab-cased object key. |
| `run` input                   | Infers the typed trigger payload.                                                     |
| `run` return value            | Infers the typed `status().data.output` value.                                        |

**Important:** the current adapter does not execute or deploy the local `run` callback. Create matching provider-side code and keep its input/output contract synchronized with this definition.

Provider identity is resolved as follows:

| Runtime     | Provider-side identity for the example |
| ----------- | -------------------------------------- |
| Trigger.dev | Task ID `send-welcome-email`           |
| Inngest     | Event name `farm/send-welcome-email`   |

Inngest's `farm` prefix can be changed with `eventNamePrefix`.

## Mount a runtime

**src/lib/integrations.ts**

```ts
import { jobs, trigger } from "@farm.js/jobs";
import { tasks } from "./jobs";

export const appIntegrations = {
  jobs: jobs({
    runtime: trigger({
      apiKey: process.env.TRIGGER_SECRET_KEY,
      projectRef: process.env.TRIGGER_PROJECT_REF,
    }),
    tasks,
  }),
} as const;

export type AppIntegrations = typeof appIntegrations;
```

Swap `trigger(...)` for `inngest(...)` to keep the same caller namespace with the Inngest runtime. Runtime capabilities are not identical, so check the matrix below before sharing task defaults.

The runtime definition is the jobs integration's injection boundary. Farm does not construct a
Trigger.dev or Inngest SDK instance in the application process; it calls the configured external
runtime over HTTP.

This means jobs have one clear ownership split: the application passes provider credentials and
endpoint options to `trigger(...)` or `inngest(...)`, Farm constructs the HTTP runtime adapter, and
the provider continues to own deployed tasks or functions. There is no in-process SDK `instance` to
inject.

## Create callers

```ts
import { createIntegrations } from "@farm.js/core/client";
import type { AppIntegrations } from "./integrations";

export const { api, apiClient } = createIntegrations<AppIntegrations>();
```

Use `api` in trusted server code. Only expose `apiClient` to the browser when the application has added its own authorization around job routes.

## Trigger one run

Task input is placed directly in `body`. Farm-specific launch options live under `$options`.

```ts
const queued = await api.jobs.sendWelcomeEmail.trigger({
  body: {
    userId: "usr_123",
    $options: {
      idempotencyKey: "welcome:usr_123",
      tags: ["signup"],
    },
  },
});

const handleId = queued.data!.handleId;
```

When a task takes a scalar input rather than an object, wrap it in `$value` so it stays distinguishable from inline payload fields:

```ts
const queued = await api.jobs.countTokens.trigger({
  body: {
    $value: 42,
    $options: { tags: ["billing"] },
  },
});
```

Reserved keys are `$`-prefixed (`$value`, `$input`, `$options`, `$schedule`), so an object input is free to use ordinary field names such as `value` or `input` without colliding with the envelope. `$value` is read as the scalar wrapper only when it is the sole payload key; an object that carries other fields is delivered whole. An empty object body delivers `{}`, and a task whose input is an array can send the array as the whole body (or as a batch item).

The older `{ input, options }` body is still accepted but is **deprecated**. The inline shape above,
with `$input` for collision-prone object inputs, is the canonical API.

If the task input itself has an `input` field, use the explicit `$input` envelope so Farm does not
confuse the payload with that legacy trigger format:

```ts
await api.jobs.storeCount.trigger({
  body: {
    $input: { input: 5 },
  },
});
```

The same explicit form works for `schedule` (alongside `$schedule`) and for each item in a batch.

### The deprecated `{ input, options }` envelope

The legacy envelope still works, and will keep working for the whole `0.x` line, but it cannot be
made unambiguous. A body whose only keys are `input` and `options` is read as the envelope, so an
object input that carries its own `input` field is unwrapped into that field's value instead of being
delivered whole:

```ts
// Deprecated. Read as the envelope, so the task receives "digest", not { input: "digest" }.
await api.jobs.sendDigest.trigger({ body: { input: "digest" } });

// Canonical. The $ prefix is reserved, so this cannot collide with payload data.
await api.jobs.sendDigest.trigger({ body: { $input: { input: "digest" } } });
```

A development build logs a one-time warning per operation when a request arrives in the legacy shape.
The warning names the shape and the replacement and never includes any part of the body, because a job
payload can carry user data. Production builds stay silent.

`trigger`, `schedule`, and every item in a `batchTrigger` follow the same policy.

### Migrating during a rolling deploy

`$input` is understood only by consumers running this version or later. An older consumer does not
treat `$input` as reserved, so it delivers `{ $input: ... }` to the task as an ordinary payload field
and the task sees the wrapper instead of its input.

**Update every consumer before any producer starts sending `$input`.**

| Producer sends                           | Consumer before `$input`         | Consumer on this version                   |
| ---------------------------------------- | -------------------------------- | ------------------------------------------ |
| `{ input, options }`                     | Works                            | Works, logs a development warning          |
| Inline object, no reserved keys          | Works                            | Works                                      |
| Inline object with its own `input` field | Unwrapped as the envelope        | Unwrapped as the envelope, so use `$input` |
| `{ $input, $options }`                   | **Delivered as a payload field** | Works                                      |

The third row is the reason for the deprecation: it is wrong on both sides of the deploy, and `$input`
is the only form that fixes it. The fourth row is the reason for the ordering rule.

Removal is not scheduled within `0.x`. The earliest candidate is the first release after `1.0`, which
will be announced before it lands.

## Batch trigger

```ts
const batch = await api.jobs.sendWelcomeEmail.batchTrigger({
  body: {
    items: [
      {
        userId: "usr_123",
        $options: {
          idempotencyKey: "welcome:usr_123",
        },
      },
      {
        userId: "usr_456",
        $options: {
          idempotencyKey: "welcome:usr_456",
        },
      },
    ],
  },
});
```

Each result contains an index, provider handle ID, and queue timestamp. Trigger.dev can also return a provider batch ID; Inngest returns `batchId: null`.

## Read status

```ts
const result = await api.jobs.sendWelcomeEmail.status({
  query: {
    handleId,
  },
});

if (result.data?.status === "completed") {
  console.log(result.data.output?.messageId);
}
```

Farm normalizes provider states to `queued`, `delayed`, `running`, `waiting`, `completed`, `failed`, `canceled`, `expired`, or `unknown`. The raw provider response remains available as `data.raw`.

## Schedule and cancel

One-off scheduling requires exactly one of `at` or `after`:

```ts
await api.jobs.sendWelcomeEmail.schedule({
  body: {
    userId: "usr_123",
    $schedule: {
      after: "10m",
      idempotencyKey: "welcome:usr_123",
      tags: ["scheduled"],
    },
  },
});
```

`$options` launch options (`idempotencyKey`, `tags`, `debounce`) also apply to a schedule. Tags from both are combined, and a value set on `$schedule` wins over the same `$options` field. `$schedule` alone sets the timing.

```ts
await api.jobs.sendWelcomeEmail.cancel({
  body: {
    handleId,
  },
});
```

These callers exist in the shared API for both runtimes. Trigger.dev supports one-off scheduling and cancellation. Inngest supports one-off scheduling by mapping the requested time to the event `ts`, but cancellation remains unsupported and returns `501`.

For Inngest, `at` must be an ISO timestamp with an explicit `Z` or UTC offset. `after` accepts a positive number of milliseconds or a duration ending in `ms`, `s`, `m`, `h`, or `d`. The same formats apply to `$options.delay`: an ISO timestamp is absolute and a number or duration is relative. Farm rejects invalid or past absolute times before contacting Inngest.

Inngest uses `idempotencyKey` as the event `id`. Per-call debounce cannot be represented by an event send; configure [debounce on the Inngest function](https://www.inngest.com/docs/guides/debounce) instead. Farm tags are also unsupported by Inngest event sends. Check `task.capabilities` or use the typed guard before exposing provider-dependent controls:

```ts
import { supportsJobsRuntimeCapability } from "@farm.js/jobs";

if (supportsJobsRuntimeCapability(task.capabilities, "schedule")) {
  // Show or call provider-backed scheduling controls.
}
```

Inngest documents future event timestamps as scheduling new matching function runs. A function already waiting for that event resumes immediately rather than waiting for the future timestamp. See the [event send reference](https://www.inngest.com/docs/reference/typescript/v3/events/send) for that provider behavior.

The `schedule` field on `task({...})`, including cron and timezone, is exposed as metadata only. It does not create a provider schedule.

## Task defaults

```ts
task({
  defaults: {
    queue: {
      name: "email",
      concurrencyLimit: 2,
    },
    retry: {
      attempts: 3,
    },
    ttl: "10m",
    tags: ["email"],
    concurrencyKey(input: { userId: string }) {
      return `user:${input.userId}`;
    },
    idempotencyKey(input: { userId: string }) {
      return `welcome:${input.userId}`;
    },
  },
  async run(input: { userId: string }) {
    return { messageId: input.userId };
  },
});
```

Trigger.dev maps all of these defaults into launch options. Inngest accepts `idempotencyKey`; one-off timing is supplied per call through `schedule` or `$options.delay`. Defining Trigger-only task defaults causes integration setup to fail.

## Runtime capabilities

| Capability                        | Trigger.dev   | Inngest                   |
| --------------------------------- | ------------- | ------------------------- |
| Trigger one run                   | Yes           | Yes                       |
| Batch trigger                     | Yes           | Yes                       |
| Status polling                    | Yes           | Yes                       |
| Idempotency key                   | Yes           | Yes, sent as the event ID |
| Delay and one-off schedule        | Yes           | Yes, via the event `ts`   |
| Debounce and tags                 | Yes           | No                        |
| Queue and retry defaults          | Yes           | No                        |
| TTL and concurrency key           | Yes           | No                        |
| Cancel                            | Yes           | No                        |
| Cron `task.schedule` registration | Metadata only | Metadata only             |

## Generated routes

For the `sendWelcomeEmail` key and default `basePath`, Farm mounts:

| Method | Route                                              |
| ------ | -------------------------------------------------- |
| `GET`  | `/api/jobs/tasks`                                  |
| `POST` | `/api/jobs/send-welcome-email/trigger`             |
| `POST` | `/api/jobs/send-welcome-email/batch-trigger`       |
| `POST` | `/api/jobs/send-welcome-email/schedule`            |
| `GET`  | `/api/jobs/send-welcome-email/status?handleId=...` |
| `POST` | `/api/jobs/send-welcome-email/cancel`              |

Read task IDs, provider IDs, paths, defaults, configuration state, and runtime capabilities with:

```ts
const metadata = await api.jobs.tasks.list();
```

Change the route prefix with `jobs({ basePath: "/api/automation", ... })`.

## Production checklist

- Implement and deploy the matching provider task or event function.
- Keep object keys and provider IDs stable.
- Restrict browser access to trigger, status, and cancel routes.
- Use idempotency keys for user-retriable actions.
- Persist handle IDs when a later request needs status or cancellation.
- Verify each option against the selected runtime capability matrix.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
