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

# Runs

> Monitor a batch of AI-run calls with the Runs API

## Overview

A **run** is one agent dialling a list of contacts. It is the batch primitive: instead of creating a conversation per person and tracking hundreds of ids, you launch a run and read one aggregate.

A run does not replace the conversation, it batches it. Launching mints **one conversation per contact**, and the call attempts hang off that conversation exactly as they do for a conversation you created on its own. So everything the [Conversations](/guides/resources/conversations) guide says about transcripts, analysis, analytics and webhooks holds for a conversation a run produced. What the run adds is a lifecycle and a roll-up.

## Building and running one

Three requests, and none of them can call anybody twice:

```javascript theme={null}
POST /runs                    // an empty draft
POST /runs/{id}/contacts      // add contacts — attaching an id twice is a no-op
POST /runs/{id}/launch        // one conversation per contact, now or up to 30 days out
```

Then `pause`, `resume` and `cancel` drive it, and `GET /runs/{id}` reports where it has got to.

<Info>
  **Create takes no contacts, on purpose.** If it did, a request that timed out after the server
  committed would leave you retrying into a *second* draft holding the same list — dialling
  nothing, but sitting there launchable. A retried create leaves an empty draft instead, which
  holds nothing anyone can launch by mistake. It is also why this API has no `Idempotency-Key`
  header: the one endpoint that would have needed one does not, once create is empty.
</Info>

## Resource Structure

```json theme={null}
{
  "id": "789e0123-e45b-67d8-a901-234567890123",
  "name": "Q3 product follow-ups",
  "agentId": "456e7890-e12b-34d5-a678-901234567890",
  "status": "RUNNING",
  "scheduledAt": null,
  "scheduleError": null,
  "progress": {
    "total": 42,
    "queued": 30,
    "inProgress": 3,
    "completed": 8,
    "failed": 1,
    "cancelled": 0,
    "unreachable": 0,
    "completion": 0.21
  },
  "createdAt": "2026-08-20T09:00:00Z",
  "updatedAt": "2026-08-20T11:42:00Z"
}
```

`scheduledAt` is when a `SCHEDULED` run starts dialling, `null` on one that launched immediately. `scheduleError` is why a scheduled run did not start at its time — see [Starting later](#starting-later).

The run carries no caller ID, retry policy or call window. Those are the agent's, and the agent is the single source of them.

## Required Scopes

| Operation                  | Required Scope                       |
| -------------------------- | ------------------------------------ |
| Get run                    | `read:runs`                          |
| List a run's conversations | `read:runs` and `read:conversations` |
| Create, launch, lifecycle  | `write:runs`                         |
| Attach or detach contacts  | `write:runs` and `read:contacts`     |
| Delete run                 | `delete:runs`                        |

The run scopes are new and have **no legacy aliases** — runs had no public surface before, so there is nothing to stay compatible with. Keys that already existed were granted all three, and new keys are issued with all three, so there is nothing to add.

`delete:runs` is separate from `write:runs` deliberately: a key that may build and drive a run need not also be able to remove it.

The conversations drill-down needs `read:conversations` as well because its rows **are** conversation resources, with the same analysis and analytics `GET /conversations` returns. `read:interviews` satisfies that half, as it does everywhere else.

## Progress

Every conversation the run has produced lands in exactly one bucket, so the six counts sum to `total`:

| Bucket        | What is in it                                                  |
| ------------- | -------------------------------------------------------------- |
| `queued`      | Dispatched but not yet dialled                                 |
| `inProgress`  | Ringing or connected right now                                 |
| `completed`   | The call happened and finished                                 |
| `failed`      | Something went wrong on the call                               |
| `cancelled`   | Closed out without being attempted                             |
| `unreachable` | The retry budget was spent without the contact ever picking up |

Two of them are worth reading carefully.

`inProgress` is **not** derived from the conversation's stored `status`. Nothing flips that row while a call is being placed and ringing, so a contact whose phone is ringing right now would otherwise report as queued. Such a call is moved out of `queued` rather than added on top, which is why the buckets still sum to `total`.

`unreachable` is its own bucket rather than part of `failed` because nothing malfunctioned — the contact simply never picked up within the retry budget. Folding the two together would also mean an entirely unreachable run never reached 100%.

`completion` is the fraction of conversations in a terminal state (`completed + failed + cancelled + unreachable`), between `0` and `1`.

## What a launch actually promises

A `2xx` from launch means the conversations are **queued, not dialled**. The provider is called later by the dispatch worker, outside your request, so:

* **per-contact outcomes never come back in the response.** They arrive as [`conversation.*` webhooks](/guides/webhooks), each carrying `runId`
* **the batch is all-or-nothing.** The whole contact list is admitted against your billing before any of it is dispatched; a run you cannot afford is refused whole with a [`402`](/guides/error-handling#billing-errors), nothing is queued, and the run stays a draft. Partial admission was rejected because it turns one request into an outcome nobody can predict or undo — you cannot un-call the contacts who already went out

<Warning>
  Admission is a **preflight, not a reservation**. Nothing is held, so two different runs launched
  at the same moment can both pass and your balance can move between admission and the dial. What
  protects the money is the per-call reservation before each individual call, which fails closed.
  What admission buys you is a single refusal up front instead of a run that fails one call at a
  time and reads like a telephony problem.
</Warning>

Rate limits do not bound any of this. Your API key's per-key and per-company limits bound **requests**, not the calls one request fans out into. Admission is what bounds dialling; the rate limiter never was.

## Starting later

`POST /runs/{id}/launch` with a `scheduledAt` books the run instead of dispatching it: it moves to `SCHEDULED`, queues nothing, and starts on its own at that time — up to **30 days ahead**, the same ceiling a single conversation's `scheduleTime` has always had.

It fires through the same code an immediate launch runs, so a run that starts on Monday morning is indistinguishable from one launched by hand on Monday morning. It may begin up to a minute late.

Until it fires it is still an ordinary draft: attach more contacts, or cancel it and the appointment goes with it. Contacts attached in the meantime are **held rather than dialled** — a run booked for Monday has not started, so a contact added on Saturday waits and goes out with the rest.

<Warning>
  **A booking is not a reservation.** The batch is admitted against billing when you schedule it
  *and again when it fires*, and the second one decides. Nothing is held in between.

  A run refused at its time stays `SCHEDULED` and is retried, with the reason in `scheduleError`.
  Topping up is enough to make it go. But **that field is the only place the refusal is visible** —
  a run whose `scheduledAt` has passed while its status is still `SCHEDULED` has been refused, and
  nothing else will tell you.
</Warning>

## Lifecycle

```
DRAFT ────────────────▶ RUNNING ⇄ PAUSED
  │                       │  ▲
  │   at scheduledAt      │  │
  └──▶ SCHEDULED ─────────┘  │
                             │
                        COMPLETED
                             │
        attaching a contact  │
        reopens it ──────────┘

DRAFT | SCHEDULED | RUNNING | PAUSED ──▶ CANCELLED   (terminal)
```

Cancel is reachable from every non-terminal status, not only from `PAUSED`. Cancelling a scheduled run clears its appointment as well as setting the status, so it never starts.

<Warning>
  `COMPLETED` is **not terminal**. Attaching a contact to a finished run reopens it to `RUNNING`,
  which is what makes a run usable as a rolling sequence rather than a one-shot batch. If your
  integration stops polling on `COMPLETED`, it will miss anything added afterwards.
</Warning>

`CANCELLED` is terminal. `PAUSED` is reversible, and it is a real stop:

* **Nothing further is dialled**, including calls already queued when you paused. They are withdrawn from the dispatch queue, and anything the queue had already picked up is refused at the call itself.
* **The work is held, not discarded.** The conversations stay in place and `resume` puts back exactly what pause held — a contact already reached during the running window is not called again, and none of the waiting ones are lost.
* **A contact attached to a paused run is not called** either. The conversation is created, the run stays paused, and they go out with everything else on resume.

`CANCELLED` differs on the last two: it closes the un-dialled conversations out, and there is no way back. A call already connected when you cancel is left to finish; its retry is refused rather than rescheduled.

## Correlating a Conversation Back to Its Run

Every conversation carries a nullable `runId`:

```json theme={null}
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "contactId": "contact-uuid",
  "agentId": "agent-uuid",
  "runId": "789e0123-e45b-67d8-a901-234567890123",
  "status": "COMPLETED"
}
```

It is `null` for a conversation created on its own, and the run's id for one a run produced.

**The webhook payloads carry it too**, beside the ids they already send, so a `conversation.completed` handler attributes the call to its batch without reading the conversation back at all:

```json theme={null}
{
  "event": "conversation.completed",
  "data": {
    "conversationId": "550e8400-e29b-41d4-a716-446655440000",
    "contactId": "contact-uuid",
    "runId": "789e0123-e45b-67d8-a901-234567890123",
    "status": "COMPLETED"
  }
}
```

Like the other payload ids, `runId` does not depend on the webhook's [vocabulary](/guides/webhooks#event-name-vocabulary) — it is always present, and `null` where there is no run.

## Monitoring a Run

```javascript theme={null}
// Reads a JSON body, or throws. Without a deadline a stalled request never settles, and
// parsing a non-2xx body would hand you an error envelope dressed as a result.
async function read(path) {
  const response = await fetch(`https://api.instaview.sk${path}`, {
    headers: { Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}` },
    signal: AbortSignal.timeout(10_000),
  });

  if (!response.ok) {
    throw new Error(`GET ${path} failed: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

const run = await read(`/runs/${runId}`);

console.log(`${Math.round(run.progress.completion * 100)}% done, ${run.progress.queued} still queued`);
```

Then drill in when you need per-contact detail:

```javascript theme={null}
const page = await read(`/runs/${runId}/conversations?limit=100`);

const unreachable = page.data.filter((conversation) => conversation.status === "UNREACHABLE");
```

Prefer webhooks to polling where you can: each conversation in a run fires the ordinary [`conversation.*` events](/guides/webhooks), and `runId` in the payload attributes it to the run without a request of your own.

## Company Isolation

You can only read runs belonging to your own API key's company. A run in another company returns `404 Not Found`, the same as one that does not exist — the API does not confirm that a resource you cannot read exists.

## Related Resources

<CardGroup cols={2}>
  <Card title="Get Run" icon="layer-group" href="/api-reference/runs/get-run">
    Status and aggregate progress
  </Card>

  <Card title="List Run Conversations" icon="list" href="/api-reference/runs/list-run-conversations">
    The per-contact drill-down
  </Card>

  <Card title="Conversations" icon="microphone" href="/guides/resources/conversations">
    What each conversation in a run carries
  </Card>

  <Card title="Agents" icon="robot" href="/guides/resources/agents">
    The agent a run dials with
  </Card>
</CardGroup>
