--- title: 'Concurrency limits' description: 'Declare a named limit with a capacity, take a slot of it from a workflow with acquireSlot, and give the slot back from the same workflow or another one with releaseSlot.' position: 5 --- # Concurrency limits > A **concurrency limit** caps how many pieces of one kind of work run at once, > across every workflow that takes part. You declare it once with a capacity. A > workflow takes a **slot** with `acquireSlot` before it starts the work, and > gives it back with `releaseSlot` when the work ends. The slot lives in the > box, not in the run, so the workflow that gives it back can be a different one. ## When you need it Use a limit when the cap belongs to a resource, not to one workflow. For example: - Your telephony provider lets you hold 20 calls at once, and three workflows place calls. - A partner API refuses more than 5 open sessions per account. - A long job starts in one workflow and ends in a webhook handled by another, and you must not start a new one until a running one ends. If you only need one run of a workflow at a time, use [serial execution](/docs/sdk/workflows/serial-execution) instead. It needs no code. ## Declare a limit Add `limits` to `stackbone.config.ts`. Each key is a limit name: lowercase letters, digits, `.`, `_` or `-`, at most 64 characters. ```ts // stackbone.config.ts import { defineWorkspace } from '@stackbone/sdk'; export default defineWorkspace({ limits: { 'voice-calls': { capacity: 20, maxHold: '45m' }, }, }); ``` | Field | Required | What it sets | | ---------- | -------- | ------------------------------------------------------------------------------------------------------ | | `capacity` | Yes | How many slots can be busy at once. A whole number of at least 1. | | `maxHold` | Yes | The longest one slot stays busy: `'45m'`, `'2h'`, `'90s'`, or milliseconds. There is no default value. | `maxHold` has no default on purpose. A run that crashes before it releases its slot would otherwise keep that slot busy for good. Set it a little above the longest time the real work takes. A config with only `limits` keeps the convention scan: the CLI still finds every workflow under `workflows/`. If you also list `workflows`, that list replaces the scan, as described in [serial execution](/docs/sdk/workflows/serial-execution#mark-a-workflow-serial). `defineWorkspace` checks each limit when the config loads, and an error names the limit and the field. `capacity` is at most 100000 and `maxHold` at most 30 days. In `stackbone dev`, a change to `limits` takes effect when you restart `stackbone dev`. ## Take a slot in one workflow, release it in another Import the helpers from `@stackbone/sdk/workflow`. In this example, workflow A takes a slot and starts a call. The call ends later, and workflow B receives that event and gives the slot back. ```ts // workflows/place-call.workflow.ts (workflow A) import { z } from 'zod'; import { acquireSlot, SlotTimeoutError } from '@stackbone/sdk/workflow'; export const inputSchema = z.object({ callId: z.string() }); export const outputSchema = z.object({ slotId: z.string().nullable() }); export async function placeCallWorkflow(input: { callId: string }) { 'use workflow'; try { const slot = await acquireSlot('voice-calls', { label: input.callId, timeout: '30m' }); await dialOut(input.callId, slot.id); // the provider reports back with slot.id return { slotId: slot.id }; } catch (error) { if (SlotTimeoutError.is(error)) return { slotId: null }; // no slot free in 30 minutes throw error; } } async function dialOut(callId: string, slotId: string) { 'use step'; // Start the call and pass slotId along, so the "call ended" event carries it. } ``` ```ts // workflows/call-ended.workflow.ts (workflow B) import { z } from 'zod'; import { releaseSlot } from '@stackbone/sdk/workflow'; export const inputSchema = z.object({ slotId: z.string() }); export const outputSchema = z.object({ released: z.boolean() }); export async function callEndedWorkflow(input: { slotId: string }) { 'use workflow'; return releaseSlot(input.slotId); } ``` Workflow A completes as soon as the call starts. Its slot stays busy until workflow B calls `releaseSlot`, or until `maxHold` passes. | Helper | Returns | Where you call it | | -------------------------------------------- | ----------------------- | --------------------------- | | `acquireSlot(limit, { timeout?, label? })` | `{ id, limiter }` | The workflow body only | | `releaseSlot(slotId)` | `{ released: boolean }` | The workflow body or a step | | `withLimit(limit, fn, { timeout?, label? })` | What `fn` returns | The workflow body only | `acquireSlot` and `withLimit` wait on a durable hook, so they belong in the `'use workflow'` function. Inside a `'use step'` they fail. `releaseSlot` is a step of its own. `label` is an id you choose (a call id, an order id). Studio shows it next to the slot. Do not put personal data in it. `releaseSlot` is idempotent. A second call with the same id, or a call for a slot that `maxHold` already took back, returns `{ released: false }` and changes nothing. You can retry it safely. ## Hold a slot for work inside one run When the work starts and ends in the same workflow, use `withLimit`. It takes a slot, runs your function, and releases the slot when the function returns or throws. ```ts import { withLimit } from '@stackbone/sdk/workflow'; export async function exportReportWorkflow(input: { reportId: string }) { 'use workflow'; return withLimit( 'partner-api', async () => { const file = await buildReport(input.reportId); // a 'use step' return { file }; }, { label: input.reportId }, ); } ``` ## Wait in the queue When every slot is busy, `acquireSlot` puts the run in a queue for that limit. The queue is first come, first served, and it is durable: a box restart keeps it. While the run waits, its status stays `running`, and its step list shows a `hook` step named `Esperando hueco ยท `. The step closes when the run gets its slot. The run waits up to `timeout`, which defaults to 24 hours. Pass a duration (`'10m'`) or milliseconds. When the timeout passes first, the run leaves the queue and `acquireSlot` throws `SlotTimeoutError`. Check for it with `SlotTimeoutError.is(error)`, which works across module copies where `instanceof` can fail. The error carries `limiter` and `timeout`. A run that waits in the queue holds no slot. If you cancel it, or it fails, the box removes it from the queue. ## What frees a slot The next run in the queue gets a slot when one of these happens: - A workflow calls `releaseSlot(slotId)`, or `withLimit` returns. - An operator releases the slot from Studio or the CLI. - The slot stays busy longer than `maxHold`. The box takes it back. - Someone raises the capacity. - A run that was waiting, or that the box had just picked, ends before it confirms the slot. A slot the box gives to a waiting run is reserved for 2 minutes. If the run does not confirm it in that time (for example, the box restarted), the slot goes to the next run and the late run moves to the end of the queue, where it keeps waiting inside what is left of its timeout. A background pass every 15 seconds handles these cases and the `maxHold` expiry, so a freed slot can take up to 15 seconds to reach the queue after a crash. Ending a run does **not** release a slot it already holds. That slot belongs to the work the run started, and only `releaseSlot` or `maxHold` frees it. ## See and operate limits Studio has a **Limits** screen. It lists each limit with its capacity, the busy and waiting counts, and its max hold. Open a limit to see its slots: busy slots first, with their run and label, then the queue in order. From there you can release a busy slot. **Edit limit** changes the capacity and the max hold. The CLI does the same against a running installation: ```sh stackbone limits list # every limit: busy/capacity, waiting, maxHold stackbone limits slots voice-calls # one limit's slots, busy first, then the queue stackbone limits release --yes # give a busy slot back stackbone limits capacity voice-calls 30 # override the declared capacity stackbone limits capacity voice-calls reset # go back to the value in stackbone.config.ts stackbone limits max-hold voice-calls 2h # override the declared maxHold stackbone limits max-hold voice-calls reset # go back to the value in stackbone.config.ts ``` Every verb targets the local-dev installation by default, or any installation with `--agent `. See [`stackbone limits`](/docs/cli/reference/limits). Release a slot by hand only when its release got lost: the work ended, and no workflow called `releaseSlot`. Otherwise the slot comes back on its own at `maxHold`. A capacity or a max hold set in Studio or with the CLI wins over the value in `stackbone.config.ts` until you reset it, and a deploy does not clear it. The box reads the capacity each time it hands out a slot. Raising it grants waiting runs at once. Lowering it takes no slot back: busy slots finish, and the box hands out new ones only when the busy count drops below the new capacity. The max hold is read once per slot, when the box grants it: the slot's expiry is set then and never changes. A new max hold applies only to slots granted after the change. Lowering it takes back no running work, and raising it does not extend a slot that is already busy. The max hold override takes the same values as `maxHold`, up to 30 days. ## Limits and serial mode | You need | Use | | ------------------------------------------------------------- | -------------------------------------------------------- | | One run of a workflow at a time, in arrival order | [Serial execution](/docs/sdk/workflows/serial-execution) | | At most N pieces of work at once, shared by several workflows | A concurrency limit | | Work that starts in one workflow and ends in another | A concurrency limit with `acquireSlot` and `releaseSlot` | Serial mode queues whole runs before they start. A limit queues inside a run, at the `acquireSlot` call, so the steps before it run at once. ## What limits do not do - They do not cap a rate. A limit counts busy slots, not calls per second. - They have no priorities. The queue is first come, first served. - They count per box. Two installations of the same agent each have their own slots. ## What's next - [Serial execution](/docs/sdk/workflows/serial-execution): one run at a time for a single workflow. - [Getting started](/docs/sdk/workflows/building-workflows): scaffold, trigger, and observe a workflow. - [`stackbone limits`](/docs/cli/reference/limits): the CLI verbs in full. - [Project configuration](/docs/cli/reference/configuration): the `stackbone.config.ts` reference.