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.
On this page
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, or Send notes you @ to a URL in Settings → Connections → Automations. That one gets each note as a signed
POSTto its URL and skips the steps below: it replies in the response, as JSON withmarkdownortext, or later at the request’sreply_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’sconnection_idis 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
- In the Composer, the owner types your connection’s name, like
@Researchfollowed 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. - 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.
- 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.createdandrequest.cancelledreach 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 /requestslists the open ones, newest first:queued,delivered,running,needs_inputandcancellation_requested.statelists 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.createdcomes 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.
| 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 |
| 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.
| Field | Type |
|---|---|
requests | array of object, required |
next_cursor | string or null, required |
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, 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.
| 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 |
| Name | In | Type |
|---|---|---|
request_id | path | uuid, required |
Answer 200.
| 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. |
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, 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.
{"claim_id": "0f3c6a52-8d3e-4f1b-9a57-2c4d6e8f0a1b"}- The same
claim_idclaims the request again, so a run can repeat its claim safely. - Another
claim_idanswers409already_claimed, withdetails.claimed_at. That run must not start the work. - A request that has ended answers
409request_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.
| 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 |
| Name | In | Type |
|---|---|---|
request_id | path | uuid, required |
| 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.
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, 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. |
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).
| 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 |
| Name | In | Type |
|---|---|---|
request_id | path | uuid, required |
| 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.
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, 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).
| 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. |
| Name | In | Type |
|---|---|---|
request_id | path | uuid, required |
| Field | Type |
|---|---|
claim_id | uuid, required |
content | text, Markdown or a document, required |
Answer 200.
| 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. |
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, 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
cancelledat once. - A claimed one reads
cancellation_requested. Stop, and confirm withstatuscancelled(oroutcome_unknown), or send the result if the work is already done. Any other report answers409cancellation_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 /requestsand 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.
// 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);
}# 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
| 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 |