Skip to content

Shared Thread Read State

Read and advance your own reading position on a shared thread, so a client can put you back where you stopped and draw an "N new messages" divider instead of always landing at the newest message.

The owner read routes are ownership-scoped and return 404 for a share-link recipient. These are the slug-scoped equivalents. Authorization is the same as reading the thread's messages — the slug must resolve for you — so both a view-only visitor and a joined participant can use them.

The marker is per-viewer: it is keyed on the authenticated user and is never shared between people. It is also the same read cursor the thread's unread badge uses, so marking read here clears the thread's unread indicator everywhere rather than keeping a separate position that drifts.

GEThttps://api.aitronos.com/v1/shared-threads/{slug}/read-state
PUThttps://api.aitronos.com/v1/shared-threads/{slug}/read-state

Authentication

Requires user authentication (Bearer token). An API key has no recipient identity and is rejected with 403.

Path Parameters

slug string required

The share-link slug, from the share URL.

Request Body (PUT only)

last_read_message_id string required

The newest message you have now read. It must be a message this thread actually shows you; an unknown, deleted, hidden, or other-thread id returns 404 rather than being stored, because a marker pointing at nothing is a restore position you could never clear.

The write is monotonic: a message older than your stored marker leaves the stored one in place. Scroll-driven calls, a second tab, and late retries can therefore never rewind your progress.

Returns

Both methods return the read state as it stands after the call, so a client never has to re-read to learn the outcome.

  • last_read_message_id string | null — the message you last read. null is normal: you have never opened the thread, or your cursor was set by a whole-thread mark-read that named no message.
  • last_read_at string | null — timestamp of the cursor. Set whenever any read state exists, even when last_read_message_id is null.
  • unread_count integer — messages after the cursor you have not seen. Counts only rows the transcript renders, and never your own messages.
  • first_unread_message_id string | null — the message to draw the divider above, and to pass as the around anchor when jumping there. Computed server-side, so a deleted or invisible row right after the cursor cannot become a dead anchor. null exactly when unread_count is 0.
  • unread_count_capped boolean — true when more messages are unread than unread_count reports (the count stops at 999 so the scan stays bounded). Render 999+ rather than an exact number when set.

Code Examples

cURL

# Where did I stop reading?
curl -X GET "https://api.aitronos.com/v1/shared-threads/abc123slug/read-state" \
  -H "Authorization: Bearer YOUR_TOKEN"

# I have now read up to this message.
curl -X PUT "https://api.aitronos.com/v1/shared-threads/abc123slug/read-state" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"last_read_message_id": "msg_abc123"}'

Python SDK

from aitronos import Aitronos

client = Aitronos(api_key="", token="YOUR_TOKEN")

state = client.threads.shared.get_read_state(slug="abc123slug")
if state.unread_count:
    print(f"{state.unread_count} new since {state.last_read_at}")
    print(f"divider goes above {state.first_unread_message_id}")

client.threads.shared.set_read_state(
    slug="abc123slug",
    last_read_message_id="msg_abc123",
)

JavaScript

const state = await (
  await fetch(
    "https://api.aitronos.com/v1/shared-threads/abc123slug/read-state",
    { headers: { Authorization: "Bearer YOUR_TOKEN" } },
  )
).json();

await fetch(
  "https://api.aitronos.com/v1/shared-threads/abc123slug/read-state",
  {
    method: "PUT",
    headers: {
      Authorization: "Bearer YOUR_TOKEN",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ last_read_message_id: "msg_abc123" }),
  },
);

Response Examples

Success — you have unread messages (200)

{
  "last_read_message_id": "msg_abc123",
  "last_read_at": "2026-08-05T20:07:04Z",
  "unread_count": 12,
  "first_unread_message_id": "msg_def456",
  "unread_count_capped": false
}

Success — never opened (200)

Open at the bottom with no divider. This is a normal state, not an error.

{
  "last_read_message_id": null,
  "last_read_at": null,
  "unread_count": 0,
  "first_unread_message_id": null,
  "unread_count_capped": false
}

Error — the message is not part of this thread (404)

{
  "success": false,
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "The requested resource could not be found.",
    "system_message": "That message is not part of this thread",
    "type": "client_error",
    "status": 404,
    "details": { "last_read_message_id": "msg_ghost" },
    "trace_id": "...",
    "timestamp": "..."
  }
}

The same uniform deny as opening the link — it never reveals whether the link or thread exists.

{
  "success": false,
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "The requested resource could not be found.",
    "system_message": "Share link not found",
    "type": "client_error",
    "status": 404,
    "details": {},
    "trace_id": "...",
    "timestamp": "..."
  }
}