Skip to main content
← Back to list
01Issue
FeatureIn ProgressSwamp CLIPublic
Assigneesstack72

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.
  • 403 with { "error": "inbox_requires_user" }: a collective API key. The inbox belongs to a person, not a collective.
  • 404 on /summary or /messages: the server has no inbox (an older or self-hosted Swamp Club).
  • 429 with Retry-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 on verifyIdentity. The cases are:

    • { kind: "ok", summary, etag }
    • { kind: "not_modified" }
    • { kind: "unsupported" } for a 404
    • { kind: "unavailable" } for a 403 inbox_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. A Retry-After sleep would hold teardown. The poll's caller-supplied signal is the only timeout.

  • listInboxMessages(apiKey, { status, limit, before }, signal), getInboxMessage(apiKey, id, signal) and markInboxRead(apiKey, ids, signal) go through the shared fetch helper, like fetchIssue. They throw a UserError with 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 kind is kept as a string. Drop link when it is not https:.

Fixture and design doc

  • src/infrastructure/http/inbox_responses.fixture.json holds one example of each response: summary, a list page with every kind and 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.md holds 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 InboxSummaryResult case, including a 304 and an ETag passed through as If-None-Match
  • a 429 returns backoff immediately, with no retry sleep
  • an aborted signal returns failed, not a throw
  • list query parameters, the nextCursor round trip, and a malformed entry dropped while the rest are kept
  • every UserError message above
  • markInboxRead sends { 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
02Bog Flow
✓OPEN✓TRIAGED◉IN PROGRESS○SHIPPED+ 1 MOREASSIGNED+ 8 MOREREVIEW+ 5 MOREVERIFICATION_STARTED

In Progress

10/26/2026, 11:07:48 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/26/2026, 9:54:27 PM

Sign in to post a ripple.