# Runs API

Start, continue, stream, inspect, cancel, and delete durable agent runs.

The Runs API is the application-facing execution API. A run keeps the pinned
agent version, trusted Pi session, and durable workspace across turns.

## Start a run

```http
POST /api/v1/runs
```

Required scope: `run:write`.

```bash
curl "$SALAMBO_BASE_URL/api/v1/runs" \
  -H "Authorization: Bearer $SALAMBO_API_KEY" \
  -H "Idempotency-Key: start-report-001" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": "agt_01J...",
    "input": "Write a short status report.",
    "background": true
  }'
```

Supply exactly one selector:

* `agent` starts a new run, optionally on an exact version with `version`;
* `run_id` continues an existing run.

The default delivery waits for the affected turn. Set `background` to return
after durable admission, or set `stream` to receive Server-Sent Events. Do not
set both fields to `true`.

In the TypeScript SDK, `runs.execute` waits for the turn unless `background` is
`true`, and `runs.stream` receives the events. The SDK sends an idempotency key
for you, or use the one you pass:

```ts
const run = await salambo.runs.execute(
  {
    agent: 'agt_01J...',
    input: 'Write a short status report.',
    background: true,
  },
  { idempotencyKey: 'start-report-001' },
);

const stream = await salambo.runs.stream({
  agent: 'agt_01J...',
  input: 'Write a short status report.',
});
for await (const event of stream) {
  console.log(event.sequence, event.type);
}
const result = await stream.finalResponse();
```

## Start on an exact agent version

A new run starts on the agent's active deployment. Add `version` to start it on
exactly that deployment version instead:

```json
{
  "agent": "agt_01J...",
  "version": 3,
  "input": "Qualify Acme"
}
```

The version is the number every run reports as `agent_version`. Starting on it
does not activate it, so other runs of the agent keep using the active version.
To compare versions on the same tasks, start one run per version and read the
`agent_version` each response reports to confirm what executed it.

* `version` only goes with `agent`. Sending it with `run_id` is a `400`. A run
  started on an exact version runs its first turn on that version. Later turns
  follow the workspace-upgrade policy of the agent's active deployment:
  `automatic` moves the run to the active version before its next turn, and
  `new-run-only` keeps it where it is. Each turn reports the version that
  executed it.
* The version must belong to the agent and be ready to run.
* Retrying with the same `Idempotency-Key` returns the same turn on the same
  version, even if the agent activates another version in between. Reusing the
  key for a different version is a `409 idempotency_conflict`.

Errors that name `version` in `error.param`:

| Status | Code                      | Meaning                                                                |
| ------ | ------------------------- | ---------------------------------------------------------------------- |
| `400`  | `invalid_request`         | `version` is not a positive whole number, or it is sent with `run_id`  |
| `404`  | `agent_version_not_found` | The agent has never had this version                                   |
| `409`  | `agent_version_not_ready` | The version exists but is not ready to run, for example it is building |

An unknown agent, or an agent of another workspace, is still `404 agent_not_found`
whatever the version.

In the TypeScript SDK, `version` goes beside `agent`. The types reject it beside
`run_id`, and the SDK throws before sending a call that mixes the selectors. The
response is the run object as the API returns it, so `agent_version` is the
version that executed the turn. Pass `idempotencyKey` to repeat a start safely:

```ts
const run = await salambo.runs.execute(
  { agent: 'agt_01J...', version: 3, input: 'Qualify Acme' },
  { idempotencyKey: 'qualify-acme-v3' },
);
console.log(run.agent_version); // 3
```

## Tag a run

A tag is a plain string label you choose, such as `regression` or
`customer-reported`, so you can find the run again. Tags are free-form: there is
no list of allowed values and no naming format, and Salambo never interprets a
tag. `scenario=abc` is just the text `scenario=abc`. Tags belong to the run.

Add `tags` when you start a run:

```json
{
  "agent": "agt_01J...",
  "input": "Qualify Acme",
  "tags": ["regression", "customer-reported"]
}
```

* A tag is 1 to 64 characters and is stored exactly as you send it. It is
  case-sensitive, so `Regression` and `regression` are two tags, and nothing is
  trimmed, so `" a "` keeps its spaces.
* A tag that is empty, only whitespace, or holds a control character (a tab or a
  line break, for example) is a `400` with `error.param` set to `tags`.
* A request names at most 20 tags and a run has at most 20. A repeated tag is
  one tag.
* `tags` only goes with `agent`. Sending it with `run_id` is a `400` naming
  `tags`. To change the tags of a run that exists, use the call below.
* Retrying with the same `Idempotency-Key` and the same tags returns the same
  run. The order of the tags does not matter. Reusing the key with other tags is
  a `409 idempotency_conflict`.

In the TypeScript SDK, `tags` goes beside `agent`. The types reject it beside
`run_id`, and the SDK throws before sending a call that mixes the selectors:

```ts
const run = await salambo.runs.execute({
  agent: 'agt_01J...',
  input: 'Qualify Acme',
  tags: ['regression', 'customer-reported'],
});
```

Every run and every listed run carries its tags, sorted, as a list that is empty
when it has none:

```json
{
  "id": "run_01J...",
  "object": "run",
  "tags": ["customer-reported", "regression"]
}
```

The SDK returns the list as the API sends it, on the run that `execute`,
`retrieve` and `list` return:

```ts
console.log(run.tags); // ['customer-reported', 'regression']
```

### Change a run's tags

```http
POST /api/v1/runs/{runId}/tags
```

Required scope: `run:write`.

```json
{ "add": ["triaged"], "remove": ["needs-triage"] }
```

The response is the run's full tag list:

```json
{
  "object": "run_tags",
  "run_id": "run_01J...",
  "tags": ["regression", "triaged"]
}
```

Adding and removing are set operations. Adding a tag the run already has, or
removing one it does not have, changes nothing, so a repeated request is safe
without an `Idempotency-Key`, and two callers editing one run never erase each
other's tags.

* Supply at least one of `add` and `remove`, and no tag in both: that is a `400`.
* The limit of 20 applies to the result. A request may add tags while it removes
  others, and a result over 20 is a `400` naming `add` that changes nothing.
* An unknown run, or a run of another workspace, is `404`, and an expired run is
  `410 run_expired`.

Changing tags never changes the run's turns, their inputs and outputs, the
deployment version they ran on, or work in progress, and it does not keep an idle
run retained for longer.

In the TypeScript SDK, `runs.tags.update` takes the lists and returns the run's
full tag list. Give at least one of `add` and `remove`: the types require it, and
the SDK throws before sending a call that gives neither. A repeated call is safe,
so the SDK sends no idempotency key.

```ts
const result = await salambo.runs.tags.update('run_01J...', {
  add: ['triaged'],
  remove: ['needs-triage'],
});
console.log(result.tags); // ['regression', 'triaged']
```

A tag the API refuses is thrown as an `InvalidRunTagsError`, a `SalamboApiError`
whose `param` names `tags`, `add`, `remove` or `tag`:

```ts
import { InvalidRunTagsError } from '@salambo-ai/sdk';

try {
  await salambo.runs.tags.update('run_01J...', { add: [''] });
} catch (error) {
  if (!(error instanceof InvalidRunTagsError)) throw error;
  console.log(error.param); // 'add'
}
```

## Wait for a turn

By default `POST /api/v1/runs` waits for the affected turn, for up to 120
seconds. If the turn is final by then the answer is `200` with the run. If it is
not, the answer is `202` with the run as it stands and a `Location` header naming
it. A `202` means the request stopped waiting. The turn did not stop, and
nothing was started twice. Set `background` to get the answer as soon as the
turn is admitted. The turn in a `202` is usually still running, but it can
already be final, because a turn admitted in the background can finish before
the API reads it back. Check `run.turn.status` and do not assume there is
something to wait for.

Either way, the way to the result is to read the turn until its status is
final. `completed`, `failed`, `cancelled` and `incomplete` never change. Until
then it is `queued`, `in_progress` or `cancelling`:

```http
GET /api/v1/runs/{runId}?turn_id=turn_...
```

Name the turn with `turn_id`. Without it the endpoint selects the latest turn,
and a turn added to the run later would answer for the one you were waiting on.

In the TypeScript SDK, `runs.execute` does this itself: on a `202` it goes on
with `runs.wait`, so it returns a run whose turn is final unless `background`
is `true`. Use `runs.wait` to finish a background start:

```ts
const started = await salambo.runs.execute({
  agent: 'agt_01J...',
  input: 'Reconcile these invoices.',
  background: true,
});

const run = await salambo.runs.wait(started.id, {
  turn_id: started.turn.id,
  signal: AbortSignal.timeout(10 * 60_000),
});
```

What to know before you rely on it:

* **A failed turn is a result.** `wait` resolves for every final status and does
  not throw for `failed`, `cancelled` or `incomplete`. Read `run.turn.status`
  and `run.turn.error`.
* **There is no deadline.** A turn that never settles is waited on for ever.
  Pass `signal: AbortSignal.timeout(ms)` to set one.
* **Aborting a wait does not cancel the turn.** It stops reading. To stop the
  work, [cancel it](#cancel-work).
* **Reads are spaced out.** The first wait is `pollIntervalMs` (default 1000
  milliseconds), each one after it is half as long again, up to 10 seconds.
  Every wait is then made up to 20% longer at random, and none is shorter than
  its interval.
* **A `429` does not end the wait.** The SDK waits as long as the `Retry-After`
  the API sent says, or the next interval if there was none, and reads again.
  Any other error ends it. Reading is rate limited like every other request, so
  a client that polls its own way should back off the same way.

To watch a turn while it runs, [stream it](#start-a-run). To read what it did
afterwards, use [turn history](#read-turn-history) and
[retained events](#read-retained-events).

## Continue a run

Use the same endpoint with the existing run ID:

```json
{
  "run_id": "run_01J...",
  "input": "Revise the report for an executive audience.",
  "on_busy": "queue"
}
```

`on_busy` accepts:

| Value    | Behavior                                                    |
| -------- | ----------------------------------------------------------- |
| `queue`  | Admit one bounded follow-up turn after active work.         |
| `steer`  | Add the input to the active turn when steering is possible. |
| `reject` | Return a conflict when the run is busy.                     |

If the active turn ends before a `steer` input is stored, the call is a `409`
with code `steering_target_ended`:

| Status | Code                    | Meaning                                                                                |
| ------ | ----------------------- | -------------------------------------------------------------------------------------- |
| `409`  | `steering_target_ended` | The turn this input was sent to has ended. Send it again with a new `Idempotency-Key`. |

The input was not taken, and `retryable` is `false`: the same `Idempotency-Key`
returns the same answer, because it names the admission that was refused. A new
key is a new request.

In the TypeScript SDK, pass `run_id` instead of `agent`:

```ts
const next = await salambo.runs.execute({
  run_id: 'run_01J...',
  input: 'Revise the report for an executive audience.',
  on_busy: 'queue',
});
```

## Send text and files

`input` can be a string or an array of typed parts:

```json
{
  "run_id": "run_01J...",
  "input": [
    { "type": "input_text", "text": "Review this invoice." },
    { "type": "input_file", "file_id": "file_01J..." }
  ]
}
```

Upload larger input files through `POST /api/v1/files`. Small files can be sent
inline with `filename` and base64 `file_data`.

In the TypeScript SDK, `files.upload` returns the file to reference, and
`input` takes the same typed parts:

```ts
const file = await salambo.files.upload({
  file: readableStream,
  filename: 'invoice.pdf',
  media_type: 'application/pdf',
});

const run = await salambo.runs.execute({
  run_id: 'run_01J...',
  input: [
    { type: 'input_text', text: 'Review this invoice.' },
    { type: 'input_file', file_id: file.id },
  ],
});
```

## Read run and turn state

```http
GET /api/v1/runs/{runId}
GET /api/v1/runs/{runId}?turn_id=turn_...
```

Required scope: `run:read`.

Without `turn_id`, the endpoint selects the latest turn. The returned object
contains the run state and the selected turn. A run that has expired returns
`410` with code `run_expired`:

```json
{
  "id": "run_01J...",
  "object": "run",
  "agent_id": "agt_01J...",
  "agent_version": 7,
  "state": "idle",
  "disposition": "created_run",
  "tags": ["regression"],
  "turn": {
    "id": "turn_01J...",
    "object": "turn",
    "status": "completed",
    "agent_version": 7,
    "input": [],
    "output": [{ "type": "output_text", "text": "..." }],
    "error": null
  }
}
```

`turn.agent_version` is the deployment version of the agent that executes that
turn. It is pinned when the turn is admitted and never changes, so it still names
the version that ran after the agent's active version moves on. One run can hold
turns on different versions, so read it from the turn you care about. The
run-level `agent_version` equals the selected turn's.

The TypeScript SDK returns this object as the API sends it:

```ts
const run = await salambo.runs.retrieve('run_01J...', {
  turn_id: 'turn_01J...',
});
console.log(run.turn.agent_version);
```

## List runs

```http
GET /api/v1/runs
```

Required scope: `run:read`.

```bash
curl "$SALAMBO_BASE_URL/api/v1/runs?agent_id=agt_01J...&version=7&limit=20" \
  -H "Authorization: Bearer $SALAMBO_API_KEY"
```

The list is ordered by most recent activity. Filters combine:

| Query parameter  | Selects                                                                 |
| ---------------- | ----------------------------------------------------------------------- |
| `agent_id`       | Runs of one agent                                                       |
| `version`        | Runs with at least one turn on that deployment version                  |
| `state`          | `idle` or `busy` runs                                                   |
| `tag`            | Runs with this tag. Repeat it, up to 5 times, for runs with all of them |
| `created_after`  | Runs created at or after a Unix timestamp                               |
| `created_before` | Runs created at or before a Unix timestamp                              |

Repeating `tag` narrows the list: `?tag=regression&tag=customer-reported` lists
only runs that have both. A tag matches exactly, with its case, and the filter
uses the same cursor pagination as any other. More than 5 tags, or a tag that
could not be a tag, is a `400` naming `tag`.

A filter of tags that are all common in your workspace searches at most 50,000
of its runs to fill a page. If that does not fill one, the request is refused
with `400`, code `invalid_request` and `param` `tag`, and never answered with a
short page, which would read as the end of the list. Add a more specific tag:
a run must have every tag, so a tag that few runs have narrows the search.

In the TypeScript SDK, `tag` takes one tag or an array of them, and sends each as
its own `tag` parameter:

```ts
// Runs that have both tags
const page = await salambo.runs.list({
  tag: ['regression', 'customer-reported'],
});
```

A run that moved to a newer agent version still lists under the versions its
earlier turns ran on, so it can appear under more than one `version`. Its
listed `agent_version` is the version its workspace is on, which a queued turn
on a newer version may not have reached yet.

`limit` accepts 1 to 100 and defaults to 20. When `has_more` is `true`, pass
`next_cursor` as `after` to read the next page. Cursors are opaque.

Each listed run is a summary: its ID, agent, `agent_version`, state, tags and
timestamps. Retrieve a run to read its turns.

In the TypeScript SDK, `runs.list` returns one page. Iterating it with
`for await` reads further pages as the loop needs them and stops when `has_more`
is `false`. `page.next_cursor` is the cursor to pass as `after`.

```ts
const page = await salambo.runs.list({ agent_id: 'agt_01J...', version: 7 });
for await (const run of page) {
  console.log(run.id, run.agent_version);
}
```

## Durable data and events

A run and its turns are durable data. Their status, input, output, error and
`agent_version` are stored with the run and stay readable through retrieval and
listing for as long as the run is retained, whether or not any events remain.
Retrieval and listing read only this data and never load events.

Events are technical history. They are stored separately, follow their own
retention, and are meant for observing and debugging execution. Read the run
object, not the events, for the result of a turn. It stays readable, output
included, when the events are gone or cannot be read.

## Read retained events

```http
GET /api/v1/runs/{runId}/events
```

The event list contains public Pi events and Salambo platform events in run
sequence order. Use `turn_id` to select one turn, `after` to continue from an
event cursor, and `limit` to set the page size.

The full event stream is encrypted before Salambo writes it to S2. Postgres
stores the bounded run and turn read model. It does not store the full event
payload history.

Events are technical history for observing and debugging execution, such as
which tools a turn called or how it recovered. They are not the conversation:
read what each turn was asked and answered from
[turn history](#read-turn-history), which stays available after older events
expire.

Each event carries `id`, `sequence` (its position in the run), `source`, `type`,
`run_id`, `turn_id`, `created_at` (Unix seconds) and `data`. A listed event is
the same object the live stream delivers, so a streamed event and its retained
copy share an `id`.

Events do not keep the images that tools and messages carry. Two things are replaced
by a marker:

* An image part in a tool result or a message, such as a screenshot, appears in
  `data` as its metadata only: its `mimeType`, its decoded size in `bytes`, the
  `sha256` of those bytes, and `omitted` set to `not_persisted`.
* An inline `data:image/...;base64,...` URL inside a string field of an event is
  replaced in that string by `[image omitted: image/png, 937500 bytes]`: its media
  type and its decoded size. This is done to a whole field, such as the text of a
  tool result or of a final message.

The agent still sees the image, because only the event copy lacks it.

```json
{
  "type": "image",
  "mimeType": "image/png",
  "bytes": 937500,
  "sha256": "9f2c…",
  "omitted": "not_persisted"
}
```

The text a model streams piece by piece (`text_delta` and `toolcall_delta` events) is
stored as the model wrote it, a data URL in it included. The final message of the
turn (`message_end`) is a whole field, so its data URLs are replaced in the event,
while the `output` of the turn keeps them.

A string too large for one stored event (about 675 KiB of JSON in an event) is
replaced by a string that says so: `[truncated: 800000 bytes]`. Any other value too
large for it is replaced by `{ "truncated": true }` with its `originalBytes` and its
`sha256`. The limit is on the event as the database prints it, which is longer than
its JSON for numbers written with an exponent: a list of about 3,300 numbers like
`1e-300`, or 41,000 like `1e+21`, is already too large, where a list of 330,000 small
integers is not.

In the TypeScript SDK, `runs.events.list` returns one page with the API's
`history` on it. Iterating the page reads further pages as the loop needs them,
`nextPage()` reads one at a time, and `next_cursor` is the cursor to pass as
`after`, from any process:

```ts
const page = await salambo.runs.events.list('run_01J...', { limit: 100 });
console.log(page.history); // { status: 'complete', retention_days: 14 }

for await (const event of page) {
  console.log(event.sequence, event.created_at, event.type);
}
```

### History is a rolling window

Each run keeps the retention period its event streams were provisioned with, 14
days by default, and every list response returns it:

```json
{
  "object": "list",
  "data": [],
  "has_more": false,
  "next_cursor": null,
  "history": { "status": "partial", "retention_days": 14 }
}
```

Events expire one by one as they age past the period, so older events can be
gone while newer ones remain. Salambo applies the window itself: an event older
than `retention_days` is not returned, even if it is still stored. An
entitlement change applies to runs created afterwards, and never to a run that
already exists. `retention_days` is `null` while a run has no event stream yet.

A read without `after` reports in `history.status` what it found:

| Status     | Meaning                                                                                            |
| ---------- | -------------------------------------------------------------------------------------------------- |
| `complete` | The events begin at the run's first event, or at the selected turn's first with `turn_id`          |
| `partial`  | Older events expired. The page holds the events that remain, from the oldest retained one          |
| `expired`  | Events were recorded and every one has expired. `data` is empty, and the response is still a `200` |
| `empty`    | No event has ever been recorded for the run                                                        |

A retained suffix is never presented as the whole history. A read with `after`
continues a page and reports `null` for `status`, because it says nothing about
where history starts.

In the TypeScript SDK the history is `page.history`. The pages that follow the
first carry `status: null`, so keep the first page's history if you need it:

```ts
const first = await salambo.runs.events.list('run_01J...');
if (first.history.status === 'partial') {
  console.log(
    `older events expired; ${first.history.retention_days} day window`,
  );
}

const second = await first.nextPage();
console.log(second?.history.status); // null
```

### Cursors and errors

A cursor names one event, and a read resumes after it, so that event has to be
retained. When it has expired, whether S2 deleted it or only the window passed
it, the request returns `410` with code `event_cursor_expired`, even if the
next event survives. Read again without `after` to start from what remains.

| Status | Code                        | Meaning                                                                               |
| ------ | --------------------------- | ------------------------------------------------------------------------------------- |
| `410`  | `event_cursor_expired`      | The event `after` names is no longer retained                                         |
| `503`  | `event_boundary_pending`    | The first event of an admitted turn is not recorded yet. Retry                        |
| `503`  | `event_history_unavailable` | The history service could not be read, or a stream that held events is missing. Retry |

Both `503` responses are retryable. Neither is ever reported as `empty` or
`expired`: an outage is not evidence that history is gone.

The TypeScript SDK throws each of these as its own class, so you can recognise
them with `instanceof`. Every one is still a `SalamboApiError` with `status`,
`code`, `param` and `retryable`:

| Code                        | SDK class                         |
| --------------------------- | --------------------------------- |
| `event_cursor_expired`      | `RunEventCursorExpiredError`      |
| `event_boundary_pending`    | `RunEventBoundaryPendingError`    |
| `event_history_unavailable` | `RunEventHistoryUnavailableError` |

The SDK never answers an expired cursor by reading again without it, because
that would hand you the retained events as if nothing had been lost. It throws,
and you decide, for example by reading what remains:

```ts
import { RunEventCursorExpiredError } from '@salambo-ai/sdk';

try {
  await salambo.runs.events.list('run_01J...', { after: savedCursor });
} catch (error) {
  if (!(error instanceof RunEventCursorExpiredError)) throw error;
  const retained = await salambo.runs.events.list('run_01J...');
  console.log(retained.history.status); // 'partial', or 'expired'
}
```

The same holds while paging, and for a stream that reconnects. A `stream`
reconnect resumes after the last event it received, and if that event has
expired the iteration and `finalResponse()` reject with the same error instead
of skipping ahead. The SDK retries a `503` itself, so one that reaches you
survived its retries and is worth retrying later.

Consumers must accept unknown event types and additive data fields. Use
`source` and `type` as discriminators.

## Read turn history

```http
GET /api/v1/runs/{runId}/turns
GET /api/v1/runs/{runId}/turns/{turnId}
```

Required scope: `run:read`.

Turn history is the durable conversation record of a run. Use it to fetch a
transcript: what each turn was asked, what it answered, which agent version ran
it, and how it ended.

```bash
curl "$SALAMBO_BASE_URL/api/v1/runs/run_01J.../turns?limit=20" \
  -H "Authorization: Bearer $SALAMBO_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "turn_01J...",
      "object": "turn",
      "status": "completed",
      "agent_version": 7,
      "input": [{ "type": "input_text", "text": "Summarize the findings." }],
      "output": [{ "type": "output_text", "text": "..." }],
      "created_at": 1786640500,
      "started_at": 1786640502,
      "completed_at": 1786640540,
      "duration_ms": 38000,
      "error": null
    }
  ],
  "has_more": true,
  "next_cursor": "eyJ..."
}
```

Turns are ordered oldest first, so the list reads as the conversation. A page
holds at most `limit` turns, from 1 to 100 with a default of 20. When `has_more`
is `true`, pass `next_cursor` as `after` to read the next page. Cursors are
opaque, and a cursor only works for the run that issued it: another run's cursor
returns `400` with code `invalid_cursor`.

Every turn carries its `input` and final `output`, its `status`, its `error` when
it failed or stopped incomplete, its timestamps, and `agent_version`, the
deployment version that executed it. Turns that are queued or running are
included with their current status, and their output is final once they finish.
`GET /api/v1/runs/{runId}/turns/{turnId}` returns one such turn by itself, where
`GET /api/v1/runs/{runId}?turn_id=...` returns the run state around a turn.

Turn history is read from records stored with the run. It stays available for as
long as the run is retained, and an expired run returns `410` with code
`run_expired`. It does not use the technical event log, so it keeps working after
older events expire. Events are rolling technical history, described under Read
retained events, and are not the record of a conversation.

The TypeScript SDK reads turn history with `runs.turns`. `list` returns one page
of turns and, like `runs.list`, iterates further pages lazily and exposes
`next_cursor`. `retrieve` returns one turn.

```ts
const history = await salambo.runs.turns.list('run_01J...', { limit: 20 });
for await (const turn of history) {
  console.log(turn.agent_version, turn.status);
}

const turn = await salambo.runs.turns.retrieve('run_01J...', 'turn_01J...');
```

## Cancel work

```http
POST /api/v1/runs/{runId}/cancel
```

Use an empty body to target the active turn, or the queued turn when nothing is
active. Supply `turn_id` to cancel one selected turn. Active cancellation
returns `202` while the runtime settles. A queued turn returns `200` once
cancelled. A turn that already finished, or an idle run, returns `200` with the
existing result unchanged. See [Runs and turns](/docs/operate/runs-turns#cancellation).

In the TypeScript SDK, `runs.cancel` returns the run as `runs.retrieve` reads it.
The result is the turn's `status`, and the SDK adds no lifecycle of its own:

```ts
// The active turn, or the queued turn when nothing is active
const run = await salambo.runs.cancel('run_01J...');

// One selected turn
await salambo.runs.cancel('run_01J...', { turn_id: 'turn_01J...' });
```

`run.turn.status` says which of the four results you got: `cancelling` for an
active turn, `cancelled` for a queued one, and the turn's own status for one that
had already finished or an idle run. A finished turn is never relabelled
`cancelled`, so a `failed` or `incomplete` turn keeps its terminal reason in
`run.turn.error`. To observe an active turn settle, wait for it as for any turn:

```ts
const cancelling = await salambo.runs.cancel('run_01J...');

const settled = await salambo.runs.wait('run_01J...', {
  turn_id: cancelling.turn.id,
});
console.log(settled.turn.status); // 'cancelled'
```

Cancelling is idempotent. The SDK sends the `idempotencyKey` you pass, or makes
one per call and reuses it for its own retries. A repeated call with the same key
returns the turn it first selected, never a later one:

```ts
await salambo.runs.cancel(
  'run_01J...',
  {},
  { idempotencyKey: 'stop-report-001' },
);
```

Aborting the `signal` of `runs.execute` or `runs.wait` stops your call and does
not cancel the turn: cancelling is only this call. An unknown run or turn is
`404` (`run_not_found`, `turn_not_found`), and an expired run is
`410 run_expired`.

## Delete an idle run

```http
DELETE /api/v1/runs/{runId}
```

Deletion fences new work and starts asynchronous cleanup. A busy run must be
cancelled and allowed to settle before deletion.

```ts
await salambo.runs.delete('run_01J...');
```

## Idempotency

All mutating endpoints require an `Idempotency-Key` header, except
[changing a run's tags](#change-a-runs-tags), which is safe to repeat without
one. Reuse a key only when you retry the same operation.
