Relationships
#3990 Inbox API contract and SwampClubClient inbox methods: summary, list, get and mark-read, pinned by a shared fixture and design/surfaces/inbox.md
Opened by stack72 · 10/26/2026
Summary
Fix the Swamp Club inbox API contract and add the SwampClubClient methods that call it. This is PR 1 of the inbox slice (slice 2). The swamp subscription slice (swamp-club#3950, swamp-club#3951) has already shipped. Users see nothing change: no command, no poll and no notice uses these methods yet.
The contract is the critical path. The swamp inbox and swamp inbox read commands and the post-command notice all depend on it, and Swamp Club builds its endpoints from it. This PR pins the contract in three places: a design doc, typed client methods and a shared JSON fixture. Swamp Club's tests can then run against the same fixture as the CLI's.
Overview of the whole inbox slice (the poll, the notice, the commands): https://claude.ai/artifact/8iNQzzUZaH9FcM29NP8s81
Contract
Every endpoint authenticates with the user's API key in the x-api-key header, as /api/whoami does. Messages belong to a user. A broadcast is delivered as one copy per user, so read state is per user.
| Endpoint | Request | Response |
|---|---|---|
GET /api/inbox/summary |
Optional If-None-Match: <etag> |
200 with an ETag header and { unread, newestId, newestAt, pollAfterSeconds }, or 304 with no body when unchanged |
GET /api/inbox/messages |
?status=unread|all (default unread), &limit= (default 50, max 100), &before=<id> |
200 { messages: Message[], nextCursor: string | null }, newest first |
GET /api/inbox/messages/:id |
none | 200 Message; 404 when the message is not the caller's |
POST /api/inbox/read |
{ ids: string[] }, at most 100 |
200 { read: string[] }: the ids now read that belong to the caller. Idempotent; ids that are unknown or not the caller's are left out |
InboxSummary {
unread: number // count of unread messages
newestId: string | null // newest unread message id
newestAt: string | null // ISO 8601
pollAfterSeconds: number // the CLI waits at least this long before polling again; floor 900
}
Message {
id: string // ULID: sortable, so "newer than" is a string compare
kind: string // "announcement" | "issue_update" | "points" | "direct" today; a string so new kinds still parse
title: string // one line, plain text, at most 120 characters
body: string // markdown, at most 8 KiB
createdAt: string // ISO 8601
link?: string // https URL on the Swamp Club site
action?: { label: string, command: string } // e.g. { "label": "Update now", "command": "swamp update" }
readAt: string | null // ISO 8601
}Status codes that apply to every endpoint:
401: the key is unknown or revoked.403with{ "error": "inbox_requires_user" }: a collective API key. The inbox belongs to a person, not a collective.404on/summaryor/messages: the server has no inbox (an older or self-hosted Swamp Club).429withRetry-After: back off.
Client methods
Add to src/infrastructure/http/swamp_club_client.ts:
inboxSummary(apiKey, etag, signal): Promise<InboxSummaryResult>returns a result and never throws, because the background poll is best effort. Model it onverifyIdentity. The cases are:{ kind: "ok", summary, etag }{ kind: "not_modified" }{ kind: "unsupported" }for a 404{ kind: "unavailable" }for a 403inbox_requires_user{ kind: "rejected" }for a 401{ kind: "backoff", retryAfterSeconds }for a 429{ kind: "failed", reason }for a timeout, a refused connection, a 5xx or a malformed body
It must not go through
fetchWithRateLimitRetry. ARetry-Aftersleep would hold teardown. The poll's caller-supplied signal is the only timeout.listInboxMessages(apiKey, { status, limit, before }, signal),getInboxMessage(apiKey, id, signal)andmarkInboxRead(apiKey, ids, signal)go through the sharedfetchhelper, likefetchIssue. They throw aUserErrorwith a plain message:- a 404 from list: "This Swamp Club server has no inbox."
- a 403
inbox_requires_user: "The inbox needs a personal API key; collective keys have no inbox." - a 404 from get: "Message not found."
Parse every response with a zod schema. A message entry that fails the schema is dropped and the rest of the list is kept. An unknown
kindis kept as a string. Droplinkwhen it is nothttps:.
Fixture and design doc
src/infrastructure/http/inbox_responses.fixture.jsonholds one example of each response: summary, a list page with everykindand both read states, a single message, a mark-read answer, and the 403 body. Client tests load it, and later PRs reuse it for libswamp and renderer tests.design/surfaces/inbox.mdholds the contract above, and briefly how the CLI uses it: the poll from slow commands, the cache, the post-command notice and its 24-hour retirement. This is the copy Swamp Club works from.
Tests
swamp_club_client_test.ts, or a new swamp_club_client_inbox_test.ts, using the client's injected fetch the way the existing tests do:
- every
InboxSummaryResultcase, including a304and anETagpassed through asIf-None-Match - a
429returnsbackoffimmediately, with no retry sleep - an aborted signal returns
failed, not a throw - list query parameters, the
nextCursorround trip, and a malformed entry dropped while the rest are kept - every
UserErrormessage above markInboxReadsends{ ids }and returns only the ids the server confirmed
Out of scope
swamp inbox,swamp inbox read, the cache, the poll wiring and the notice (later PRs in this slice)- Skill changes
- The Swamp Club endpoints and the way messages get sent: broadcasts, targeted messages, and the automatic "your issue is fixed" ones. That work is tracked separately on the Swamp Club side
Triaged
Click a lifecycle step above to view its details.
Sign in to post a ripple.