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

# Flow Control

> Shape how a workflow runs under load: concurrency, throttle, rate limit, debounce, batch, priority, singleton, idempotency, plus AI caps and throttles.

Flow control shapes *how* a workflow runs under load. Each control is an optional, flat field on the
workflow definition, alongside `retry`. A workflow with no flow config runs unshaped - every control is
opt-in and off by default.

```ts theme={null}
workflow({
  name: "sync.account",
  concurrency: { limit: 5, key: "accountId" },
  handler: async (ctx) => {
    await ctx.step.run("sync", () => syncAccount(ctx.event.data.accountId));
  },
});
```

## At a glance

| Control         | Purpose                            | Acts at                       | On overflow     |
| --------------- | ---------------------------------- | ----------------------------- | --------------- |
| `concurrency`   | Cap simultaneous runs              | execution slot                | wait + retry    |
| `throttle`      | Cap start rate, smoothly           | admission                     | delay           |
| `rateLimit`     | Cap start rate, shedding           | admission                     | drop            |
| `debounce`      | Collapse a burst to its last event | admission                     | coalesce        |
| `batch`         | Fold many events into one run      | admission                     | collect         |
| `priority`      | Jump the shared queue              | admission                     | re-order        |
| `singleton`     | One run at a time per key          | admission                     | skip or cancel  |
| `idempotency`   | One run per key within a window    | admission                     | drop (deduped)  |
| `cap`           | Ceiling on a run's AI spend        | each `step.ai` call           | halt + fail run |
| `tokenThrottle` | Cap a scope's AI token rate        | admission (debited post-step) | delay the start |

## Keys

Most controls take an optional `key`: a dotted path into the event data that scopes the control to a
value. `key: "accountId"` gives each account its own independent limit; `key: "user.id"` reads a nested
field. Keys are field paths, not expressions. An omitted key - or a missing / non-scalar field - scopes
the control to the whole workflow.

## Concurrency

Caps how many runs execute **at once** in a scope. A slot is held only while a run is actively
executing, so a run that is sleeping or awaiting an event releases its slot and does not consume one. The
count is taken across the shared database, so the limit is **global**, not per-process. Over-limit runs are
not dropped - they wait and retry as slots free, preserving order.

```ts theme={null}
concurrency: { limit: 5, key: "accountId" }
```

| Property | Type     | Default        | Description                                            |
| -------- | -------- | -------------- | ------------------------------------------------------ |
| `limit`  | `number` | required       | Max runs executing simultaneously in the scope.        |
| `key`    | `string` | whole workflow | Event-data path; each value gets an independent limit. |

### Project-wide ceiling

Above the per-workflow `concurrency` you set in code, each project has a **concurrency ceiling** that
caps how many runs execute at once across the *whole* project, regardless of workflow or key. It comes
from your plan, not from a workflow field (`0` = unlimited). A run must clear both its per-workflow
limit and the project ceiling to start; whichever is tighter applies, and an over-ceiling run waits and
retries exactly like a per-workflow over-limit run.

```sh theme={null}
curl "$DURATON_URL/flow-state" | jq .concurrency
# { "limit": 50, "inUse": 12 }
```

## Throttle

Bounds how often runs **start**, smoothing bursts by spreading overflow into the future - one start
every `perMs / limit`. No run is lost; excess runs begin later.

```ts theme={null}
throttle: { limit: 100, perMs: 60_000, key: "customer" }
```

| Property | Type          | Default        | Description                                    |
| -------- | ------------- | -------------- | ---------------------------------------------- |
| `limit`  | `number`      | required       | Max starts per window.                         |
| `perMs`  | `number` (ms) | required       | Window length.                                 |
| `key`    | `string`      | whole workflow | Event-data path; each value gets its own rate. |

## Rate limit

Same window as throttle, opposite action: instead of delaying overflow it **drops** it. Up to `limit`
runs start per `perMs`; the rest are shed and the event response reports `dropped: true`. Use it for
abuse protection where shedding beats queueing.

```ts theme={null}
rateLimit: { limit: 1000, perMs: 60_000, key: "ip" }
```

| Property | Type          | Default        | Description                                    |
| -------- | ------------- | -------------- | ---------------------------------------------- |
| `limit`  | `number`      | required       | Max starts admitted per window.                |
| `perMs`  | `number` (ms) | required       | Window length.                                 |
| `key`    | `string`      | whole workflow | Event-data path; each value gets its own rate. |

<Note>
  Throttle and rate limit share one rate primitive (GCRA). Throttle delays the overflow; rate limit
  drops it.
</Note>

## Debounce

Coalesces a burst of events into a single run that fires after `periodMs` of quiet. Each new event
slides the deadline forward and replaces the payload, so only the **last** event in a quiet-bounded
burst runs.

```ts theme={null}
debounce: { periodMs: 5_000, key: "documentId" }
```

| Property   | Type          | Default        | Description                                          |
| ---------- | ------------- | -------------- | ---------------------------------------------------- |
| `periodMs` | `number` (ms) | required       | Quiet gap after the last event before the run fires. |
| `key`      | `string`      | whole workflow | Event-data path; each value debounces independently. |

## Batch

Collects events into **one** run, flushing when the buffer hits `maxSize` **or** `timeoutMs` elapses,
whichever comes first. The run receives the events as `ctx.events`; `ctx.event` is the first of them.

```ts theme={null}
workflow({
  name: "index.documents",
  batch: { maxSize: 100, timeoutMs: 5_000, key: "index" },
  handler: async (ctx) => {
    for (const e of ctx.events ?? []) {
      await ctx.step.run(e.data.id, () => index(e.data));
    }
  },
});
```

| Property    | Type          | Default        | Description                                     |
| ----------- | ------------- | -------------- | ----------------------------------------------- |
| `maxSize`   | `number`      | required       | Flush once this many events are buffered.       |
| `timeoutMs` | `number` (ms) | required       | Flush this long after the first buffered event. |
| `key`       | `string`      | whole workflow | Event-data path; each value batches separately. |

## Priority

Shifts a workflow's runs **earlier** in the shared queue by `shiftMs`, so they dequeue ahead of other
workflows competing for the same slots.

```ts theme={null}
priority: { shiftMs: 60_000 }
```

| Property  | Type          | Default  | Description                                     |
| --------- | ------------- | -------- | ----------------------------------------------- |
| `shiftMs` | `number` (ms) | required | Treat runs as if enqueued this many ms earlier. |

## Singleton

Allows at most **one** non-terminal run per key.

```ts theme={null}
singleton: { key: "accountId", mode: "skip" }
```

| Property | Type                   | Default        | Description                                                                                                                             |
| -------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `key`    | `string`               | whole workflow | One concurrent run per value.                                                                                                           |
| `mode`   | `"skip"` \| `"cancel"` | `"cancel"`     | On collision: `skip` drops the new trigger (response reports `skipped: true`); `cancel` cancels the running run and starts the new one. |

## Idempotency

Suppresses a **second run of this workflow** for the same derived key within a time window. The first
matching event starts a run; a later event whose key resolves to the same value inside the window is
dropped for this workflow (the response reports `deduped: true` with no `runId`). The event is still
recorded and still wakes `waitForEvent` waiters - only the duplicate **run** is suppressed.

```ts theme={null}
idempotency: { key: "orderId", periodMs: 86_400_000 } // at most one run per orderId per 24h
```

| Property   | Type          | Default            | Description                                                                                                           |
| ---------- | ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `key`      | `string`      | whole workflow     | Event-data path; one run per value within the window. An omitted key means one run per window for the whole workflow. |
| `periodMs` | `number` (ms) | `86_400_000` (24h) | How long a key stays claimed before it can run again.                                                                 |

The `key` is a dotted field path into the event data, resolved the same way as [every other
control](#keys). A structurally malformed path - empty segments, or a leading or trailing dot - is
rejected when the workflow is registered.

<Warning>
  When the path names a field the event does not carry, or the value at it is not a scalar (string,
  number, or boolean), the key resolves to the shared workflow-wide window - so a mistyped path silently
  stops per-key deduplication and folds distinct events into a single window.
</Warning>

To verify a per-key path resolves, send two events with **distinct** payloads and confirm two runs
start. Sending the same payload twice is deduped whether or not the path resolves, so it proves nothing.

This is run-level dedupe keyed off the event payload. To dedupe a whole event regardless of which
workflows it matches - the usual safety net for an at-least-once caller retrying `POST /events` - send a
`dedupeId` on the event instead (see [Events](/reference/api/events)); a repeat of
that id within 24h is dropped before any fan-out.

## AI spend controls

Two more controls are declared the same way but meter **AI spend** rather than run starts. They are
documented with the rest of the spend tooling in [Cost controls](/ai/cost-controls); the table is the
short version.

| Control         | Bounds                                        | When crossed                                                                              | Resumes                   |
| --------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------- |
| `cap`           | One run's AI spend (`maxCost`, `maxTokens`)   | Halts **before** the `step.ai` call that would cross it; the run fails with `BudgetError` | Replay under a raised cap |
| `tokenThrottle` | Token **rate** per key (`tokens` per `perMs`) | Delays the key's next run starts; nothing fails or pauses                                 | When the rate recovers    |

## Observing flow control

```sh theme={null}
curl "$DURATON_URL/flow-state?workflow=index.documents"
```

```json theme={null}
{
  "debounce": [{ "app": "docs", "workflow": "index.documents", "pending": 3, "nextFireAt": "2026-07-01T10:00:05Z" }],
  "batch": [{ "app": "docs", "workflow": "index.documents", "buffered": 42, "oldestAt": "2026-07-01T10:00:01Z" }],
  "concurrency": { "limit": 50, "inUse": 12 }
}
```

| Surface           | What it carries                                                                                                                                                                                                     |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /flow-state` | The live debounce and batch backlogs per workflow (`?app=` and `?workflow=` narrow the scope), plus the project's concurrency ceiling and current draw under `concurrency` (`limit`, `0` = unlimited, and `inUse`). |
| `GET /workflows`  | Each workflow's configured controls, on its `flowControl` field.                                                                                                                                                    |
| `GET /runs/stats` | In-flight and queued counts (`active`, `queued`, `running`).                                                                                                                                                        |

The console renders the same three reads in the Workflows list and on Overview.
