Farm.js

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
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:

FieldPurpose
sendWelcomeEmail object keyCreates the api.jobs.sendWelcomeEmail caller namespace.
idSelects the provider-side task or event name. Defaults to the kebab-cased object key.
run inputInfers the typed trigger payload.
run return valueInfers 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:

RuntimeProvider-side identity for the example
Trigger.devTask ID send-welcome-email
InngestEvent name farm/send-welcome-email

Inngest's farm prefix can be changed with eventNamePrefix.

Mount a runtime

src/lib/integrations.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

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.

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:

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:

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:

// 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 sendsConsumer before $inputConsumer on this version
{ input, options }WorksWorks, logs a development warning
Inline object, no reserved keysWorksWorks
Inline object with its own input fieldUnwrapped as the envelopeUnwrapped as the envelope, so use $input
{ $input, $options }Delivered as a payload fieldWorks

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

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

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:

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.

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 instead. Farm tags are also unsupported by Inngest event sends. Check task.capabilities or use the typed guard before exposing provider-dependent controls:

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 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

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

CapabilityTrigger.devInngest
Trigger one runYesYes
Batch triggerYesYes
Status pollingYesYes
Idempotency keyYesYes, sent as the event ID
Delay and one-off scheduleYesYes, via the event ts
Debounce and tagsYesNo
Queue and retry defaultsYesNo
TTL and concurrency keyYesNo
CancelYesNo
Cron task.schedule registrationMetadata onlyMetadata only

Generated routes

For the sendWelcomeEmail key and default basePath, Farm mounts:

MethodRoute
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:

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.