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

# Recover a Stuck or Long-Running Run

> Tell a slow run from a stuck one, stop it, and split the work so the next run finishes

> [!NOTE]
>
> **Capability:** Watching, cancelling, and splitting runs works with any API key. `max_duration_seconds` is generally available. `max_steps` is in beta: enroll at [agent.tinyfish.ai/beta](https://agent.tinyfish.ai/beta) first, or a request that sets it returns `403 FORBIDDEN`.

Use this when a run has been `RUNNING` longer than you expected, or when a big goal keeps running out of steps or time. A run usually takes this long because the goal is too big for one browser session, not because something broke. The fix is to watch it, stop it if you need to, and send the work again as smaller runs.

## Check whether it's stuck or just slow

Start the run with `queue` so you get a `run_id` straight away, then poll it with `runs.get`. While the run is going, `status` is `RUNNING`, `num_of_steps` is `null`, and `streaming_url` is a live view of the browser you can open.

**Python**

```py theme={null}
import time
from tinyfish import TinyFish, AgentConfig, RunStatus

client = TinyFish()

queued = client.agent.queue(
    url="https://scrapeme.live/shop/",
    goal="Go through shop pages 1 to 20 and list the name and price of every product.",
    agent_config=AgentConfig(max_steps=40),
)
run_id = queued.run_id

while True:
    run = client.runs.get(run_id)
    print(run.status, run.streaming_url)
    if run.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
        break
    time.sleep(10)
```

**TypeScript**

```ts theme={null}
import { TinyFish, RunStatus } from "@tiny-fish/sdk";

const client = new TinyFish();

const queued = await client.agent.queue({
  url: "https://scrapeme.live/shop/",
  goal: "Go through shop pages 1 to 20 and list the name and price of every product.",
  agent_config: { max_steps: 40 },
});
const runId = queued.run_id!;

const DONE: string[] = [RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED];

while (true) {
  const run = await client.runs.get(runId);
  console.log(run.status, run.streaming_url);
  if (DONE.includes(run.status)) break;
  await new Promise((r) => setTimeout(r, 10_000));
}
```

This is a run 20 seconds in, from `GET /v1/runs/{id}`:

```json theme={null}
{
  "run_id": "bfd238a3-7cf9-464e-af01-edaf7d211447",
  "status": "RUNNING",
  "created_at": "2026-11-04T00:59:43Z",
  "started_at": "2026-11-04T00:59:45Z",
  "finished_at": null,
  "num_of_steps": null,
  "result": null,
  "error": null,
  "streaming_url": "https://ip-18-144-225-223.tetra-data.production.tinyfish.io/tf-e52ada86-ddc5-4271-9ff5-50b0985cf267/stream/0",
  "browser_config": { "proxy_enabled": false, "proxy_country_code": null }
}
```

> [!NOTE]
>
> `num_of_steps` is on the TypeScript SDK's `Run` type (`run.num_of_steps`), but it isn't exposed on the Python SDK's `Run` model yet — read it from the raw REST response (as above) if you need it in Python.

Polling tells you the run is alive, not what it's doing. To see that, either open `streaming_url` or use `stream` instead of `queue`. Every `PROGRESS` event has a `purpose` that says what the agent is doing, so a run that's paging through results looks different from one that keeps retrying the same click.

**Python**

```py theme={null}
from tinyfish import TinyFish, AgentConfig, ProgressEvent

client = TinyFish()

with client.agent.stream(
    url="https://scrapeme.live/shop/",
    goal="Go through shop pages 1 to 20 and list the name and price of every product.",
    agent_config=AgentConfig(max_steps=40),
    on_streaming_url=lambda e: print("Watch live:", e.streaming_url),
) as stream:
    for event in stream:
        if isinstance(event, ProgressEvent):
            print(event.timestamp, event.purpose)
```

**TypeScript**

```ts theme={null}
import { TinyFish } from "@tiny-fish/sdk";

const client = new TinyFish();

const stream = await client.agent.stream(
  {
    url: "https://scrapeme.live/shop/",
    goal: "Go through shop pages 1 to 20 and list the name and price of every product.",
    agent_config: { max_steps: 40 },
  },
  {
    onStreamingUrl: (e) => console.log("Watch live:", e.streaming_url),
    onProgress: (e) => console.log(e.timestamp, e.purpose),
  },
);

for await (const event of stream) {
  // callbacks above handle each event; the loop keeps the stream open until COMPLETE
  void event;
}
```

How to read what you see:

| What you see | What it means | What to do |
| - | - | - |
| New `PROGRESS` events, each moving forward (next page, next item) | Slow but working. The goal is just big | Let it finish, or cancel and [split the task](#split-the-task-into-smaller-runs) |
| The same `purpose` over and over, or the live view sits on one screen | Looping on a popup, a bot check, or an element it can't find | [Cancel it](#cancel-the-run), then see [Troubleshoot Runs](https://docs.tinyfish.ai/agent/how-to/troubleshoot-runs.md) |
| Only `HEARTBEAT` events | The connection is open and the run is still going | Keep waiting, or poll `runs.get` |
| Still `PENDING` | Queued, not started | Wait. You can cancel a pending run too |

After a run ends, `streaming_url` no longer shows the browser. To see what happened, read the run's step timeline. Each entry in `steps` has a `timestamp`, a `status`, and an `action` describing what the agent did, and `video_url` is a recording of the whole run. Neither SDK returns `steps` or `video_url` yet, so read them from the REST endpoint. `video_url` is a signed link that expires after 15 minutes, so fetch it when you need it rather than storing it.

**Python**

```py theme={null}
import os
import httpx

run_id = "b7900351-93bb-439a-816d-323aaa479d44"

resp = httpx.get(
    f"https://agent.tinyfish.ai/v1/runs/{run_id}",
    params={"screenshots": "none", "html": "none"},
    headers={"X-API-Key": os.environ["TINYFISH_API_KEY"]},
)
resp.raise_for_status()
run = resp.json()

for step in run.get("steps") or []:
    print(step["timestamp"], step["status"], step["action"])
print("Recording:", run.get("video_url"))
```

**TypeScript**

```ts theme={null}
const runId = "b7900351-93bb-439a-816d-323aaa479d44";

const resp = await fetch(
  `https://agent.tinyfish.ai/v1/runs/${runId}?screenshots=none&html=none`,
  { headers: { "X-API-Key": process.env.TINYFISH_API_KEY ?? "" } },
);
if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);
const run = await resp.json();

for (const step of run.steps ?? []) {
  console.log(step.timestamp, step.status, step.action);
}
console.log("Recording:", run.video_url);
```

## Set limits that fit the task

Two fields in `agent_config` bound a run. Set both on anything that pages through results.

| Field | What it caps | Default | How it behaves |
| - | - | - | - |
| `max_steps` | Steps the agent takes (1 to 500) | 150 | When the run hits the cap, the agent takes one more step to write up what it has. A cap of 3 reports `num_of_steps: 4` |
| `max_duration_seconds` | Wall-clock time | No limit | The agent wraps up when time runs out, so the run ends a little after the limit. A 10-second limit ran for about 21 seconds, and a 60-second limit ran for 70 |

Neither limit makes the run fail. The run ends `COMPLETED` with whatever the agent wrote into `result`, and `error` is `null`. To tell a finished run from one that was cut short, add a `status` field to your output schema, as shown in [Troubleshoot Runs](https://docs.tinyfish.ai/agent/how-to/troubleshoot-runs.md#make-every-run-report-how-it-ended).

To pick values, run the goal once on a small piece (one page, one item), look at `num_of_steps`, and multiply by the number of pieces. Leave headroom: one shop page took 2 steps, but a 20-page goal had used 14 steps by page 5. Set `max_duration_seconds` to the longest you're willing to wait, and allow a few extra seconds for the wrap-up.

## Cancel the run

Cancel a run with `POST /v1/runs/{id}/cancel`. The Python and TypeScript SDKs don't have a cancel method yet, so call the endpoint directly with your API key. This works on runs started with `queue`, `stream`, or `/v1/automation/run-batch`. A run started with `run` (`/v1/automation/run`) can't be cancelled, so use `queue` for anything that might run long.

**Python**

```py theme={null}
import os
import httpx

run_id = "bfd238a3-7cf9-464e-af01-edaf7d211447"

resp = httpx.post(
    f"https://agent.tinyfish.ai/v1/runs/{run_id}/cancel",
    headers={"X-API-Key": os.environ["TINYFISH_API_KEY"]},
)
resp.raise_for_status()
print(resp.json())
```

**TypeScript**

```ts theme={null}
const runId = "bfd238a3-7cf9-464e-af01-edaf7d211447";

const resp = await fetch(`https://agent.tinyfish.ai/v1/runs/${runId}/cancel`, {
  method: "POST",
  headers: { "X-API-Key": process.env.TINYFISH_API_KEY ?? "" },
});
if (!resp.ok) throw new Error(`Cancel failed: ${resp.status}`);
console.log(await resp.json());
```

```json theme={null}
{
  "run_id": "bfd238a3-7cf9-464e-af01-edaf7d211447",
  "status": "CANCELLED",
  "cancelled_at": "2026-11-04T01:00:38.496Z",
  "message": null
}
```

Cancelling is safe to repeat. Calling it again on the same run returns the current status and says why nothing changed:

```json theme={null}
{
  "run_id": "bfd238a3-7cf9-464e-af01-edaf7d211447",
  "status": "CANCELLED",
  "cancelled_at": "2026-11-04T01:00:38.000Z",
  "message": "Run already cancelled"
}
```

A run that already finished returns `COMPLETED` or `FAILED` with `message: "Run already finished"`. A `404` means the run ID doesn't exist, and a `403` means your key can see the run but isn't allowed to cancel it.

To stop several runs at once, send up to 100 IDs to `POST /v1/runs/batch/cancel`:

```bash theme={null}
curl -X POST https://agent.tinyfish.ai/v1/runs/batch/cancel \
  -H "X-API-Key: $TINYFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"run_ids": ["7c2b641f-ec9c-4aa5-b47d-435f310d26ad", "957c9480-21f8-41bf-87dc-f3848110610a"]}'
```

The response has a `results` entry for each run it found and a `not_found` list for IDs it didn't.

## Recover what the run found

**Cancelling throws away the run's work.** After the cancel above, the run had been going for almost a minute, and this is what `GET /v1/runs/{id}` returned:

```json theme={null}
{
  "run_id": "bfd238a3-7cf9-464e-af01-edaf7d211447",
  "status": "CANCELLED",
  "started_at": "2026-11-04T00:59:45Z",
  "finished_at": "2026-11-04T01:00:38Z",
  "num_of_steps": 0,
  "result": null,
  "error": {
    "message": "Run cancelled by user",
    "category": "UNKNOWN",
    "retry_after": null,
    "help_url": "https://docs.tinyfish.ai/error-codes",
    "help_message": "Need help? See our error reference for troubleshooting tips."
  }
}
```

`result` is `null`. If you want partial data back, let a limit stop the run instead of cancelling it. When `max_steps` or `max_duration_seconds` runs out, the agent writes up what it has before it ends. Ask for a field that says where it got to, so you know where to pick up:

```json theme={null}
{
  "status": "COMPLETED",
  "num_of_steps": 14,
  "result": {
    "status": "incomplete",
    "reason": "Only completed pages 1–5 of 20 due to budget exhaustion before the time limit. 80 products extracted across 5 pages.",
    "products": [],
    "last_page": 5
  },
  "error": null
}
```

That's a 20-page goal with `max_duration_seconds: 60`. It reports how far it got (`last_page: 5`), but `products` came back empty even though the reason says it extracted 80. A long run's wrap-up is a summary, and it can drop the data itself. Use it to find out where to resume, not as the data. The reliable way to keep what you've collected is to make each run small enough to finish, which is the next section.

## Split the task into smaller runs

Instead of one run that pages through everything, start one run per page (or per item) and run them at the same time. Each run is short, finishes on its own, and returns its own result, so a problem on one page doesn't cost you the rest.

**Python**

```py theme={null}
import asyncio
from tinyfish import AsyncTinyFish, AgentConfig, RunStatus

client = AsyncTinyFish()

PAGE_SCHEMA = {
    "type": "object",
    "properties": {
        "status": {"type": "string", "enum": ["success", "incomplete"]},
        "reason": {"type": "string"},
        "products": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {"name": {"type": "string"}, "price": {"type": "string"}},
                "required": ["name", "price"],
            },
        },
    },
    "required": ["status", "reason"],
}

GOAL = (
    "List the name and price of every product on this page only. "
    "Don't go to other pages. Set status to success if you listed them all, "
    "or incomplete if you couldn't, and put a one-sentence explanation in reason."
)

DONE = (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED)


async def scrape_page(page: int):
    queued = await client.agent.queue(
        url=f"https://scrapeme.live/shop/page/{page}/",
        goal=GOAL,
        output_schema=PAGE_SCHEMA,
        agent_config=AgentConfig(max_steps=10),
    )
    while True:
        run = await client.runs.get(queued.run_id)
        if run.status in DONE:
            return page, run
        await asyncio.sleep(5)


async def main():
    results = await asyncio.gather(*(scrape_page(p) for p in range(1, 4)))
    for page, run in results:
        result = run.result or {}
        if run.status == RunStatus.COMPLETED and result.get("status") == "success":
            print(page, len(result.get("products", [])), "products")
        else:
            print(page, "needs a retry:", run.status, run.result or run.error)


asyncio.run(main())
```

**TypeScript**

```ts theme={null}
import { TinyFish, RunStatus } from "@tiny-fish/sdk";

const client = new TinyFish();

const PAGE_SCHEMA = {
  type: "object",
  properties: {
    status: { type: "string", enum: ["success", "incomplete"] },
    reason: { type: "string" },
    products: {
      type: "array",
      items: {
        type: "object",
        properties: { name: { type: "string" }, price: { type: "string" } },
        required: ["name", "price"],
      },
    },
  },
  required: ["status", "reason"],
};

const GOAL =
  "List the name and price of every product on this page only. " +
  "Don't go to other pages. Set status to success if you listed them all, " +
  "or incomplete if you couldn't, and put a one-sentence explanation in reason.";

const DONE: string[] = [RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED];

async function scrapePage(page: number) {
  const queued = await client.agent.queue({
    url: `https://scrapeme.live/shop/page/${page}/`,
    goal: GOAL,
    output_schema: PAGE_SCHEMA,
    agent_config: { max_steps: 10 },
  });
  while (true) {
    const run = await client.runs.get(queued.run_id!);
    if (DONE.includes(run.status)) return { page, run };
    await new Promise((r) => setTimeout(r, 5_000));
  }
}

const results = await Promise.all([1, 2, 3].map(scrapePage));
for (const { page, run } of results) {
  const result = run.result as { status: string; products?: unknown[] } | null;
  if (run.status === RunStatus.COMPLETED && result?.status === "success") {
    console.log(page, result.products?.length, "products");
  } else {
    console.log(page, "needs a retry:", run.status, result ?? run.error);
  }
}
```

All three runs were queued together, and each one finished in 25 to 37 seconds using 2 steps. This is page 1:

```json theme={null}
{
  "run_id": "7c2b641f-ec9c-4aa5-b47d-435f310d26ad",
  "status": "COMPLETED",
  "started_at": "2026-11-04T01:00:57Z",
  "finished_at": "2026-11-04T01:01:23Z",
  "num_of_steps": 2,
  "result": {
    "status": "success",
    "reason": "All 16 products with names and prices listed from the page.",
    "products": [
      { "name": "Bulbasaur", "price": "£63.00" },
      { "name": "Ivysaur", "price": "£87.00" }
    ]
  },
  "error": null
}
```

Together, the three pages took about 45 seconds from the first queue call to the last result, and 6 steps in total. The single 20-page run used 14 steps to cover 5 pages and returned no products.

The same split works for any goal with independent parts: one run per URL, per search term, or per record. If the parts depend on each other (you need page 1 to find the link for page 2), keep them in one run and give it enough `max_steps`.

To send many runs in one request, use `POST /v1/automation/run-batch` with 1 to 100 runs. The SDKs don't have a batch method, so call it with cURL or the CLI (`tinyfish agent batch run --input runs.csv`). It returns all the run IDs at once, and you can poll them together with `POST /v1/runs/batch`:

```bash theme={null}
curl -X POST https://agent.tinyfish.ai/v1/automation/run-batch \
  -H "X-API-Key: $TINYFISH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "runs": [
      { "url": "https://scrapeme.live/shop/page/1/", "goal": "List the name and price of every product on this page only." },
      { "url": "https://scrapeme.live/shop/page/2/", "goal": "List the name and price of every product on this page only." },
      { "url": "https://scrapeme.live/shop/page/3/", "goal": "List the name and price of every product on this page only." }
    ]
  }'
```

```json theme={null}
{ "run_ids": ["...", "...", "..."], "error": null }
```

In a batch, `output_schema` goes at the top level of the request and applies to every run. See [Multiple URLs](https://docs.tinyfish.ai/agent/how-to/multiple-urls.md) for more on running work in parallel.

## Retry the parts that didn't finish

Once the work is split, retry only the pieces that need it, and decide based on how the run ended:

| How the run ended | Retry? |
| - | - |
| `COMPLETED` with your schema's `status: "success"` | No. Keep the result |
| `COMPLETED` with `status: "incomplete"` | Yes, as a smaller piece or with a higher `max_steps` |
| `CANCELLED` with `error.category: "BILLING_FAILURE"` | Not as-is. Add credits first |
| `CANCELLED`, anything else | No. Someone stopped it on purpose |
| `FAILED` with `error.category` `SYSTEM_FAILURE` or `UNKNOWN` | Yes. Wait `retry_after` seconds if it's set |
| `FAILED` with `AGENT_FAILURE` or `BILLING_FAILURE` | Not as-is. Change the input or add credits first |

Check `error.category` before treating `CANCELLED` as "stop retrying" — a billing-rejected run also comes back `CANCELLED`, not `FAILED`, and that one still needs credits added. A run you (or someone else) cancelled on purpose is also `CANCELLED`, with `error.category: "UNKNOWN"`, so a retry loop that reads only `status` will restart runs you meant to stop.

**Python**

```py theme={null}
import time
from tinyfish import TinyFish, AgentConfig, RunStatus

client = TinyFish()
DONE = (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED)


def run_page(page: int, attempts: int = 3):
    for attempt in range(attempts):
        queued = client.agent.queue(
            url=f"https://scrapeme.live/shop/page/{page}/",
            goal="List the name and price of every product on this page only.",
            agent_config=AgentConfig(max_steps=10),
        )
        run = client.runs.get(queued.run_id)
        while run.status not in DONE:
            time.sleep(5)
            run = client.runs.get(queued.run_id)

        if run.status == RunStatus.COMPLETED:
            return run
        if run.error and run.error.category == "BILLING_FAILURE":
            return run  # rejected at the billing check (CANCELLED or FAILED); add credits first
        if run.status == RunStatus.CANCELLED:
            return run  # someone stopped it on purpose; don't start it again
        if run.error and run.error.category == "AGENT_FAILURE":
            return run  # the same request will fail the same way

        # SYSTEM_FAILURE or UNKNOWN: wait and try again
        wait = (run.error.retry_after if run.error else None) or 2**attempt * 5
        time.sleep(wait)
    return run


print(run_page(4).status)
```

**TypeScript**

```ts theme={null}
import { TinyFish, RunStatus } from "@tiny-fish/sdk";

const client = new TinyFish();
const DONE: string[] = [RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function runPage(page: number, attempts = 3) {
  let run;
  for (let attempt = 0; attempt < attempts; attempt++) {
    const queued = await client.agent.queue({
      url: `https://scrapeme.live/shop/page/${page}/`,
      goal: "List the name and price of every product on this page only.",
      agent_config: { max_steps: 10 },
    });
    run = await client.runs.get(queued.run_id!);
    while (!DONE.includes(run.status)) {
      await sleep(5_000);
      run = await client.runs.get(queued.run_id!);
    }

    if (run.status === RunStatus.COMPLETED) return run;
    const category = run.error?.category;
    if (category === "BILLING_FAILURE") return run; // rejected at the billing check; add credits first
    if (run.status === RunStatus.CANCELLED) return run; // stopped on purpose; don't start it again
    if (category === "AGENT_FAILURE") return run; // will fail the same way

    // SYSTEM_FAILURE or UNKNOWN: wait and try again
    const wait = run.error?.retry_after ?? 2 ** attempt * 5;
    await sleep(wait * 1000);
  }
  return run;
}

console.log((await runPage(4))?.status);
```

Before you retry, make sure the first run really ended. Requests to `run-batch` aren't idempotent: if a batch request times out on your side and you send it again, you can end up with two copies of every run. Call `runs.list` or `POST /v1/runs/batch` first to see whether the runs exist.

## Common Mistakes

> [accordion] Cancelling a run to "save" what it found so far
>
> A cancelled run returns `result: null`. If you want partial data, let `max_steps` or `max_duration_seconds` stop it, or split the work so each finished piece is already saved.

> [accordion] Starting long runs with run
>
> `run` waits for the result and can't be cancelled. Use `queue` or `stream` for anything that might take more than a minute or two.

> [accordion] Reading the end of a capped run as the data
>
> When a long run hits a limit, the wrap-up says how far it got, but the data fields can be empty. Use it to find where to resume, then get the rest with smaller runs.

> [accordion] Treating max\_duration\_seconds as an exact cutoff
>
> The agent finishes its current step and writes up its result after the limit, so the run ends a bit later. Set the limit a little below the longest you're willing to wait.

> [accordion] Setting max\_steps to exactly what a small test used
>
> A run that hits the cap uses one extra step to wrap up, and the same goal can take more steps on a slower page. Leave headroom.

> [accordion] Retrying every run with an UNKNOWN error
>
> Cancelled runs report `category: "UNKNOWN"`, except a run the billing check rejected, which reports `BILLING_FAILURE`. Check `error.category` before deciding whether to retry.

> [accordion] Re-sending a batch request after a timeout
>
> `run-batch` isn't idempotent. Check whether the runs were created before you send it again.

## Related

> [card] [Troubleshoot Runs](https://docs.tinyfish.ai/agent/how-to/troubleshoot-runs.md)
>
> Fix each finish reason

> [card] [Runs](https://docs.tinyfish.ai/agent/explanation/runs.md)
>
> Run lifecycle, statuses, and finish reasons

> [card] [Runs Reference](https://docs.tinyfish.ai/agent/api/runs.md)
>
> List, get, cancel, batch, and per-step screenshots and HTML

> [card] [Agent Mode, Max Steps, and Max Duration](https://docs.tinyfish.ai/agent/explanation/agent-modes.md)
>
> Default vs Strict mode, step and time limits

> [card] [Run TinyAgent Across Multiple URLs](https://docs.tinyfish.ai/agent/how-to/multiple-urls.md)
>
> Fire the same goal at many URLs at once

> [card] [Error Codes](https://docs.tinyfish.ai/error-codes.md)
>
> Every code and how to handle it


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.