---
title: Research
description: Start an answer that runs on its own, follow its progress, and read or cancel it.
doc_version: 0.1.0-preview
last_updated: 2026-09-14
---

# Research

Start an answer that runs on its own, and read it when it ends. A research run takes the same request as Answer, but no connection stays open while Engine works.

## When to use research

Answer holds the HTTP connection open until the answer is ready. If the client disconnects, Engine cancels the query. Research returns `202` at once with a run ID. Engine then works on the answer by itself and reports each change.

| Operation | Use it when |
| --- | --- |
| [Answer](/engine/guides/answer) | A person waits for the reply, and the question fits in one request. `standard` mode allows 120 seconds. |
| Research | The question can take minutes, as in `ultra` or `recall` mode, which allow 600 seconds. Also use it when your caller cannot hold a connection open, or when you want to show progress. |

Both operations take the same body, run the same checks, and have the same time limits. An image in `query_image` adds 45 seconds. Both need the key’s `query` action.

## Before you begin

Use a collection with ready files and a key with `query` access. Check `query.modes` in `/info` for the modes that your cluster supports. `recall`, `web_search: "use_if_needed"`, and `query_image` need a cluster key.

To follow runs with events, ask the cluster operator to add `research.updated` and `research.progress` to a webhook. See [Handle jobs and events](/engine/guides/jobs-and-events). Without a webhook, read the run with GET.

## Start a run

**POST https://engine.example.com/namespaces/acme/collections/incident-logs/research**

```http
POST /namespaces/acme/collections/incident-logs/research HTTP/1.1
Host: engine.example.com
Authorization: Bearer <GRAPHON_ENGINE_API_KEY>
Content-Type: application/json
Idempotency-Key: checkout-pool-research-1

{
  "query": "Find every incident in which checkout ran out of database connections.",
  "mode": "ultra",
  "return_sources": true
}
```

**202 Accepted**

```json
{
  "id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
  "namespace": "acme",
  "collection": "incident-logs",
  "status": "queued",
  "mode": "ultra",
  "progress": null,
  "answer": null,
  "sources": null,
  "image_description": null,
  "usage": null,
  "error": null,
  "created_at": "2026-10-06T12:02:00Z",
  "updated_at": "2026-10-06T12:02:00Z",
  "completed_at": null
}
```

Save `id`. It starts with `rsr_`. You need it to read or cancel the run. The run starts as `queued`. A field that the run has not set yet is null.

`Idempotency-Key` is required. A repeat with the same key and body returns the same run in its current state. A different body with the same key returns `409 idempotency_conflict`. Engine keeps the key for 24 hours. Use a new key for each new run.

Engine checks the request and the collection before it creates the run. A failed check returns its error at once, and Engine creates no run. See [Handle errors](#handle-errors).

## Follow the run

| Status | Meaning |
| --- | --- |
| `queued` | Engine accepted the run. The run has not started. |
| `running` | Engine is working on the answer. `progress` holds the last stage. |
| `succeeded` | The answer is ready. |
| `failed` | The run ended with an error. Read `error`. |
| `canceled` | The run stopped after a cancel. |

`succeeded`, `failed`, and `canceled` are final. A run in a final status does not change again.

Engine sends `research.updated` when it creates the run and at each status change. The first event has `data.previous_status: null`. Engine sends `research.progress` each time `progress` changes. Each research event names the run in `research_id`. Its `job_id` and `file_id` are null.

**Example webhook body**

```json
{
  "events": [
    {
      "id": "evt_01J8Z3K4N5P6Q7R8S9T0V1W2X4",
      "type": "research.progress",
      "occurred_at": "2026-10-06T12:04:00Z",
      "cluster": "graphon-engine",
      "namespace": "acme",
      "collection": "incident-logs",
      "job_id": null,
      "file_id": null,
      "research_id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "data": {
        "stage": "synthesizing",
        "label": "Synthesizing answer",
        "step": 3,
        "total_steps": 3
      }
    },
    {
      "id": "evt_01J8Z3K4N5P6Q7R8S9T0V1W2X5",
      "type": "research.updated",
      "occurred_at": "2026-10-06T12:05:00Z",
      "cluster": "graphon-engine",
      "namespace": "acme",
      "collection": "incident-logs",
      "job_id": null,
      "file_id": null,
      "research_id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "data": {
        "status": "succeeded",
        "previous_status": "running",
        "mode": "ultra"
      }
    }
  ]
}
```

A `research.progress` event has `stage`, `label`, `step`, and `total_steps` in `data`. Show `label` to people, and use `stage` in code. A `research.updated` event has `status`, `previous_status`, and `mode`, and `error_code` when the status is `failed`. Deduplicate each event by its `id`.

Each webhook has its own delivery order. A separate webhook for research events keeps them from waiting behind file events.

## Read the run

Read the run at any time with GET. Use it when you have no webhook, after a missed event, or to load the answer after `research.updated` reports `succeeded`.

**GET https://engine.example.com/namespaces/acme/collections/incident-logs/research/rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3**

```http
GET /namespaces/acme/collections/incident-logs/research/rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3 HTTP/1.1
Host: engine.example.com
Authorization: Bearer <GRAPHON_ENGINE_API_KEY>
```

**200 OK · running**

```json
{
  "id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
  "namespace": "acme",
  "collection": "incident-logs",
  "status": "running",
  "mode": "ultra",
  "progress": {
    "stage": "exploring",
    "label": "Exploring your files",
    "step": 2,
    "total_steps": 3
  },
  "answer": null,
  "sources": null,
  "image_description": null,
  "usage": null,
  "error": null,
  "created_at": "2026-10-06T12:02:00Z",
  "updated_at": "2026-10-06T12:03:00Z",
  "completed_at": null
}
```

If you poll, wait a few seconds between reads. Engine checks the stage of a running run every 5 seconds, so faster reads return the same `progress`.

Only the key that started the run, or a cluster key, can read or cancel it. Another key gets `404 not_found`, as for a run that does not exist. The key must also reach the collection with the `query` action.

## Read the answer and sources

**200 OK · succeeded**

```json
{
  "id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
  "namespace": "acme",
  "collection": "incident-logs",
  "status": "succeeded",
  "mode": "ultra",
  "progress": {
    "stage": "synthesizing",
    "label": "Synthesizing answer",
    "step": 3,
    "total_steps": 3
  },
  "answer": "One incident matches: checkout exhausted its connection pool [[SRC:0001]].",
  "sources": {
    "[SRC:0001]": {
      "file_id": "fil_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
      "path": "/incidents/checkout.txt",
      "score": 0.81,
      "is_cited": true,
      "evidence": {
        "kind": "text",
        "text": "Checkout stalled because the service exhausted its connection pool.",
        "location": {
          "char_start": 0,
          "char_end": 67
        }
      }
    }
  },
  "image_description": null,
  "usage": {
    "credits": 4
  },
  "error": null,
  "created_at": "2026-10-06T12:02:00Z",
  "updated_at": "2026-10-06T12:05:00Z",
  "completed_at": "2026-10-06T12:05:00Z"
}
```

When the status is `succeeded`, the run holds `answer`, `sources`, `image_description`, and `usage`. These fields have the same shape as in an Answer response. `usage.credits` is the final credit count for the run. `completed_at` is the time the run ended.

Render citations as for Answer. `[[SRC:0001]]` in `answer` maps to the `[SRC:0001]` key in `sources`. See [Render citations](/engine/guides/answer#render-citations). `sources` is null when the request set `return_sources: false`.

The sources include only files that are still in the collection when the run ends. Engine removes a source whose file is gone, together with its markers.

## Cancel a run

**POST https://engine.example.com/namespaces/acme/collections/incident-logs/research/rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3:cancel**

```http
POST /namespaces/acme/collections/incident-logs/research/rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3:cancel HTTP/1.1
Host: engine.example.com
Authorization: Bearer <GRAPHON_ENGINE_API_KEY>
```

**202 Accepted · cancel requested**

```json
{
  "id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
  "namespace": "acme",
  "collection": "incident-logs",
  "status": "running",
  "mode": "ultra",
  "progress": {
    "stage": "exploring",
    "label": "Exploring your files",
    "step": 2,
    "total_steps": 3
  },
  "answer": null,
  "sources": null,
  "image_description": null,
  "usage": null,
  "error": null,
  "created_at": "2026-10-06T12:02:00Z",
  "updated_at": "2026-10-06T12:03:00Z",
  "completed_at": null
}
```

A `queued` run becomes `canceled` at once. A `running` run can still show `running` in the response. It becomes `canceled` when Engine has stopped its work. Engine does not bill a canceled run.

Wait for `research.updated` with `data.status: "canceled"`, or read the run. A cancel of a run that already ended returns the run unchanged.

## Handle errors

A request error returns at once, and Engine creates no run. The checks are the same as for Answer.

| Error | Cause |
| --- | --- |
| `400 invalid_request` | A field is unknown or invalid, the mode is not available, or `principals` does not match the collection’s `access_control`. |
| `400 invalid_filter` | The `filter` has an unsupported field, operator, or value. |
| `403 forbidden` | The key does not have the `query` action. Or a key that is not a cluster key sent `recall`, `web_search: "use_if_needed"`, or `query_image`. |
| `404 not_found` | The key cannot see the collection, or the collection is being deleted. |
| `409 collection_empty` | The collection has no files. |
| `409 collection_not_ready` | No content is ready. When `retryable` is true, wait for processing. When it is false, correct the failed files first. |
| `409 idempotency_conflict` | The `Idempotency-Key` was used before with a different body. |
| `413 request_too_large` | The body is larger than 6,291,456 bytes. |
| `503 temporarily_unavailable` | A dependency is not available. Retry with the same `Idempotency-Key`. |

A run that starts can still end `failed`. Its `error` then holds `code`, `message`, and `retryable`. The `research.updated` event holds the code in `data.error_code`.

**200 OK · failed**

```json
{
  "id": "rsr_01J8Z3K4N5P6Q7R8S9T0V1W2X3",
  "namespace": "acme",
  "collection": "incident-logs",
  "status": "failed",
  "mode": "ultra",
  "progress": {
    "stage": "exploring",
    "label": "Exploring your files",
    "step": 2,
    "total_steps": 3
  },
  "answer": null,
  "sources": null,
  "image_description": null,
  "usage": null,
  "error": {
    "code": "temporarily_unavailable",
    "message": "The research ran longer than 600 seconds. It is not billed. Start it again.",
    "retryable": true
  },
  "created_at": "2026-10-06T12:02:00Z",
  "updated_at": "2026-10-06T12:12:00Z",
  "completed_at": "2026-10-06T12:12:00Z"
}
```

A run that passes its time limit ends `failed` with `temporarily_unavailable`, and Engine does not bill it. A run that the cluster stops, for example during a deploy, ends the same way. When `retryable` is true, start a new run with a new `Idempotency-Key`. The same key returns the same failed run.

## Retention

Engine keeps a run for 7 days after it ends. A daily cleanup then deletes it, and a read returns `404 not_found`. Store the answer and sources in your application if you need them for longer.

See [Create research](/engine/reference/create-research), [Get research](/engine/reference/get-research), and [Cancel research](/engine/reference/cancel-research) for every field and error.
## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
