Skip to main content
Version: Latest
UPDATED 2026-11-02 — Posts with an external form (cta.handledExternally) never create a request or a DM. On the author's responses screen show "responses handled externally" copy for them. Nothing changes for other posts.
- **2026-11-02** — Posts with an external form (`cta.handledExternally`) never create a request or a DM. On the author's responses screen show "responses handled externally" copy for them. Nothing changes for other posts.- **2026-10-29** — The seed message's `content` (the requester's note) must be drawn as a text bubble with the card, for the author too. The old "no special casing" line hid this. See the chat guide §5.3a.
- **2026-09-10** — Notification deep links now use the `clustr://` scheme instead of `ksn://` (e.g. `clustr://posts/:id`). Paths and query params are unchanged, so only your URL handler's scheme check moves. The API no longer sends `ksn://`.- **2026-09-09** — The conversation's `pendingPostRequest` now includes `selectedOptions`, so the chat card can show the ticked options exactly like the author's Responses tray. No extra call.
**External-form posts.** If the post's `cta.handledExternally` is `true`, skip thissection. There are no requests and no badge. Show "Responses are collected on yourexternal form" instead (posts guide §1.5). ## 5 — Post edit freeze (author UX)

Mobile Post Request ↔ DM Wiring — Integration Guide

Wiring guide for the request→DM integration only: a request opening its conversation at submit time, accept flipping it, the author's "new response" badge, and the edit freeze. Prerequisites (read first): mobile-post-requests-guide.md (request endpoints + CTA), mobile-chat-guide.md (conversations + sockets), mobile-post-share-guide.md (POST_SHARE card rendering).

  • Base URL: http://localhost:3001 (dev) — production URL from backend team
  • Auth: Authorization: Bearer <accessToken> on every endpoint
  • Headers: Content-Type: application/json, X-Client-Type: mobile

What's new​

  • 2026-11-02 — Posts with an external form (cta.handledExternally) never create a request or a DM. On the author's responses screen show "responses handled externally" copy for them. Nothing changes for other posts.
  • 2026-10-29 — The seed message's content (the requester's note) must be drawn as a text bubble with the card, for the author too. The old "no special casing" line hid this. See the chat guide §5.3a.
  • 2026-10-05 — The request's seed POST_SHARE message now carries postRequest: { id, selectedOptions } — the options the requester ticked — in the message list and the live new_message. Render them as chips on the request card. It stays after accept (when pendingPostRequest goes null). Absent on every other message.
  • 2026-10-05 — Authors can now reopen a declined request (reopen works on REJECTED, not just ACCEPTED). While the reopened request is PENDING, there is nothing to do for the chat: no chat events fire and the row stays gone (it was dropped on decline). When the author accepts, the chat comes back with the request card written again as a new message — no empty thread. That accept sends the usual three socket events for a row you no longer have: treat chat_list_update for an unknown conversation as "add this row" (§2, "Accept after a declined request was reopened").
  • 2026-09-10 — Notification deep links now use the clustr:// scheme instead of ksn:// (e.g. clustr://posts/:id). Paths and query params are unchanged, so only your URL handler's scheme check moves. The API no longer sends ksn://.

What changed (delta over the base requests guide)​

SurfaceNew
A request opens its DM at SUBMIT, not on acceptthe requester's outgoing request is an ordinary row in folder=inbox. The client-side merge and the fake-conversation screen are gone (§1)
Conversation object (list, :id, search)pendingPostRequest ({ id, postId, selectedOptions } | null) — provenance, not a lock: set while the request is unanswered, so you can banner "Awaiting a response to your post" and open the post from the card
Sending into an unanswered requestthe ordinary PENDING-DM rule: the requester sends text (media / GIF / post card → 403), the author cannot send until they accept. Changed 2026-08-31 — the old 403 "Waiting for the author to accept your request" that refused both sides is gone
Where the author sees itResponses while the thread is only the request; once the requester writes a message of their own it also appears in their chat Requests tray with the whole history and the usual Accept / Delete / Block
Seed POST_SHARE messagepostRequest: { id, selectedOptions } — present only on a post-request seed, in GET …/messages and the live new_message. Prefer it over pendingPostRequest for chips: it outlives accept
PostRequest objectconversationId (string | null, set from submit), authorReadAt (ISO | null), submitCount (int)
Author inbox countsunread number
POST /api/posts/:id/requests submitopens/attaches the DM, returns conversationId. The note is moderated — 422 MESSAGE_BLOCKED on blocked words
PATCH .../requests/:reqId acceptflips the existing DM to ACCEPTED and backfills receipts; never writes a second seed message
post_request_accepted notificationdata.conversationId + chat deep link
Post object (list + detail)viewerRequestConversationId (string | null) — "Open chat" CTA target when the viewer's request is ACCEPTED
PATCH .../requests/:reqIdnew "reopen" action — ACCEPTED back to PENDING (+ post_request_reopened notification). The conversation stays ACCEPTED and keeps its conversationId — reopening a request never closes an open chat
PATCH /api/posts/:id409 once eventAt has passed

1 — Requester's DM tab (nothing to merge)​

This section replaced a client-side merge and a "fake conversation" rendering contract. Both are gone — delete that code. A post request is a real Conversation with a real Message from the moment it is submitted, so there is nothing to splice into the chat list and nothing to fake.

The DM tab is one call:

GET /api/chat/conversations?folder=inbox

A pending outgoing request comes back as an ordinary row — real conversationId, real message, real sequenceNumber, receipts, sockets, cursor pagination, and updatedAt ordering that already puts it in the right place among the user's other chats. No second fetch, no merge, no client sort.

It is marked so you can render its state:

{
"id": "conv_abc",
"status": "PENDING",
"requesterId": "<me>",
"pendingPostRequest": { "id": "req_x", "postId": "post_y", "selectedOptions": ["Frontend"] },
"messages": [ { "type": "POST_SHARE", "sharedPostId": "post_y", "content": "…the note…" } ]
}
pendingPostRequestRender
non-nullBanner: "Awaiting a response to your post". The POST_SHARE card is the thread's first message; tapping it opens the post, and selectedOptions are the options the requester ticked ([] when none) — render them as chips beside the note. Do not disable the composer on this field
nullno banner — an ordinary chat

The composer follows the same rule as every other conversation, from status + requesterId alone:

if (conv.status === "ACCEPTED") full();
else if (conv.requesterId === me) textOnly(); // ← a post requester lands here
else locked(); // Accept / Delete / Block

So the requester may keep writing text into their own request before the author answers — media[] and postId stay 403 until it is accepted, exactly as in a cold message request.

Opening the thread is a normal chat screen against the normal endpoints (GET /api/chat/conversations/:id/messages). The request note and the post card are already in the timeline as message #1, sent by the requester.

State changes arrive as they do for any chat:

EventDo
conversation_status_changed → ACCEPTEDthe author accepted — full composer; pendingPostRequest is null on the next fetch
chat_list_update with pendingPostRequest: nullthe request was answered on the author's side — drop the banner. No refetch: the event carries pendingPostRequest and tray
conversation_clearedthe request was declined or withdrawn — drop the row. Nothing was deleted; a later message brings the thread back
chat_list_updateordinary list refresh

There is still no socket event for PostRequest state itself — request status lives on the post CTA and in /requests/mine; the chat side only ever tells you about the conversation.

If the pair already had a chat, the request appends to that thread instead of opening a new one, pendingPostRequest is not set (an open conversation stays open), and it is an ordinary message in an ordinary thread — full composer on both sides. One user-to-user relationship has exactly one conversation.

Accepting in the chat tray is not answering the request. If the author taps Accept on the row in their Requests tray, the conversation flips to ACCEPTED and the chat opens — but the PostRequest stays PENDING and still needs an Accept or Ignore in Responses ("Ignore" here is UI copy for the reject action on PATCH .../requests/:reqId — same sticky decline, no separate backend state). Two surfaces, two decisions.

2 — Accept → conversation handoff​

The conversation already exists — the requester has had it in their inbox since they submitted. Accept flips it from PENDING to ACCEPTED, backfills the author's receipt for the seed message, and opens the author's composer (the requester's was never closed). No second conversation and no second seed message are ever created.

The requester's next fetch (or the push payload) carries the same chat:

// PATCH /api/posts/:id/requests/:reqId → 200 (author side)
// GET /api/posts/requests/mine → items[] (requester side)
{
"status": "ACCEPTED",
"conversationId": "conv_abc123", // navigate: /chat/conv_abc123
...
}

The DM opens with a seed message already inside: a normal POST_SHARE message, senderId = the requester, content = their request note, sharedPostId = the post. Render it like any shared post (mobile-post-share-guide.md), plus the note and selectedOptions chips — see the chat guide §5.3a. The author sees it too. Accepting more requests from the same person appends more seed messages into the same DM.

⚠ ACCEPTED with conversationId: null is a VALID state (blocked pair, or linkage failed server-side). Never assume ACCEPTED ⇒ chat exists. Null → link to the post instead of the chat. Do not retry client-side; the backend has an operational repair path.

If the post is later deleted, the seed message survives with sharedPostId: null → render the standard "post unavailable" placeholder.

Accept after a declined request was reopened​

Sequence: author rejects → both sides get conversation_cleared and drop the row → author reopens (row PENDING again, post_request_reopened notification, no chat events — nothing to do, the row stays gone) → author accepts.

On that accept the server flips the conversation to ACCEPTED and writes the request card again as a new POST_SHARE message (senderId = requester, content = their note, same sharedPostId). Because it sits above the clear boundary it brings the thread back for both users with that card as the first visible message — the older copy stays hidden, so there is no duplicate.

You receive the usual accept-moment events (§3), but for a conversation the client no longer has a row for:

EventDo
conversation_status_changed { conversationId, status: "ACCEPTED" }no-op if you have no row
new_message (type: "POST_SHARE")only matters if that chat screen is open
chat_list_update { conversationId, status: "ACCEPTED", tray: "inbox", pendingPostRequest: null, lastMessage, unreadIncrement }row absent → refetch GET /api/chat/conversations?folder=inbox (or insert from the payload) and show it. unreadIncrement is 1 for the author, 0 for the requester

Rule of thumb: treat chat_list_update for an unknown conversationId as "add this row", never as "ignore". The seed is re-written only when the original was hidden; an ordinary accept (thread never cleared) writes nothing new, as before.

3 — Socket events you will receive​

All existing events from mobile-chat-guide.md — the seed message reuses the normal send pipeline. On the accept moment:

EventRoomWhen
conversation_status_changed { conversationId, status: "ACCEPTED" }both usersonly if a PENDING chat request already existed between the pair (it flips)
new_message (full message, type: "POST_SHARE")conversation roomthe seed. Resolve the card via POST /api/posts/share-cards as usual
chat_list_update { conversationId, status: "ACCEPTED", lastMessage, unreadIncrement }both personal roomssidebar row. unreadIncrement: 1 for the author, 0 for the requester (they're the sender)

No message / message_request push fires for the seed — the post_request_accepted push is the single notification and it deep-links to clustr://chat/:conversationId (falls back to clustr://posts/:id when conversationId is null).

4 — Author's responses screen: "new" badge​

Both author inboxes (GET /api/posts/:id/requests, GET /api/posts/requests/inbox) now return:

"counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 }

Contract — fetch marks read. There is no mark-read endpoint and no viewport tracking:

  • A row with authorReadAt: null in the payload you just received is "new" → highlight it. The server marks exactly those rows read as it serves them.
  • Your next fetch returns them with authorReadAt set → no highlight. Keep the highlight for the lifetime of the screen from the payload you rendered — do not refetch to "confirm".
  • counts.unread is computed before the same response's marking, so the badge you display alongside page 1 is accurate at fetch time.
  • Pagination: only served rows get marked. Unfetched pages still count in unread until actually delivered.
  • unread spans all statuses (a withdrawn-before-seen row is still "new"). Requester endpoints never mark anything.

Badge suggestion: use counts.unread from the aggregate inbox for the home-screen "Requests" tab badge; per-post counts.unread for each "View Responses" entry point.

External-form posts. If the post's cta.handledExternally is true, skip this section. There are no requests and no badge. Show "Responses are collected on your external form" instead (posts guide §1.5).

5 — Post edit freeze (author UX)​

PATCH /api/posts/:id now returns 409 ("Post can no longer be edited after its event date") once the post's eventAt is in the past. Wiring:

  • Hide/disable the edit action when post.eventAt < now.
  • Posts without eventAt never freeze.
  • This is independent of the existing prompt freeze (requestPrompt* locked after the first request — also 409). Handle both by surfacing the server message.

6 — Edge matrix​

CaseWhat the client seesDo
submit, blocked pair200, conversationId: nullrequest stands with no chat; accept will retry the linkage
submit while the author has an unanswered message request to you200, conversationId: nullthe request cannot attach to a thread you have not accepted; accept creates the DM instead
accept, blocked pair200, conversationId: nullauthor: normal accepted row, no chat affordance. requester: ACCEPTED row linking to post
decline / withdraw, seed-only threadrow drops from the DM tab via conversation_clearednothing is deleted — a later message from either side brings the thread back
decline / withdraw after the requester has chattedno conversation_cleared — the thread survives on both sides; a post_request_declined / _withdrawn system message lands in itrefetch the row: pendingPostRequest is now null, so drop the banner. For the author it stays an ordinary message request in Requests
requester messages before the author answersauthor's Requests tray gains the row (with the seed card + the message); requester's own row is unchanged in their inboxthe author's message_request notification fires, and their chat_list_update carries tray: "requests" + pendingPostRequest — place and render it without a fetch
accept twice / stale inbox409 "Request is not pending"refetch inbox
post deleted after acceptseed message with sharedPostId: null; requester's /requests/mine row gone (cascade)placeholder card; DM survives
conversation deleted laterconversationId back to null on the rowfall back to post link
same requester on 2 postssame conversationId on both rowsone DM, two seed messages
withdraw then re-submita new seed message, higher sequenceNumberthe row reappears in the DM tab showing only what arrived after the withdrawal. Two seeds are still not a conversation — the author sees it in Responses only, until the requester writes something of their own
author reopens a declined request (2026-10-05)row back to PENDING, conversationId kept; thread stays cleared, no system line, post_request_reopened notification still sentnothing to do on reopen — the row was already dropped on decline and no event brings it back. When the author then accepts, the thread flips to ACCEPTED and the request card is written again as a fresh POST_SHARE — see §2 "Accept after a declined request was reopened"
author reopens an accepted requestrow back to PENDING, conversationId kept; the conversation stays ACCEPTEDchat is unaffected and stays usable — only the post CTA reverts to pendingLabel
request while intake paused/completed409 on submitper base guide — CTA state from the post object

7 — Wiring checklist​

  1. DM tab = GET /api/chat/conversations?folder=inbox, nothing else. Render pendingPostRequest rows as "Waiting for acceptance" with the composer disabled (§1). Delete any merge and fake-conversation code.
  2. On post_request_accepted: drop the request row, navigate via data.conversationId; null → post fallback (§2).
  3. Render the seed like any POST_SHARE message — zero new message UI (§2).
  4. Handle the three accept-moment socket events (§3) — all pre-existing handlers from the chat guide.
  5. Responses screens: highlight authorReadAt: null rows from the fetched payload; badges from counts.unread (§4).
  6. Disable post editing when eventAt has passed; surface 409 messages (§5).
  7. chat_list_update for a conversationId you have no row for → add the row (refetch inbox). Needed when a reopened declined request is accepted (§2).

7 — Decisions from the 2026-08-31 design review​

Four touch these screens. Two shipped on 2026-09-01 (marked ✅); the other two are decided and not built. Full reasoning: docs/CHAT_SYSTEM_MESSAGES.md.

DecisionEffect here
✅ post_request_opened retiredShipped. Submitting writes no system row — the seed card is the artifact, carrying the post, the note and View original post. Old threads keep rows written before 2026-09-01; those now arrive as system.unsupported: true with a neutral line. Nothing else about submit changed: the seed, conversation_cleared, tray placement and the socket events in §3 are all as documented above
❌ status chip on the request cardWithdrawn 2026-09-01 — don't build it. Accepted, not-accepted and reopened are each already a system line in the thread; pending is the absence of one. A chip renders the same facts twice. No server field is coming
✅ "not accepted" replaced "declined"Shipped. The notification title now reads "Your request to join wasn't accepted" (verb from the post type's catalog, as before), and the thread line reads "Your request wasn't accepted." Event codes and the post_request_rejected type are unchanged — don't match on the words
✅ Post titles in the request linesShipped 2026-09-01. "You accepted Arjun's request for "Debate Partner"." postTitle is snapshotted into system.data at write time, never resolved from postId — a deleted post must not blank an old sentence. null on pre-2026-09-01 rows, where the clause is simply dropped (it does not mean the post was deleted). Clipped to ~40 chars server-side. Two lines also changed voice: the requester reads "Your request for … was accepted." and, after a reopen, "Your request for … is being looked at again."

The seed-only decline/withdraw behaviour in §6 (thread clears for both, no system message, outcome by notification) was reviewed and kept. It is deliberate, not a gap.

⤓ Download .md