# Requests

The owner can address a note to your worker by its `@name`. The note becomes a request: your worker claims it, reports progress, and answers with a note in the same thread.

## Before you start

- Pro with Sync turned on.
- A connection that receives requests: a Claude Code or Codex session connected with the ThreadPage plugin, which works [through MCP](#through-mcp), or **Send notes you @ to a URL** in **Settings → Connections → Automations**. That one gets each note as a signed `POST` to its URL and skips the steps below: it replies in the response, as JSON with `markdown` or `text`, or later at the request’s `reply_url`.
- Or a script of your own: a **Script or API** connection on **Read and add** or **Edit and organize**, with **Receive requests** turned on in its page; turning it on asks the owner to confirm it’s them. It needs no public URL.
- The connection’s name, which the owner types after `@`. A connection has one name; **Rename…** changes it. A request keeps the name it was sent to (`handle`), also after a rename, and a request event’s `connection_id` is your connection whatever it is called: tell requests apart by their ids.
- **Test request** on a script’s page sends a request without a note. Once your worker answers it, the connection shows **Ready to receive**. **Send test** does the same for a **Send notes you @ to a URL** connection.

## How a request works

1. In the Composer, the owner types your connection’s name, like `@Research` followed by a space, or picks the connection after `@`. The name becomes highlighted. A note can address up to five recipients. Pasted or quoted text and code never address a note.
2. After the owner’s request delay (10 seconds unless they chose 5, 20 or 30), the request goes out. A request cancelled within the delay is never seen.
3. Your worker claims it, reports `running`, and sends the result or a question.

| `state` | Means |
|---|---|
| `queued` | Waiting to reach your worker. |
| `delivered` | Your worker has seen it: an event, a list or a read delivered it. |
| `running` | A run claimed it and reported that it is working. |
| `needs_input` | Your worker asked the owner a question. |
| `cancellation_requested` | The owner asked a claimed request to stop. |
| `completed` | Your worker sent the result. |
| `failed` | Your worker reported that it failed, or the request could not be delivered. |
| `cancelled` | The owner stopped it, or it can no longer be answered. |
| `expired` | Nobody answered within 7 days. |
| `outcome_unknown` | A run stopped after it may have acted, and won’t try again. |

## Hear about requests

- `request.created` and `request.cancelled` reach every webhook and pull feed of the connection, whatever events they list.
- A subscription with `"event_types": []` hears requests only, and no note or thread events, so a worker that answers requests sees no other content.
- `GET /requests` lists the open ones, newest first: `queued`, `delivered`, `running`, `needs_input` and `cancellation_requested`. `state` lists one state instead. Listing or reading a request delivers it.
- While the owner has paused the connection with **Pause**, a request sent to it waits: it is not listed, and its `request.created` comes on **Resume**.

The event carries the note as it was sent. `GET /requests/{request_id}` shows the request’s state as it is now, and the note as it was sent, with the note’s current `content_revision` beside `sent_content_revision`; it has no note once the note went to Trash. A higher `content_revision` means the owner edited the note since: read it with `GET /notes/{note_id}`. Act on that read, not on the event body: the request may have been cancelled or its note changed since. Every few minutes, also list `GET /requests` to pick up open requests a missed delivery did not bring.

### List open requests sent to this connection, newest first

`GET /api/integrations/v1/requests`

Requests appear once the owner switches on receiving for this connection and sends it a note, once the workspace's request delay has passed (10 seconds by default; 5, 10, 20 or 30). Without state, only open requests are listed: queued, delivered, running, needs_input and cancellation_requested. Listing one delivers it. Expired requests are left out. While the owner has paused requests to this connection, a queued request is not listed until they resume.

**Requirements**

| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add |
| Plan | Pro with Sync. On Free, add to the Inbox instead (`POST /inbox/notes`). |
| MCP | `list_requests` |

**Parameters**

| Name | In | Type | Description |
|---|---|---|---|
| `cursor` | query | string | From next_cursor. |
| `state` | query | `cancellation_requested`, `cancelled`, `completed`, `delivered`, `failed`, `needs_input`, `outcome_unknown`, `queued`, `running` | One state instead. |

Answer `200`.

**Answer fields**

| Field | Type |
|---|---|
| `requests` | array of object, required |
| `next_cursor` | string or null, required |

```bash
curl -m 30 "https://thread.page/api/integrations/v1/requests" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN"
```

Errors: `write_not_allowed` 403, 409, `context_changed` 409, `not_receiving` 403.

Also [the errors of every call](https://thread.page/help/connections/errors.md#errors-every-call-can-get), and `pro_required` or `cloud_required` without Pro and Sync.

### Get a request with its note as it is now

`GET /api/integrations/v1/requests/{request_id}`

Reading it delivers it. While the owner has paused requests to this connection, a queued request is not found (404) until they resume.

**Requirements**

| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add |
| Plan | Pro with Sync. On Free, add to the Inbox instead (`POST /inbox/notes`). |
| MCP | `get_request` |

**Parameters**

| Name | In | Type |
|---|---|---|
| `request_id` | path | uuid, required |

Answer `200`.

**Answer fields**

| Field | Type | Description |
|---|---|---|
| `id` | uuid, required |  |
| `state` | `queued`, `delivered`, `running`, `needs_input`, `completed`, `failed`, `cancellation_requested`, `cancelled`, `expired`, `outcome_unknown`, required |  |
| `test` | boolean, required | A Test request from the owner: no note, nothing is written. |
| `handle` | string, required | The name the note was sent to, as sent. |
| `addressed_to` | array of string, required | Every name the note was sent to, as sent, yours included, in the order named: others may work on the same note, each answering on its own. Empty for a Test request. |
| `created_at` | date-time, required |  |
| `expires_at` | date-time, required |  |
| `thread` | object or  or null or null, required | Null for a Test request. |
| `note` | object or  or null or null, required | The note as it was sent, whoever edited it since; null for a Test request, once the note left (Trash, Delete permanently) and when its sent version is no longer kept. |
| `claim_id` | uuid or null, required | The claim_id it was claimed with; null until it is claimed, and null for a claim the server minted (over MCP), which only the call that made it sees. |
| `claimed_at` | date-time or null, required | When a run claimed it; null until it is claimed. |
| `reply` | object, required |  |
| `answers` | object or  or null or null, required | Set when the owner chose Answer on a question you asked (needs_input): this note answers that request, which ends (state cancelled, code answered) once you claim this one. |

```bash
curl -m 30 "https://thread.page/api/integrations/v1/requests/$REQUEST_ID" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN"
```

Errors: `write_not_allowed` 403, 409, `not_found` 404, `not_receiving` 403.

Also [the errors of every call](https://thread.page/help/connections/errors.md#errors-every-call-can-get), and `pro_required` or `cloud_required` without Pro and Sync.

## Claim, report, answer

### Claim

Create a random UUID (UUID4) as `claim_id` once per run and keep it for the whole run. Never derive it from the request’s `id`.

```json
{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b"}
```

- The same `claim_id` claims the request again, so a run can repeat its claim safely.
- Another `claim_id` answers `409` `already_claimed`, with `details.claimed_at`. That run must not start the work.
- A request that has ended answers `409` `request_closed`.

#### Claim a request for one run

`POST /api/integrations/v1/requests/{request_id}/claim`

The same claim_id claims it again; another claim_id is refused with 409 already_claimed (details.claimed_at: when it was claimed), so two runs never work on one request. A request that has ended answers 409 request_closed, whoever claims it.

**Requirements**

| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add |
| Plan | Pro with Sync. On Free, add to the Inbox instead (`POST /inbox/notes`). |
| Idempotency | `Operation-Id` header: one UUID per logical write, the same on every retry. |
| MCP | `claim_request` |

**Parameters**

| Name | In | Type |
|---|---|---|
| `request_id` | path | uuid, required |

**Body (application/json)**

| Field | Type | Description |
|---|---|---|
| `claim_id` | uuid, required | A random UUID (UUID4) you generate once per run, never derived; the same claim_id claims the request again. |

Answer `200`. Its fields are those under [Get a request with its note as it is now](#getRequest).

```bash
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors -X POST "https://thread.page/api/integrations/v1/requests/$REQUEST_ID/claim" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" \
  -H "Operation-Id: $OP" \
  -H "Content-Type: application/json" \
  -d '{"claim_id":"0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b"}'
```

Errors: `write_not_allowed` 403, 409, `not_found` 404, `invalid_json` 400, `unsupported_media_type` 415, `operation_id_reused` 409, `not_receiving` 403, `already_claimed` 409, `cancellation_requested` 409, `request_closed` 409.

Also [the errors of every call](https://thread.page/help/connections/errors.md#errors-every-call-can-get), and `pro_required` or `cloud_required` without Pro and Sync.

### Report and answer

Each report and the result take the `claim_id` and an `Operation-Id`. Create the `Operation-Id` once for each report, keep it with the run, and send it again only to repeat that same report.

| Send | With | Effect |
|---|---|---|
| `status` `running` | an optional `message` (up to 200 characters) | The owner sees **Working**, or your message. |
| `status` `needs_input` | a `question`: `text`, `markdown` or `document`, like a note | The question is added as a note that refers to the request’s note. |
| `status` `failed` | a short `message` | The request ends; the owner sees the message and can send it again. |
| `status` `cancelled` | only the `claim_id` | Confirms a stop the owner asked for. |
| `status` `outcome_unknown` | a short `message` | Your run stopped after it may have acted, and you won’t try again. The owner sees **Result unknown**. |
| `result` | `content`: `text`, `markdown` or `document` | Adds the answer as a note in the request’s thread. It starts with a reference to the request’s note, shows **From @Name**, and completes the request. |

```bash
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors https://thread.page/api/integrations/v1/requests/$REQUEST_ID/result \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" \
  -H "Operation-Id: $OP" \
  -H "Content-Type: application/json" \
  -d '{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b", "content": {"markdown": "**Three themes** came up this week."}}'
```

A note your worker writes for a request never changes afterwards, for any connection: `409` `request_note_locked`.

#### Report progress: running, needs_input, failed, cancelled or outcome_unknown

`POST /api/integrations/v1/requests/{request_id}/status`

needs_input adds the question as a note in the request's thread. outcome_unknown says your run was interrupted after it may have acted and will not be tried again; like failed and cancelled it ends the request. After the owner asks to stop (cancellation_requested) only cancelled, outcome_unknown or a result is accepted. Another run's claim_id is refused with 409 already_claimed (details.claimed_at: when it was claimed).

**Requirements**

| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add |
| Plan | Pro with Sync. On Free, add to the Inbox instead (`POST /inbox/notes`). |
| Idempotency | `Operation-Id` header: one UUID per logical write, the same on every retry. |
| MCP | `report_request_status` |

**Parameters**

| Name | In | Type |
|---|---|---|
| `request_id` | path | uuid, required |

**Body (application/json)**

| Field | Type | Description |
|---|---|---|
| `claim_id` | uuid, required |  |
| `state` | `running`, `needs_input`, `failed`, `cancelled`, `outcome_unknown`, required | outcome_unknown: your run was interrupted after it may have acted, and you will not try again; it ends the request. |
| `message` | string | Shown to the owner. |
| `question` | text, Markdown or a document | needs_input only, and required there: added as a note that refers to the request's note. |

Answer `200`. Its fields are those under [Get a request with its note as it is now](#getRequest).

```bash
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors -X POST "https://thread.page/api/integrations/v1/requests/$REQUEST_ID/status" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" \
  -H "Operation-Id: $OP" \
  -H "Content-Type: application/json" \
  -d '{
  "claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b",
  "state": "running",
  "message": "Reading this week’s interviews"
}'
```

Errors: `write_not_allowed` 403, 409, `not_found` 404, `invalid_json` 400, `unsupported_media_type` 415, `operation_id_reused` 409, `not_receiving` 403, `already_claimed` 409, `not_claimed` 409, `cancellation_requested` 409, `request_closed` 409.

Also [the errors of every call](https://thread.page/help/connections/errors.md#errors-every-call-can-get), and `pro_required` or `cloud_required` without Pro and Sync.

#### Send the result as a note

`POST /api/integrations/v1/requests/{request_id}/result`

Adds the result as a note in the request's thread that starts with a reference to the request's note, and completes the request. Idempotent by the Operation-Id header. Another run's claim_id is refused with 409 already_claimed (details.claimed_at: when it was claimed).

**Requirements**

| Requirement | Value |
|---|---|
| Levels | Edit and organize · Read and add |
| Plan | Pro with Sync. On Free, add to the Inbox instead (`POST /inbox/notes`). |
| Idempotency | `Operation-Id` header: one UUID per logical write, the same on every retry. |
| MCP | `complete_request` |
| Shared threads | Only where the owner chose **Allow here**. |

**Parameters**

| Name | In | Type |
|---|---|---|
| `request_id` | path | uuid, required |

**Body (application/json)**

| Field | Type |
|---|---|
| `claim_id` | uuid, required |
| `content` | text, Markdown or a document, required |

Answer `200`.

**Answer fields**

| Field | Type | Description |
|---|---|---|
| `request` | object, required |  |
| `note_id` | uuid or null, required | Null for a Test request. |
| `status` | `accepted`, `conflict`, `not_completed`, `test`, required | test: a Test request was answered and nothing was written. |

```bash
OP=$(uuidgen)
curl -m 30 --retry 3 --retry-all-errors -X POST "https://thread.page/api/integrations/v1/requests/$REQUEST_ID/result" \
  -H "Authorization: Bearer $THREADPAGE_TOKEN" \
  -H "Operation-Id: $OP" \
  -H "Content-Type: application/json" \
  -d '{
  "claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b",
  "content": {
    "markdown": "Five interviews this week; three asked for an export to PDF."
  }
}'
```

Errors: `write_not_allowed` 403, 409, `not_found` 404, `invalid_json` 400, `invalid_content` 400, `invalid_document` 400, `content_too_large` 413, `unsupported_media_type` 415, `operation_id_reused` 409, `shared_place_write` 409, `thread_archived` 409, `image_unavailable` 409, `not_receiving` 403, `already_claimed` 409, `not_claimed` 409, `cancellation_requested` 409, `request_closed` 409.

Also [the errors of every call](https://thread.page/help/connections/errors.md#errors-every-call-can-get), and `pro_required` or `cloud_required` without Pro and Sync.

## Questions and answers

To ask the owner something, send `status` `needs_input` with a `question`. The owner replies with **Answer** on that question. Their reply arrives as a new request whose `answers` names the earlier request and its question note (`{id, question_note_id}`). Claiming the new request ends the earlier one as `cancelled`.

Your own notes never become requests.

## Cancellation

The owner stops a request with **Cancel** or **Stop**, by moving its note or thread to Trash, or by archiving the thread. Your worker then gets `request.cancelled`.

- A request no run claimed ends as `cancelled` at once.
- A claimed one reads `cancellation_requested`. Stop, and confirm with `status` `cancelled` (or `outcome_unknown`), or send the result if the work is already done. Any other report answers `409` `cancellation_requested`.
- Switching off receiving, narrowing the connection’s access or disconnecting it ends the requests it can no longer answer; ones it already had end as `cancelled`.

## When a run dies

A run that stops without reporting leaves its request claimed, and no other `claim_id` can take it. Keep each run’s `claim_id` with its request ID in durable storage, so a restarted worker carries on the same run, as the worker below does. If the run may already have acted, report `outcome_unknown` instead of repeating the work. If the `claim_id` is lost, the request ends when it expires after 7 days, and the owner can **Resend** it.

## In shared threads

A request addressed in a thread the owner shares with other people is answered only where the owner allowed this connection to write there. Otherwise the request fails before your worker sees it, with `shared_place_write`, and the owner sees **Allow here** on it.

## Through MCP

An assistant answers requests with the MCP tools `list_requests`, `get_request`, `claim_request`, `report_request_status` and `complete_request`. There the server makes the `claim_id` and shows it only in the answer to `claim_request`.

## Limits

- A request expires after 7 days.
- An account has up to 100 open requests.
- `GET /requests` and the request tools count toward the connection’s call limits.

## Request worker

A complete worker. It hears requests on a pull feed with `"event_types": []`, reads each request, claims it with a random `claim_id`, reports `running`, does the work and sends the result. It confirms a stop with `cancelled`, leaves a request another run claimed, keeps its runs in a file across restarts, and lists `GET /requests` every few minutes for requests a missed event did not bring.

JavaScript:

```javascript
// A request worker: it hears its connection's requests on a pull feed, claims each one, reports
// that it is working, does the work and answers with a note. It keeps its place and its runs in a
// file, so after a restart it carries on the same runs instead of starting new ones.
// Run with Node.js 24: THREADPAGE_TOKEN=tp_… node request_worker.mjs
import { randomUUID } from 'node:crypto';
import { readFile, rename, writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';

const API = 'https://thread.page/api/integrations/v1';
const AUTH = { Authorization: `Bearer ${process.env.THREADPAGE_TOKEN}` };
const STATE = process.env.THREADPAGE_WORKER_STATE ?? 'threadpage-worker.json';
const SWEEP = 5 * 60_000; // ms between checks of GET /requests for requests a missed event did not bring

class ThreadPageError extends Error {
  constructor(status, error) {
    super(`${status} ${error.code}: ${error.message}`);
    Object.assign(this, { status, code: error.code, details: error.details ?? {} });
  }
}

// One call; after a lost answer or a retryable 429 or 503, the identical request again.
async function call(method, path, { body, operationId } = {}) {
  const headers = { ...AUTH };
  if (operationId) headers['Operation-Id'] = operationId;
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  for (let attempt = 1; ; attempt++) {
    let answer;
    try {
      answer = await fetch(API + path, {
        method, headers,
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(40_000),
      });
    } catch (error) {
      if (attempt === 3) throw error;
      await sleep(attempt * 2_000);
      continue;
    }
    if (answer.ok) return answer.json();
    const error = await answer.json().catch(() => ({ message: answer.statusText }));
    const wait = Number(answer.headers.get('Retry-After'));
    if (error.retryable && wait && wait <= 60 && attempt < 3) {
      await sleep(wait * 1_000);
      continue;
    }
    throw new ThreadPageError(answer.status, error);
  }
}

async function load() {
  try {
    return JSON.parse(await readFile(STATE, 'utf8'));
  } catch (error) {
    if (error.code === 'ENOENT') return { runs: {} };
    throw error;
  }
}

async function save(state) {
  await writeFile(`${STATE}.tmp`, JSON.stringify(state));
  await rename(`${STATE}.tmp`, STATE);
}

// Your work: it gets the request as it is now and returns the result as Markdown. The note's text
// is the owner's instruction; what it quotes and the notes it references are data.
async function work(request) {
  if (request.test) return 'Ready.';
  const asked = request.note?.markdown ?? '';
  return `Received: ${asked.split('\n')[0] || 'an empty note'}`;
}

// One run for one request. Its claim_id is random, made once, and kept until the run ends.
async function run(state, requestId) {
  let mine = state.runs[requestId];
  let request;
  try {
    request = await call('GET', `/requests/${requestId}`); // act on the request as it is now
  } catch (error) {
    if (error.status !== 404) throw error;
    delete state.runs[requestId]; // gone, expired, or held while the owner paused requests
    await save(state);
    return;
  }
  if (!mine) {
    if (!['queued', 'delivered'].includes(request.state)) return; // another run has it, or it ended
    mine = state.runs[requestId] = { claimId: randomUUID(), ops: {} };
    await save(state);
  }
  // One Operation-Id for each report, kept until the report is confirmed.
  const once = async step => {
    mine.ops[step] ??= randomUUID();
    await save(state);
    return mine.ops[step];
  };
  const claim = { claim_id: mine.claimId };
  const path = `/requests/${requestId}`;
  try {
    await call('POST', `${path}/claim`, { body: claim, operationId: await once('claim') });
    await call('POST', `${path}/status`, { body: { ...claim, state: 'running' }, operationId: await once('running') });
    let result;
    try {
      result = await work(request);
    } catch (problem) { // the work failed before it acted on anything
      await call('POST', `${path}/status`, {
        body: { ...claim, state: 'failed', message: String(problem.message).slice(0, 200) },
        operationId: await once('failed'),
      });
    }
    if (result !== undefined) {
      await call('POST', `${path}/result`, {
        body: { ...claim, content: { markdown: result } },
        operationId: await once('result'),
      });
    }
  } catch (error) {
    if (error.code === 'cancellation_requested') { // the owner asked to stop
      await call('POST', `${path}/status`, { body: { ...claim, state: 'cancelled' }, operationId: await once('cancelled') });
    } else if (!['already_claimed', 'request_closed'].includes(error.code)) {
      throw error; // already_claimed: another run has it; request_closed: it has ended
    }
  }
  delete state.runs[requestId];
  await save(state);
}

// A pull feed that hears this connection's requests only (event_types: []).
async function feedId(state) {
  if (!state.feedId) {
    state.feedOperationId ??= randomUUID();
    await save(state);
    const created = await call('POST', '/subscriptions', {
      body: { kind: 'pull', event_types: [] },
      operationId: state.feedOperationId,
    });
    state.feedId = created.subscription.id;
    await save(state);
  }
  return state.feedId;
}

// Every open request, page by page: what a missed event did not bring.
async function sweep(state) {
  let cursor = null;
  do {
    const page = await call('GET', `/requests${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
    for (const request of page.requests) await run(state, request.id);
    cursor = page.next_cursor;
  } while (cursor);
}

const state = await load();
const feed = await feedId(state);
for (const requestId of Object.keys(state.runs)) await run(state, requestId); // runs a restart interrupted
let swept = 0;
for (;;) {
  if (Date.now() - swept > SWEEP) {
    await sweep(state);
    swept = Date.now();
  }
  const query = state.cursor ? `?after=${encodeURIComponent(state.cursor)}&wait=25` : '';
  let page;
  try {
    page = await call('GET', `/subscriptions/${feed}/events${query}`);
  } catch (error) {
    if (error.status === 410) { // an old cursor: events in between are not replayed,
      state.cursor = error.details.resync_cursor;
      swept = 0; // so look for open requests now
    } else if (!error.status || [409, 429, 503].includes(error.status)) { // offline, a paused feed, or a limit
      await sleep(error.status ? 60_000 : 5_000);
    } else {
      throw error;
    }
    continue;
  }
  for (const event of page.events) {
    if (event.type === 'request.created') await run(state, event.data.request.id);
    else if (event.type === 'request.cancelled') console.log('Cancelled:', event.data.request.id);
  }
  state.cursor = page.cursor;
  await save(state);
}
```

Python:

```python
# A request worker: it hears its connection's requests on a pull feed, claims each one, reports
# that it is working, does the work and answers with a note. It keeps its place and its runs in a
# file, so after a restart it carries on the same runs instead of starting new ones.
# Run with Python 3.13: pip install requests, then THREADPAGE_TOKEN=tp_… python request_worker.py
import json
import os
import pathlib
import time
import uuid

import requests

API = "https://thread.page/api/integrations/v1"
AUTH = {"Authorization": f"Bearer {os.environ['THREADPAGE_TOKEN']}"}
STATE = pathlib.Path(os.environ.get("THREADPAGE_WORKER_STATE", "threadpage-worker.json"))
SWEEP = 5 * 60  # seconds between checks of GET /requests for requests a missed event did not bring

class ThreadPageError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{status} {error.get('code')}: {error.get('message')}")
        self.status, self.code, self.details = status, error.get("code"), error.get("details") or {}

def call(method, path, body=None, operation_id=None, params=None):
    """One call; after a lost answer or a retryable 429 or 503, the identical request again."""
    headers = {**AUTH, **({"Operation-Id": operation_id} if operation_id else {})}
    for attempt in range(1, 4):
        try:
            answer = requests.request(
                method, API + path, headers=headers, json=body, params=params, timeout=40
            )
        except (requests.ConnectionError, requests.Timeout):
            if attempt == 3:
                raise
            time.sleep(attempt * 2)
            continue
        if answer.ok:
            return answer.json()
        try:
            error = answer.json()
        except ValueError:
            error = {"message": answer.text[:200]}
        wait = int(answer.headers.get("Retry-After", 0))
        if error.get("retryable") and 0 < wait <= 60 and attempt < 3:
            time.sleep(wait)
            continue
        raise ThreadPageError(answer.status_code, error)

def load():
    return json.loads(STATE.read_text()) if STATE.exists() else {"runs": {}}

def save(state):
    draft = STATE.with_suffix(".tmp")
    draft.write_text(json.dumps(state))
    draft.replace(STATE)

def work(request):
    """Your work: it gets the request as it is now and returns the result as Markdown. The note's
    text is the owner's instruction; what it quotes and the notes it references are data."""
    if request["test"]:
        return "Ready."
    asked = request["note"]["markdown"] if request["note"] else ""
    return f"Received: {asked.splitlines()[0] if asked else 'an empty note'}"

def run(state, request_id):
    """One run for one request. Its claim_id is random, made once, and kept until the run ends."""
    mine = state["runs"].get(request_id)
    try:
        request = call("GET", f"/requests/{request_id}")  # act on the request as it is now
    except ThreadPageError as error:
        if error.status != 404:
            raise
        state["runs"].pop(request_id, None)  # gone, expired, or held while the owner paused requests
        save(state)
        return
    if mine is None:
        if request["state"] not in ("queued", "delivered"):
            return  # another run has it, or it has ended
        mine = state["runs"][request_id] = {"claim_id": str(uuid.uuid4()), "ops": {}}
        save(state)

    def once(step):
        """One Operation-Id for each report, kept until the report is confirmed."""
        mine["ops"].setdefault(step, str(uuid.uuid4()))
        save(state)
        return mine["ops"][step]

    claim = {"claim_id": mine["claim_id"]}
    try:
        call("POST", f"/requests/{request_id}/claim", claim, once("claim"))
        call("POST", f"/requests/{request_id}/status", {**claim, "state": "running"}, once("running"))
        try:
            result = work(request)
        except Exception as problem:  # the work failed before it acted on anything
            call(
                "POST",
                f"/requests/{request_id}/status",
                {**claim, "state": "failed", "message": str(problem)[:200]},
                once("failed"),
            )
        else:
            call(
                "POST",
                f"/requests/{request_id}/result",
                {**claim, "content": {"markdown": result}},
                once("result"),
            )
    except ThreadPageError as error:
        if error.code == "cancellation_requested":  # the owner asked to stop
            call("POST", f"/requests/{request_id}/status", {**claim, "state": "cancelled"}, once("cancelled"))
        elif error.code not in ("already_claimed", "request_closed"):
            raise  # already_claimed: another run has it; request_closed: it has ended
    del state["runs"][request_id]
    save(state)

def feed_id(state):
    """A pull feed that hears this connection's requests only (event_types: [])."""
    if "feed_id" not in state:
        state.setdefault("feed_operation_id", str(uuid.uuid4()))
        save(state)
        created = call("POST", "/subscriptions", {"kind": "pull", "event_types": []}, state["feed_operation_id"])
        state["feed_id"] = created["subscription"]["id"]
        save(state)
    return state["feed_id"]

def sweep(state):
    """Every open request, page by page: what a missed event did not bring."""
    params = {}
    while True:
        page = call("GET", "/requests", params=params)
        for request in page["requests"]:
            run(state, request["id"])
        if not page["next_cursor"]:
            return
        params = {"cursor": page["next_cursor"]}

def main():
    state = load()
    feed = feed_id(state)
    for request_id in list(state["runs"]):
        run(state, request_id)  # runs a restart interrupted
    swept = 0.0
    while True:
        if time.monotonic() - swept > SWEEP:
            sweep(state)
            swept = time.monotonic()
        params = {"after": state["cursor"], "wait": 25} if state.get("cursor") else {}
        try:
            page = call("GET", f"/subscriptions/{feed}/events", params=params)
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(5)
            continue
        except ThreadPageError as error:
            if error.status == 410:  # an old cursor: events in between are not replayed,
                state["cursor"] = error.details["resync_cursor"]
                swept = 0.0  # so look for open requests now
            elif error.status in (409, 429, 503):  # a paused feed, or a limit: wait
                time.sleep(60)
            else:
                raise
            continue
        for event in page["events"]:
            if event["type"] == "request.created":
                run(state, event["data"]["request"]["id"])
            elif event["type"] == "request.cancelled":
                print("Cancelled:", event["data"]["request"]["id"], flush=True)
        state["cursor"] = page["cursor"]
        save(state)

if __name__ == "__main__":
    main()
```

## If it fails

**Errors**

| Code | HTTP | When | What to do | Retryable |
|---|---|---|---|---|
| `not_receiving` | 403 | Receiving requests is off for this connection. | Ask the owner to switch on Receive requests. | No |
| `already_claimed` | 409 | Another run claimed the request with a different claim_id. | Don't start the work. | No |
| `not_claimed` | 409 | A report or result before the request was claimed. | Claim it first. | No |
| `cancellation_requested` | 409 | The owner asked to stop. | Send status cancelled (or outcome_unknown), or the result if the work is done. | No |
| `request_closed` | 409 | The request has ended: completed, cancelled, failed, expired or with an unknown outcome. | Stop; nothing more is accepted. | No |

## Next

- [Pull feed](https://thread.page/help/connections/feed.md)
- [Events and subscriptions](https://thread.page/help/connections/events.md)
