ThreadPage
All docs

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.

View as Markdown

On this page
  1. Before you start
  2. How a request works
  3. Hear about requests
  4. Claim, report, answer
  5. Questions and answers
  6. Cancellation
  7. When a run dies
  8. In shared threads
  9. Through MCP
  10. Limits
  11. Request worker
  12. If it fails
  13. Next

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 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.
How a request works
stateMeans
queuedWaiting to reach your worker.
deliveredYour worker has seen it: an event, a list or a read delivered it.
runningA run claimed it and reported that it is working.
needs_inputYour worker asked the owner a question.
cancellation_requestedThe owner asked a claimed request to stop.
completedYour worker sent the result.
failedYour worker reported that it failed, or the request could not be delivered.
cancelledThe owner stopped it, or it can no longer be answered.
expiredNobody answered within 7 days.
outcome_unknownA 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
RequirementValue
LevelsEdit and organize · Read and add
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
MCPlist_requests
Parameters
NameInTypeDescription
cursorquerystringFrom next_cursor.
statequerycancellation_requested, cancelled, completed, delivered, failed, needs_input, outcome_unknown, queued, runningOne state instead.

Answer 200.

Answer fields
FieldType
requestsarray of object, required
next_cursorstring or null, required
curl
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.

Requirements
RequirementValue
LevelsEdit and organize · Read and add
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
MCPget_request
Parameters
NameInType
request_idpathuuid, required

Answer 200.

Answer fields
FieldTypeDescription
iduuid, required
statequeued, delivered, running, needs_input, completed, failed, cancellation_requested, cancelled, expired, outcome_unknown, required
testboolean, requiredA Test request from the owner: no note, nothing is written.
handlestring, requiredThe name the note was sent to, as sent.
addressed_toarray of string, requiredEvery 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_atdate-time, required
expires_atdate-time, required
threadobject or or null or null, requiredNull for a Test request.
noteobject or or null or null, requiredThe 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_iduuid or null, requiredThe 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_atdate-time or null, requiredWhen a run claimed it; null until it is claimed.
replyobject, required
answersobject or or null or null, requiredSet 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
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.

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
RequirementValue
LevelsEdit and organize · Read and add
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
IdempotencyOperation-Id header: one UUID per logical write, the same on every retry.
MCPclaim_request
Parameters
NameInType
request_idpathuuid, required
Body (application/json)
FieldTypeDescription
claim_iduuid, requiredA 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.

curl
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.

Report and answer
SendWithEffect
status runningan optional message (up to 200 characters)The owner sees Working, or your message.
status needs_inputa question: text, markdown or document, like a noteThe question is added as a note that refers to the request’s note.
status faileda short messageThe request ends; the owner sees the message and can send it again.
status cancelledonly the claim_idConfirms a stop the owner asked for.
status outcome_unknowna short messageYour run stopped after it may have acted, and you won’t try again. The owner sees Result unknown.
resultcontent: text, markdown or documentAdds 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.
Shell
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
RequirementValue
LevelsEdit and organize · Read and add
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
IdempotencyOperation-Id header: one UUID per logical write, the same on every retry.
MCPreport_request_status
Parameters
NameInType
request_idpathuuid, required
Body (application/json)
FieldTypeDescription
claim_iduuid, required
staterunning, needs_input, failed, cancelled, outcome_unknown, requiredoutcome_unknown: your run was interrupted after it may have acted, and you will not try again; it ends the request.
messagestringShown to the owner.
questiontext, Markdown or a documentneeds_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.

curl
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).

Requirements
RequirementValue
LevelsEdit and organize · Read and add
PlanPro with Sync. On Free, add to the Inbox instead (POST /inbox/notes).
IdempotencyOperation-Id header: one UUID per logical write, the same on every retry.
MCPcomplete_request
Shared threadsOnly where the owner chose Allow here.
Parameters
NameInType
request_idpathuuid, required
Body (application/json)
FieldType
claim_iduuid, required
contenttext, Markdown or a document, required

Answer 200.

Answer fields
FieldTypeDescription
requestobject, required
note_iduuid or null, requiredNull for a Test request.
statusaccepted, conflict, not_completed, test, requiredtest: a Test request was answered and nothing was written.
curl
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 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.

// 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);
}

If it fails

Errors
CodeHTTPWhenWhat to doRetryable
not_receiving403Receiving requests is off for this connection.Ask the owner to switch on Receive requests.No
already_claimed409Another run claimed the request with a different claim_id.Don't start the work.No
not_claimed409A report or result before the request was claimed.Claim it first.No
cancellation_requested409The owner asked to stop.Send status cancelled (or outcome_unknown), or the result if the work is done.No
request_closed409The request has ended: completed, cancelled, failed, expired or with an unknown outcome.Stop; nothing more is accepted.No

Next