# @Duty

> Run periodic provider work in one process on a machine or in a workspace, with a lease another process can take over.

Source: https://frontmcp.dev/reference/sdk/duty

[Checked against FrontMCP 1.11.0](https://frontmcp.dev/versions#how-pages-are-checked) on November 4, 2026

A timer in a provider runs in every copy of a server. `@Duty` gives periodic work a lease, so one process on a machine or in a workspace runs it. Use it for refreshing an index or clearing old files; use a [job](https://frontmcp.dev/reference/sdk/job) for work a client starts and follows by run id.

```ts
@Provider()
@Duty({ name?, every, scope, leaseMs? })
class RefreshIndex {
  async run({ signal }: DutyRunContext) { /* refresh while the lease is held */ }
}
```



---

<a id="reference"></a>

## Reference

<a id="dutyoptions"></a>

### `@Duty(options)`

Decorate a provider class with a `run(context)` method and register it in `providers`.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | class name | Lease name within the server. Set it explicitly when a build renames classes. |
| `every` | `number \| string` | required | Positive whole milliseconds or a duration such as `"30s"`, `"5m"`, `"1h"` or `"1d"`. At most 2147483647 ms. |
| `scope` | `"machine" \| "workspace"` | required | One holder per machine or per project root (nearest `package.json`), for this server name. |
| `leaseMs` | `number` | three intervals, clamped to 3000–60000 ms | Lease lifetime; renewed every third of it. Minimum 1000 ms. |

`run({ signal })` returns `void` or a promise. The signal aborts on lease loss or shutdown. A still-running invocation makes the next interval skip; an exception is logged. Another process can take over after the holder stops renewing its lease.

`dutyMetadataOf(instance)` returns its class's `ResolvedDutyMetadata` (`name`, `everyMs`, `scope`, `leaseMs`), or `undefined` when it has none. `dutyMetadataSchema` validates the decorator options and converts `every` to milliseconds.

<a id="caveats"></a>

#### Caveats

Leases live in owner-only `~/.frontmcp/duties.sqlite`, or under `FRONTMCP_HOME`. They coordinate processes on one machine, not several machines. Duties need a long-lived Node process; serverless and edge deployments log a warning and do not run them. Side effects should tolerate an interrupted holder: a process that ignores an aborted signal can continue working after losing its lease.

[`createContainer()`](https://frontmcp.dev/reference/sdk/provider#createcontaineroptions) does not run duties unless `runDuties: true` and `info.name` are set.

<a id="usage"></a>

## Usage

<a id="refreshing-an-index-periodically"></a>

### Refreshing an index periodically

> **Note**
>
> The Playground cannot share a local SQLite lease between Node processes. This registration example belongs in a long-lived Node server.

```ts refresh-index.duty.ts
import { Duty, DutyRunContext, Provider } from "@frontmcp/sdk";

@Provider()
@Duty({ name: "refresh-ticket-index", every: "5m", scope: "workspace" })
export class RefreshTicketIndex {
  async run({ signal }: DutyRunContext) {
    if (signal.aborted) return;
    // Refresh the index here, passing signal to cancellable work.
  }
}
```

Register `RefreshTicketIndex` in the app's `providers`. Every server copy registers it; the lease chooses the holder.

<a id="checking-what-a-duty-resolves-to"></a>

### Checking what a duty resolves to

`@Duty` checks its options when the class is decorated, so a mistake fails at import, before any lease is taken. `dutyMetadataOf()` shows what the decorator resolved: `every` in milliseconds, and the lease it defaults to. This tool reads it from two duties that aren't registered, so nothing runs:

Initial call configuration:

```js
{ tool: "describe_duties", arguments: {} }
```

```ts duties.ts
import { Duty, DutyRunContext, Provider } from "@frontmcp/sdk";

@Provider()
@Duty({ name: "refresh-ticket-index", every: "5m", scope: "workspace" })
export class RefreshTicketIndex {
  async run({ signal }: DutyRunContext) {
    if (signal.aborted) return;
  }
}

@Provider()
@Duty({ name: "flush-audit-log", every: "2s", scope: "machine" })
export class FlushAuditLog {
  async run() {}
}
```

```ts main.ts active
import { App, Tool, ToolContext, dutyMetadataOf } from "@frontmcp/sdk";
import { FlushAuditLog, RefreshTicketIndex } from "./duties";

@Tool({ name: "describe_duties", description: "Show how each duty's options resolve", inputSchema: {} })
class DescribeDuties extends ToolContext {
  async execute() {
    return { duties: [new RefreshTicketIndex(), new FlushAuditLog()].map((duty) => dutyMetadataOf(duty)) };
  }
}

@App({ id: "help-desk", name: "Help Desk", tools: [DescribeDuties] })
export class HelpDesk {}
```

```ts duties.test.ts
import { test, expect } from "@frontmcp/testing";
import { Duty, InvalidDecoratorMetadataError } from "@frontmcp/sdk";

test("every and the default lease resolve to milliseconds", async ({ mcp }) => {
  const { duties } = (await mcp.tools.call("describe_duties", {})).json();
  expect(duties).toEqual([
    { name: "refresh-ticket-index", everyMs: 300000, scope: "workspace", leaseMs: 60000 },
    { name: "flush-audit-log", everyMs: 2000, scope: "machine", leaseMs: 6000 },
  ]);
});

test("an interval FrontMCP can't read fails when the class is decorated", () => {
  const decorate = () => Duty({ every: "5 minutes", scope: "machine" })(class Sweep { async run() {} });
  expect(decorate).toThrow(InvalidDecoratorMetadataError);
  expect(decorate).toThrow(/every must be milliseconds or a duration/);
});

test("a duty needs a run() method", () => {
  expect(() => Duty({ every: "1h", scope: "machine" })(class Sweep {} as never)).toThrow(/has no run\(\) method/);
});
```

The lease defaults to three intervals, kept between 3000 and 60000 ms: 60000 for every five minutes, 6000 for every two seconds.

<a id="troubleshooting"></a>

## Troubleshooting

<a id="duty-invalid-metadata-for-every"></a>

### `@Duty invalid metadata for "every"`

Use positive whole milliseconds or a duration such as `"30s"`. Intervals must fit within 2147483647 ms. The class needs a `run()` method; unknown options and an invalid `scope` or `leaseMs` also fail decoration with `InvalidDecoratorMetadataError`.

<a id="duty--of-server--is-declared-by-two-classes-in-this-process"></a>

### `Duty "…" of server "…" is declared by two classes in this process`

Give one class its own `name`. `DutyNameConflictError` prevents two implementations from sharing a lease accidentally.

<a id="refusing-to-keep-duty-leases-under-"></a>

### `Refusing to keep duty leases under …`

`UnsafeDutyStoreError` refuses unsafe ownership or permissions. Set `FRONTMCP_HOME` to a folder only the server's user can write.
