45 operations. Every schema and example on this page is generated from the platform contract.
How many recent events to return (default 100, max 200).
curl -G "https://www.membber.com/api/v1/board/activity" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/activity");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardActivity().ok.body.json
print(response){
"activity": [
{
"id": 1,
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"actor": "<actor>",
"verb": "<verb>",
"detail": "<detail>",
"created_at": "<created_at>"
}
]
}How many candidates to return (default 12, max 50).
'founder-binding' when the founder has ranked it, else 'judgement'.
curl -G "https://www.membber.com/api/v1/board/candidates" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/candidates");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardCandidates().ok.body.json
print(response){
"candidates": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"type": "<type>",
"section": "<section>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"prio": "<prio>",
"founder_ord": 1,
"founder_ranked": true,
"tier": "<tier>",
"unblocks_count": 1,
"age_days": 1,
"adjacent_to_inflight": true,
"branch": "<branch>",
"pr_number": 1
}
]
}Claim was removed from the product (R12). This route survives ONLY because an installed binary calls it, and it now delegates to the assign path: the thread is registered, given the ticket, and the ticket moves to Doing. Still returns 409 when another thread already holds it, exactly as before.
The ticket to pick up.
The thread picking it up.
Who is picking it up.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/claim" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"session": "<session>",
"assignee": "<assignee>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/claim", {
body: {
id: "00000d1b-0000-4000-8000-d0c500000000",
session: "<session>",
assignee: "<assignee>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.claimBoardTicket(
body: .json(.init(
id: "00000d1b-0000-4000-8000-d0c500000000",
session: "<session>",
assignee: "<assignee>"
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}The device watchdog and MetricKit report here. Repeats FOLD: the ninth hang on the same screen, reporter, device class and build appends an occurrence comment to one ticket and refreshes its count and worst duration, rather than creating a ninth row. Idempotent on source_key, so a retry cannot double-count. Simulator reports (device_class is a host arch) are filed low and labelled honestly rather than hidden, because they are still real observations.
The frontmost screen when the main thread stalled, e.g. "Board". Part of the dedupe key.
Which of the two reporters saw it: the in-process watchdog, or Apple MetricKit.
watchdogmetrickitHow long the main thread was blocked. The worst value across occurrences rides in the title.
uname().machine, a real model ("iPhone18,2") on a device, the HOST ARCH ("arm64") on a simulator. This is a FIELD rather than something regexed out of a rendered string, because it is what tells a real defect from the Apple sim host-bridge wedge. 10 of the first 12 live hang reports were arm64.
Short version + build, e.g. "1.0(1)". Part of the dedupe key.
The full rendered environment line, kept verbatim.
This ONE hang's idempotency key, derived from its own timestamp. Passed straight through to the occurrence comment, so a delivery retry can never count the same hang twice.
The full report body, call stack where MetricKit supplied one, honestly labelled.
The board ticket this occurrence was folded onto.
How many times this exact hang has now been seen.
The longest occurrence in this bucket.
True when the report came from a simulator, filed low, and labelled as such.
True when this occurrence created the ticket rather than appending to it.
curl -X POST "https://www.membber.com/api/v1/board/diagnostics/hang" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"screen": "<screen>",
"source": "watchdog",
"seconds": 0,
"device_class": "<device_class>",
"app_build": "<app_build>",
"source_key": "<source_key>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/diagnostics/hang", {
body: {
screen: "<screen>",
source: "watchdog",
seconds: 0,
device_class: "<device_class>",
app_build: "<app_build>",
source_key: "<source_key>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.reportBoardHang(
body: .json(.init(
screen: "<screen>",
source: .watchdog,
seconds: 0,
deviceClass: "<device_class>",
appBuild: "<app_build>",
sourceKey: "<source_key>"
))
).ok.body.json
print(response){
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"occurrences": -9007199254740991,
"worst_seconds": 1,
"simulator": true,
"created": true
}Reads the board_fleet_status singleton (written only by the Mac launcher from an observed, captured limit message) plus a computed queue signal: how many ranked tickets wait and whether any live developer is free. The app renders the halt banner from this; realtime `fleet_changed` broadcasts on the 'board' topic signal when to refetch.
running = work can flow; halted_limit = the Mac CLI is refusing work on a usage limit.
runninghalted_limitThe verbatim captured limit message, or null while running.
Parsed from the real "resets …" clause; null when the message states no reset time.
When the launcher last looked at reality (bumped on every recheck while halted).
What observed it: probe | spawn_probe | transcript | heartbeat | wake.
The founder's un-consumed Wake-now tap, if one is pending.
Eligible unclaimed Todo tickets (board_next_candidates), in founder order.
The title at the top of the queue, or null when it is empty.
AI sessions heard from inside the live window.
Of those, how many hold no in-flight work (their loop will absorb new tickets).
The active fleet focus, or null when none is pointed.
The pointed area slug (board_label_vocabulary), or null for a merchant focus.
The pointed store slug, or null for an area focus.
The display name at point time ("Gym"), what banners and prompts say.
The expanded predicate: sections half of the OR-union.
The expanded predicate: initiatives half.
The expanded predicate: add-ons half.
When the founder pointed it.
The target dial: how many developers the Mac runs by itself, and why it currently is not.
How many developers to run. 0 = nothing starts itself.
When the founder last hit Stop; null = never, or restarted since.
His words for the stop, if he gave any.
The honest live ceiling, launcher-written and pre-clamped to its hard cap. Null until the launcher has looked.
What binds the ceiling: 'disk' (free space) | 'cap' (the most this Mac runs at once).
Free space on the Developer volume at the last decision, the reason behind the ceiling.
When the launcher last measured the ceiling.
Why nothing started on the last tick that wanted to: halted|stopped|off|disk|at_target|no_work|load|memory|hard_cap|breaker|backoff|rate_cap|ramp. The app owns the human words.
When that refusal was last recorded. Advances on every refusing tick even when the reason is unchanged, so a stale card is distinguishable from a dead daemon.
The refusal SENTENCE beside the code, carrying the numbers only the Mac can see ("8.4 GB free leaves no room for another"). Evidence, not copy: the app still owns the friendly words for the code.
Auto-launch failures since a spawned session last said hello. At 3 the circuit breaker latches and only ONE probationary developer is tried per escalating gap (30m/1h/2h/4h), so a wall is waited out rather than hammered.
When the last auto-launch failure was recorded, read back by the launcher at startup so the breaker survives a daemon restart.
What actually failed, in a sentence, so a stopped fleet explains itself without anyone opening a log.
When the launcher last started one by itself.
curl -G "https://www.membber.com/api/v1/board/fleet" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/fleet");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardFleet().ok.body.json
print(response){
"fleet": {
"status": "running",
"message": "Added at the front desk",
"reset_at": "<reset_at>",
"observed_at": "<observed_at>",
"source": "<source>",
"wake_requested_at": "<wake_requested_at>",
"updated_at": "<updated_at>"
},
"queue": {
"waiting": -9007199254740991,
"top_title": "<top_title>",
"live_developers": -9007199254740991,
"idle_developers": -9007199254740991
},
"focus": {
"area": "<area>",
"merchant": "<merchant>",
"label": "<label>",
"sections": [
"<section>"
],
"initiatives": [
"<initiative>"
],
"addons": [
"<addon>"
],
"pointed_at": "<pointed_at>"
},
"target": {
"target_size": -9007199254740991,
"stopped_at": "<stopped_at>",
"stop_reason": "Added at the front desk",
"headroom_max": -9007199254740991,
"headroom_reason": "Added at the front desk",
"disk_free_gb": 1,
"headroom_observed_at": "<headroom_observed_at>",
"last_refusal": "<last_refusal>",
"last_refusal_at": "<last_refusal_at>",
"last_refusal_detail": "<last_refusal_detail>",
"consecutive_failures": -9007199254740991,
"last_failure_at": "<last_failure_at>",
"last_failure_detail": "<last_failure_detail>",
"last_auto_spawn_at": "<last_auto_spawn_at>",
"updated_at": "<updated_at>"
}
}One tap undoes the point: stamps released_at on the singleton, writes the fleet:focus 'released' activity row, and board_next_candidates immediately reverts to the standing founder ranking. Idempotent: releasing when nothing is pointed is a truthful no-op.
true = an active focus was released; false = there was none (a no-op, not an error).
curl -X DELETE "https://www.membber.com/api/v1/board/fleet/focus" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.DELETE("/api/v1/board/fleet/focus", {
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.releaseBoardFleetFocus().ok.body.json
print(response){
"released": true
}The founder's deliberate focus directive, NEVER fired automatically. Writes the board_fleet_focus singleton (area/merchant + display label + the predicate expanded from board_label_vocabulary.area_terms), writes a fleet:focus activity row, and board_next_candidates puts matching tickets FIRST while the row is active, the kit worker loop, server-side next and the queue whisper all inherit it through that one RPC. Re-pointing replaces the focus (idempotent for the same target). It never times out server-side; DELETE releases it.
An area slug from board_label_vocabulary (kind=area, non-complement).
A canonical store slug, point the fleet at one merchant instead.
The directive as recorded, what every developer now serves first.
The pointed area slug (board_label_vocabulary), or null for a merchant focus.
The pointed store slug, or null for an area focus.
The display name at point time ("Gym"), what banners and prompts say.
The expanded predicate: sections half of the OR-union.
The expanded predicate: initiatives half.
The expanded predicate: add-ons half.
When the founder pointed it.
curl -X POST "https://www.membber.com/api/v1/board/fleet/focus" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"area": "<area>",
"merchant": "<merchant>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/fleet/focus", {
body: {
area: "<area>",
merchant: "<merchant>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.pointBoardFleetFocus(
body: .json(.init(
area: "<area>",
merchant: "<merchant>"
))
).ok.body.json
print(response){
"focus": {
"area": "<area>",
"merchant": "<merchant>",
"label": "<label>",
"sections": [
"<section>"
],
"initiatives": [
"<initiative>"
],
"addons": [
"<addon>"
],
"pointed_at": "<pointed_at>"
}
}Writes the founder half of the board_fleet_target singleton (target_size / stopped_at / stop_reason) and records the change as a fleet:target activity row. The Mac launcher reads it on its next 15s tick and starts or stops accordingly, behind its own guards (free disk, a hard cap, a recorded halt, nothing-pickable, failure backoff, a ramp gap). 🔴 Stop (0) never kills a running developer, it stops NEW ones starting, so work in hand still lands. Setting a number above the launcher-observed headroom is refused with the real ceiling, never silently clamped.
How many developers the Mac should run by itself. 0 = Stop: nothing new starts.
Optional note recorded with a Stop (target_size 0), his words, for the activity row.
The dial as recorded, the truth the card renders.
How many developers to run. 0 = nothing starts itself.
When the founder last hit Stop; null = never, or restarted since.
His words for the stop, if he gave any.
The honest live ceiling, launcher-written and pre-clamped to its hard cap. Null until the launcher has looked.
What binds the ceiling: 'disk' (free space) | 'cap' (the most this Mac runs at once).
Free space on the Developer volume at the last decision, the reason behind the ceiling.
When the launcher last measured the ceiling.
Why nothing started on the last tick that wanted to: halted|stopped|off|disk|at_target|no_work|load|memory|hard_cap|breaker|backoff|rate_cap|ramp. The app owns the human words.
When that refusal was last recorded. Advances on every refusing tick even when the reason is unchanged, so a stale card is distinguishable from a dead daemon.
The refusal SENTENCE beside the code, carrying the numbers only the Mac can see ("8.4 GB free leaves no room for another"). Evidence, not copy: the app still owns the friendly words for the code.
Auto-launch failures since a spawned session last said hello. At 3 the circuit breaker latches and only ONE probationary developer is tried per escalating gap (30m/1h/2h/4h), so a wall is waited out rather than hammered.
When the last auto-launch failure was recorded, read back by the launcher at startup so the breaker survives a daemon restart.
What actually failed, in a sentence, so a stopped fleet explains itself without anyone opening a log.
When the launcher last started one by itself.
curl -X POST "https://www.membber.com/api/v1/board/fleet/target" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"target_size": 0
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/fleet/target", {
body: {
target_size: 0
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.setBoardFleetTarget(
body: .json(.init(
targetSize: 0
))
).ok.body.json
print(response){
"target": {
"target_size": -9007199254740991,
"stopped_at": "<stopped_at>",
"stop_reason": "Added at the front desk",
"headroom_max": -9007199254740991,
"headroom_reason": "Added at the front desk",
"disk_free_gb": 1,
"headroom_observed_at": "<headroom_observed_at>",
"last_refusal": "<last_refusal>",
"last_refusal_at": "<last_refusal_at>",
"last_refusal_detail": "<last_refusal_detail>",
"consecutive_failures": -9007199254740991,
"last_failure_at": "<last_failure_at>",
"last_failure_detail": "<last_failure_detail>",
"last_auto_spawn_at": "<last_auto_spawn_at>",
"updated_at": "<updated_at>"
}
}Idempotent request marker, stamps wake_requested_at on the board_fleet_status singleton; the launcher consumes it, probes the claude CLI for real, and clears the halt only on an observed success. Safe to tap repeatedly: while genuinely still limited the probe is refused (costing nothing) and the halt stands. Wake itself never spawns anything: it only asks the launcher to look. Since 19 Aug 2026 the launcher DOES refill the fleet by itself once a halt clears, up to the board_fleet_target dial (default 0 = nothing auto-starts) and behind its own guards.
running = work can flow; halted_limit = the Mac CLI is refusing work on a usage limit.
runninghalted_limitThe verbatim captured limit message, or null while running.
Parsed from the real "resets …" clause; null when the message states no reset time.
When the launcher last looked at reality (bumped on every recheck while halted).
What observed it: probe | spawn_probe | transcript | heartbeat | wake.
The founder's un-consumed Wake-now tap, if one is pending.
curl -X POST "https://www.membber.com/api/v1/board/fleet/wake" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/fleet/wake", {
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.wakeBoardFleet().ok.body.json
print(response){
"fleet": {
"status": "running",
"message": "Added at the front desk",
"reset_at": "<reset_at>",
"observed_at": "<observed_at>",
"source": "<source>",
"wake_requested_at": "<wake_requested_at>",
"updated_at": "<updated_at>"
}
}The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -G "https://www.membber.com/api/v1/board/inbox" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/inbox");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardInbox().ok.body.json
print(response){
"inbox": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
]
}Two callers, one row. A THREAD raising a DECISION goes through the atomic board_inbox_raise RPC (write + board_activity in one statement); when options are present the founder's HQ devices also get an hq_decision push carrying the option keys and decision_kind. decision_kind (single | multi | rank, default single, since 20260816_110) picks how the founder answers: single is one tap, multi lets him tick several, rank has him drag the whole list into order, multi/rank REQUIRE options, rank needs at least 2. The FOUNDER raising a BUG (R19, "extreme importance") goes through board_raise_inbox instead, so the item and the screenshots that made him raise it land in ONE write, a second call that failed would leave a bug with no screenshot, or a screenshot with no bug. Idempotent either way: source_key for a decision, client_request_id for a bug.
One-line question/headline the founder sees first.
The DECISION BRIEF as prose: what we are doing, the scene, and what each option gives up. Optional for an R19 bug, a title (and its screenshots) alone is a complete report.
Decision options. Omit for a plain inbox update that needs no choice.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommendation, must be one of the option keys.
How the founder should answer: single (default, one tap) | multi (tick several) | rank (drag every option into order). multi/rank REQUIRE options; rank requires at least 2.
singlemultirankboard_sessions.id of the asking thread, so the answer can find its way back.
Optional link to the board ticket this decision blocks.
Stable idempotency handle: a retried raise with the same source_key UPDATES the existing row instead of filing a duplicate (same mechanism the board-snapshot sync uses).
R19: where on the board this belongs, in his words.
R19: how urgent he thinks it is.
The ticket this re-opens, when he is reporting that something shipped is broken again.
R19: the screenshots that made him raise it. They land in the SAME write as the item.
`data:image/png;base64,...`. PNG, JPEG, HEIC, WebP or GIF, up to 15 MB decoded.
What the screenshot shows, in his words.
Defaults to 'screenshot'.
screenshotimageIdempotency key, same pattern as the messaging architecture's client_message_id. A retry with the same id returns the ORIGINAL attachment and writes no second history line, enforced by a unique index rather than promised in a comment.
What this row IS. "decision" is answerable and MUST carry >= 2 options, a recommendation and a brief; "note" is an update that asks him for nothing. Explicit rather than inferred from shape, so a plain update can never be promoted into his decision queue by accident. A diagnostic is not a valid value at all, machine signals go to the hang endpoint.
decisionnoteDECISION-BRIEF law step 2: a named person doing a real thing, so he does not have to construct the context himself. Required when kind is "decision".
Why this is HIS call rather than the thread's. A question that cannot name one of these is not a founder decision, decide it and carry on. Required when kind is "decision".
tastemoneylegalpriorityirreversibleThe HQ app screen that was frontmost when this was raised. Used SERVER-SIDE to file the row so he is never asked "where does this belong?", it is the strongest signal we hold.
Idempotency key for the R19 bug path (stored as board_inbox.source_key, which carries a unique index). A retry after a dropped response returns the ORIGINAL item rather than filing the same bug twice. `source_key` is the same handle under the decision path's name.
The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -X POST "https://www.membber.com/api/v1/board/inbox" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"title": "Morning class"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox", {
body: {
title: "Morning class"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.raiseBoardDecision(
body: .json(.init(
title: "Morning class"
))
).ok.body.json
print(response){
"inbox_item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
}Atomic board_inbox_answer RPC: writes answer + answered_at + status=answered and logs activity under inbox:<id> AND session:<asking_session_id> so the asking thread can find it. Idempotent, the SAME answer again returns ok; a DIFFERENT answer to an answered decision is a 409 (the first call stands). Kind-aware since rich decisions (20260816_110): single takes option_key, multi/rank take option_keys (multi = the picked set, rank = a full permutation of every option, best first).
single: the chosen option key (one of the decision's options).
multi: the picked set (≥1, every key must be one of the options, no duplicates). rank: the FULL order of every option key, best first, a partial list, an unknown key, or a repeat is refused by the RPC (NOT_A_PERMUTATION / UNKNOWN_OPTION / DUPLICATE_OPTION).
Free-text answer, or a rider alongside the chosen option(s).
The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -X POST "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/answer" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox/{id}/answer", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
option_key: "<option_key>",
option_keys: [
"<option_key>"
],
text: "<text>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.answerBoardInbox(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
optionKey: "<option_key>",
optionKeys: ["<option_key>"],
text: "<text>"
))
).ok.body.json
print(response){
"inbox_item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
}The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
curl -G "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/attachments" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/inbox/{id}/attachments", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardInboxAttachments(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
]
}R19/R20. The one-shot path is raiseBoardDecision, which carries the screenshots with the bug. This is for adding one afterwards, when he spots a second symptom of something he has already filed.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
curl -X POST "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/attachments" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '"<value>"'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox/{id}/attachments", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: "<value>",
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.attachBoardInboxMedia(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json("<value>")
).ok.body.json
print(response){
"attachment": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
}Step 1 of the large-media path for a bug already raised: PUT the bytes to `signed_url`, then POST …/attachments with `storage_path` to register them. Same shape as the ticket variant.
What the bytes are. Drives validation + how the app renders the tile.
imagevideofileThe file's MIME type (e.g. video/quicktime, application/pdf).
Size of the upload in bytes. Images cap at 15 MB, videos/files at 50 MB (the storage ceiling).
Original filename, for display on the ticket.
The object path the upload MUST land at, registration refuses any other.
Signed-upload token (storage `upload/sign`).
Absolute URL to PUT the bytes to.
curl -X POST "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/attachments/upload-url" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"kind": "image",
"content_type": "<content_type>",
"byte_size": 1
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox/{id}/attachments/upload-url", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
kind: "image",
content_type: "<content_type>",
byte_size: 1
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.createBoardInboxUploadUrl(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
kind: .image,
contentType: "<content_type>",
byteSize: 1
))
).ok.body.json
print(response){
"path": "<path>",
"token": "<token>",
"signed_url": "https://example.com/image.jpg"
}Atomic board_inbox_chat RPC: appends one {text, by, at} entry to `chat` and logs activity under inbox:<id> AND session:<asking_session_id>, so the asking thread's `board-thread.mjs answers` surfaces it as a kind:'chat' line. Deliberately never touches answer/answered_at/status, the decision stays open, exactly the founder's "ask a question back" case from the CLI AskUserQuestion exemplar.
The question / message for the raising thread.
The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -X POST "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/chat" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"text": "<text>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox/{id}/chat", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
text: "<text>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.chatBoardInbox(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
text: "<text>"
))
).ok.body.json
print(response){
"inbox_item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
}The section to file this inbox item under.
The priority to assign to this inbox item.
The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -X POST "https://www.membber.com/api/v1/board/inbox/00000d1b-0000-4000-8000-d0c500000000/triage" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"section": "<section>",
"priority": "<priority>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/inbox/{id}/triage", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
section: "<section>",
priority: "<priority>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.triageBoardInbox(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
section: "<section>",
priority: "<priority>"
))
).ok.body.json
print(response){
"inbox_item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
}Worker threads may read this when picking what to build next: sections earlier in the list matter more to the founder right now. The HQ app renders its Sections grouping and Progress surface in this order.
The founder's priority order of the board sections.
curl -G "https://www.membber.com/api/v1/board/sections/order" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/sections/order");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardSectionOrder().ok.body.json
print(response){
"order": [
"<order>"
]
}The full section list in the new priority order (deduplicated server-side).
The founder's priority order of the board sections.
curl -X PUT "https://www.membber.com/api/v1/board/sections/order" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"order": [
"<order>"
]
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PUT("/api/v1/board/sections/order", {
body: {
order: [
"<order>"
]
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.setBoardSectionOrder(
body: .json(.init(
order: ["<order>"]
))
).ok.body.json
print(response){
"order": [
"<order>"
]
}Verifies the email/password against Supabase auth, then checks the founder allowlist. On success mints a short-lived operator token. A correct password for a non-allow-listed address is a refusal (403), not a login.
The operator email (verified against Supabase auth).
The account password, verified against Supabase, never stored here.
The operator bearer token to send as Authorization: Bearer on every board call.
The verified, allow-listed operator email.
ISO timestamp the token expires.
curl -X POST "https://www.membber.com/api/v1/board/session" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"email": "alex@example.com",
"password": "<password>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/session", {
body: {
email: "alex@example.com",
password: "<password>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.boardLogin(
body: .json(.init(
email: "alex@example.com",
password: "<password>"
))
).ok.body.json
print(response){
"token": "<token>",
"me": "<me>",
"expiresAt": "<expiresAt>"
}Verifies the Apple identity token fully (RS256 signature against Apple's JWKS, issuer, audience com.membber.hq, expiry), then checks the founder allowlist on the verified email. A missing / relay / non-allow-listed email is a 403 that echoes the email + Apple subject it saw, so the founder can add it. On success mints the same operator token.
The Sign in with Apple identity token (JWT) returned to the app.
The operator bearer token to send as Authorization: Bearer on every board call.
The verified, allow-listed operator email.
ISO timestamp the token expires.
curl -X POST "https://www.membber.com/api/v1/board/session/apple" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"identityToken": "<identityToken>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/session/apple", {
body: {
identityToken: "<identityToken>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.boardLoginApple(
body: .json(.init(
identityToken: "<identityToken>"
))
).ok.body.json
print(response){
"token": "<token>",
"me": "<me>",
"expiresAt": "<expiresAt>"
}Verifies the Google ID token fully (RS256 signature against Google's JWKS via jwks-rsa, issuer accounts.google.com, audience the Membber Google client id, expiry) using the SAME production verifier the App Clip customer Google sign-in uses, src/lib/auth/googleAuth.ts verifyGoogleIdToken. Then checks the founder allowlist on the verified email. A missing / unverified / non-allow-listed email is a 403 that echoes the email + Google subject it saw, so the founder can add it. On success mints the same operator token.
The Google ID token (JWT) returned to the app.
The operator bearer token to send as Authorization: Bearer on every board call.
The verified, allow-listed operator email.
ISO timestamp the token expires.
curl -X POST "https://www.membber.com/api/v1/board/session/google" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"credential": "<credential>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/session/google", {
body: {
credential: "<credential>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.boardLoginGoogle(
body: .json(.init(
credential: "<credential>"
))
).ok.body.json
print(response){
"token": "<token>",
"me": "<me>",
"expiresAt": "<expiresAt>"
}R24-R26. A thread is a developer, so this is the team list: who is live, on what branch, and how many tickets each has in To-do, Doing, Review, Testing and Done.
Default TRUE, which returns finished threads too. The "Live threads" filter does not need them, but a session picker does: a ticket can still be assigned to a thread that is currently asleep. Pass false for live-only, which is the same thing live_only=true asks for.
R24/R25: only threads that are live and not finished. Equivalent to include_finished=false.
How long without a heartbeat before a thread stops counting as live. Default 20.
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
curl -G "https://www.membber.com/api/v1/board/sessions" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/sessions");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardSessions().ok.body.json
print(response){
"sessions": [
{
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
}
]
}R25: the live filter is "live and NOT finished". Idempotent, and the FIRST finish time is kept, so a repeated call does not quietly move the clock. A later heartbeat revives the thread.
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
curl -X POST "https://www.membber.com/api/v1/board/sessions/00000d1b-0000-4000-8000-d0c500000000/finish" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/sessions/{id}/finish", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.finishBoardSession(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"session": {
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
}
}The zero-tickets fix (17 Aug 2026). The roster join (board_tickets.session_id) only knows live ownership, 23 of 608 tickets, while the real record lives in content_source, board_activity, board_comments, board_ticket_views and branch matches. This unions ALL of them (board_session_history RPC) so the Developers detail can show what a thread actually did. Aggregates are computed from the same rows: nothing here is ever invented (no-fake-data law).
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
Every ticket with any real linkage, newest event first. Empty = genuinely no trace, never hidden work.
Resolved column (founder overlay wins), so "Finished" is the truth.
The initiative label when classified (platform, click-collect, gym, …).
Every way this ticket ties to the developer: held (session_id / assignee label) · filed (content_source) · activity (any activity row) · commented · viewed · branch. Real rows only.
Distinct activity verbs this developer performed on it: filed, moved, claimed, assigned, …
Distinct sections over the linked tickets, most-touched first.
Distinct initiatives over the linked tickets, most-touched first.
The developer's earliest recorded event, with last_seen_at it is the honest activity span.
curl -G "https://www.membber.com/api/v1/board/sessions/00000d1b-0000-4000-8000-d0c500000000/history" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/sessions/{id}/history", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardSessionHistory(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"session": {
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
},
"history": [
{
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"section": "<section>",
"initiative": "<initiative>",
"reopen_count": 1,
"linkage": [
"<linkage>"
],
"verbs": [
"<verb>"
],
"first_event_at": "<first_event_at>",
"last_event_at": "<last_event_at>",
"view_count": 1
}
],
"filed_count": 1,
"finished_count": 1,
"sections": [
"<section>"
],
"initiatives": [
"<initiative>"
],
"first_activity_at": "<first_activity_at>"
}R21-R23. Answered the way a developer would answer it: finish what you are already holding; else do what you were named on; else take from the AI pool; else take the top of the pick-next ranking. Both pool and ranking come from the same candidate view, so the dependency gate and the founder ordering always apply. Returns a real null with a reason rather than inventing something to do.
R23: how many tickets one thread may hold in Doing/Review/Testing at once. Default 1. A DEFAULT, not a lock: at the limit this returns no ticket and says why, but nothing stops a thread taking one anyway.
The ticket to pick up, or null with a reason saying why not.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
Which bucket it came from. 'assigned' = this thread was named on it. 'ai_pool' = marked for AI, no thread named. 'unclaimed' = the ordinary pick-next ranking. 'triage' = a founder capture waiting to be SHAPED, not claimed: read his words (capture_text + attachments), then call triageBoardTicket with the full brief. A thread should know whether it was ASKED.
assignedai_poolunclaimedtriageokneeds_triageat_wip_limitnothing_to_doWhat this thread already holds in Doing, Review or Testing.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicablecurl -G "https://www.membber.com/api/v1/board/sessions/00000d1b-0000-4000-8000-d0c500000000/next" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/sessions/{id}/next", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardSessionNext(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
},
"source": "assigned",
"reason": "ok",
"in_flight": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"max_in_flight": -9007199254740991
}Tickets this developer has opened, most recent first. Titles resolve client-side from the loaded board.
curl -G "https://www.membber.com/api/v1/board/sessions/00000d1b-0000-4000-8000-d0c500000000/views" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/sessions/{id}/views", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardSessionViews(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"views": [
{
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"last_viewed_at": "<last_viewed_at>",
"view_count": 1
}
]
}R22: the founder needs to see threads working as it happens, so a thread checks in on startup and then periodically. This is also the ONLY way a session comes into existence: every other route refuses to write a session_id it has never seen, so a ticket can never be owned by a thread that does not exist.
The thread's stable id. The same thread must send the same id every time.
A human-readable name for the thread, e.g. "board redesign, API". Never blanked once known.
The worktree it is working in.
The branch it is working on.
Defaults to 'ai'.
aihumanThe mission this thread carries, in plain words. Coalesce-preserving like label: never blanked once known.
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
curl -X POST "https://www.membber.com/api/v1/board/sessions/heartbeat" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"session_id": "630ddf64-0000-4000-8000-d0c500000063"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/sessions/heartbeat", {
body: {
session_id: "630ddf64-0000-4000-8000-d0c500000063"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.boardSessionHeartbeat(
body: .json(.init(
sessionId: "630ddf64-0000-4000-8000-d0c500000063"
))
).ok.body.json
print(response){
"session": {
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
}
}pending | launching | launched | failed | cancelled
curl -G "https://www.membber.com/api/v1/board/spawn" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/spawn");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardSpawnRequests().ok.body.json
print(response){
"requests": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"created_at": "<created_at>",
"requested_by": "<requested_by>",
"section": "<section>",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"instructions": "<instructions>",
"status": "<status>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"worktree": "<worktree>",
"branch": "<branch>",
"error": "<error>"
}
]
}Inserts a pending row in board_spawn_requests (service-role-only table). The launcher daemon on the founder's Mac, installed by HIM (FA055); the phone cannot execute anything directly, claims it atomically, creates a fresh worktree, and starts a visible Claude session that registers on the roster via the thread kit. Capped at 3 launches per rolling hour on the Mac side.
Section the new developer should work (e.g. hq, payments).
A specific ticket to put the developer on.
The founder's brief, in his words.
Per-tap idempotency key: a retry returns the first request instead of queueing a twin.
pending | launching | launched | failed | cancelled
curl -X POST "https://www.membber.com/api/v1/board/spawn" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"section": "<section>",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"instructions": "<instructions>",
"client_request_id": "8e99e5bf-0000-4000-8000-d0c50000008e"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/spawn", {
body: {
section: "<section>",
ticket_id: "fbc29b2e-0000-4000-8000-d0c5000000fb",
instructions: "<instructions>",
client_request_id: "8e99e5bf-0000-4000-8000-d0c50000008e"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.spawnBoardDeveloper(
body: .json(.init(
section: "<section>",
ticketId: "fbc29b2e-0000-4000-8000-d0c5000000fb",
instructions: "<instructions>",
clientRequestId: "8e99e5bf-0000-4000-8000-d0c50000008e"
))
).ok.body.json
print(response){
"request": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"created_at": "<created_at>",
"requested_by": "<requested_by>",
"section": "<section>",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"instructions": "<instructions>",
"status": "<status>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"worktree": "<worktree>",
"branch": "<branch>",
"error": "<error>"
}
}Filter to a resolved column (e.g. todo, doing, review, done, blocked).
Filter to a resolved type (bug, feature, task).
Filter to a section (payments, verticals, business, customer, platform).
Board labels (17 Aug 2026): filter to one initiative, gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform ('platform' = cross-cutting work).
Board labels: filter to tickets about one store, by canonical slug (busy-beans, tc60, flour-bloom, …).
Board labels: filter to tickets touching one add-on, order_collect | bookings | gym_management | own_app | moments | retention_engine. Matches rows whose addons array contains it.
Focus-first filter (18 Aug 2026): ONE area slug, a named predicate from board_label_vocabulary expanding server-side to an OR-union over sections / initiatives / addons (e.g. gym = initiative gym OR addon gym_management; 'everything-else' = the complement no other area claims). The list includes matching done history; label_facets.areas carries the open-only counts. Vocabulary kept permissive: a new area is one board_label_vocabulary row, no migration.
Free-text match against title, raw title, why, id, branch and assignee.
R24: only the tickets one thread is holding. Pair with GET /sessions to build the filter.
R4-R7: 'human' = owned by a person, 'ai' = owned by AI (a named thread OR the pool), 'unassigned' = nobody has it, which R7 calls "not being picked up yet".
humanaiunassignedR17: only the sub-tasks of one ticket.
"Yours" (19 Aug 2026): pass mine=1 for only the tickets that are HIS, what he filed (quick capture, the ⊕ New-ticket sheet, or a bug he raised that was later filed) AND his own errand list. Same predicate as the founder_owned field on every row, which is wider than created_by_founder and never narrower. Absent = the whole board.
1trueThe founder's ask (19 Aug 2026): work_state=working_now is "what is being worked on right now"; work_state=abandoned_was_held makes stalled work findable. Combine with `prio` for "high priority work being worked right now" in one tap.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableOne priority, or several comma-separated ("critical,high,urgent"). A LIST because `urgent` is a legacy synonym still live on real rows and board_next_candidates already ranks critical and high/urgent separately, a single-value filter silently misses rows, the exact omission that made a CRITICAL founder capture sort as normal before 19 Aug.
Page size (default 50, max 200).
Opaque keyset cursor from a previous page.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
Cursor for the next page, or null when the list is exhausted.
Whole-board label inventory + vocabulary, computed over every resolved row BEFORE filters or pagination, so a filter UI can offer every label the board carries with its true count, never just the values inside one fetched page. Excludes source=founder-action rows (the HQ board hides them). Every chip number on the HQ filter panel comes from this one server computation.
OPEN (col ≠ done) non-founder-action tickets. Counting regime (18 Aug 2026): every label/area count here is open work only, while the LIST a filter produces still includes matching done history, "counts = open only, list = everything matching".
initiative slug → open-ticket count.
merchant slug → open-ticket count.
add-on slug → open tickets whose addons array contains it.
area slug → open-ticket count. Areas are named predicates over the existing columns and OVERLAP by design (a gym booking fix sits in two areas), per-chip counts, never a partition.
resolved column → ticket count, done INCLUDED under its own key (the Stage facet is where history is deliberately reachable; its Done number is the real history size).
session id → open tickets that thread holds.
Open tickets marked for AI with no thread holding them yet.
Open tickets nothing is holding at all.
Open tickets that are HIS, the count behind the "Yours" facet, and the ONLY count here that includes his errand list. Every other number on this object measures what the FLEET is doing and deliberately skips founder-action rows; this one cannot, or the chip would say a different number from the list it opens. Matches founder_owned.
work_state → count, over OPEN work only, the same regime as every other count here. This is what makes "Working now" and "Abandoned" real chips with real numbers rather than labels the app has to guess at.
The label vocabulary (board_label_vocabulary): names, order and area predicates. A new initiative/merchant/area is ONE row there and appears here on the next response.
The display name a chip renders, server-named so the vocabulary grows with zero app changes.
Canonical sort order within its kind.
Areas only: the OR-union predicate, mirrored client-side for the instant local match.
true = "Everything else": matches what no other area claims.
The display name a chip renders, server-named so the vocabulary grows with zero app changes.
Canonical sort order within its kind.
Areas only: the OR-union predicate, mirrored client-side for the instant local match.
true = "Everything else": matches what no other area claims.
The display name a chip renders, server-named so the vocabulary grows with zero app changes.
Canonical sort order within its kind.
Areas only: the OR-union predicate, mirrored client-side for the instant local match.
true = "Everything else": matches what no other area claims.
The display name a chip renders, server-named so the vocabulary grows with zero app changes.
Canonical sort order within its kind.
Areas only: the OR-union predicate, mirrored client-side for the instant local match.
true = "Everything else": matches what no other area claims.
curl -G "https://www.membber.com/api/v1/board/tickets" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/tickets");
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardTickets().ok.body.json
print(response){
"tickets": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
null
]
}
],
"generated_at": "<generated_at>"
}
}
],
"next_cursor": "<next_cursor>",
"label_facets": {
"total": -9007199254740991,
"initiatives": {},
"merchants": {},
"addons": {},
"areas": {},
"stages": {},
"sessions": {},
"pool": -9007199254740991,
"unassigned": -9007199254740991,
"mine": -9007199254740991,
"work_states": {},
"vocabulary": {
"initiatives": [
{
"slug": "<slug>",
"name": "Example name",
"ord": -9007199254740991,
"terms": {
"sections": [
null
],
"initiatives": [
null
],
"addons": [
null
],
"complement": true
}
}
],
"addons": [
{
"slug": "<slug>",
"name": "Example name",
"ord": -9007199254740991,
"terms": {
"sections": [
null
],
"initiatives": [
null
],
"addons": [
null
],
"complement": true
}
}
],
"merchants": [
{
"slug": "<slug>",
"name": "Example name",
"ord": -9007199254740991,
"terms": {
"sections": [
null
],
"initiatives": [
null
],
"addons": [
null
],
"complement": true
}
}
],
"areas": [
{
"slug": "<slug>",
"name": "Example name",
"ord": -9007199254740991,
"terms": {
"sections": [
null
],
"initiatives": [
null
],
"addons": [
null
],
"complement": true
}
}
]
}
}
}Inserts source='manual', content_source='founder', col='todo', id `M-<slug-of-title>`. Idempotent on the slug: re-filing the same title refreshes that ticket's content fields and never touches col/assignee/session, a ticket someone already holds is never moved. Same NO-BLANKS validation as scripts/board-thread.mjs file-ticket.
The ticket title, becomes the M-<slug> id.
Which initiative this belongs to (same set file-ticket accepts).
customerbusinesspaymentsverticalsplatformhqR2: task | bug | feature | epic | chore.
taskbugfeatureepicchoreThe board priority scale.
criticalhighnormallowNO-BLANKS law: why this exists, in real words (≥20 chars, mandatory).
NO-BLANKS law: what the change does (≥20 chars, mandatory). Stored as what_it_does.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"title": "Morning class",
"section": "customer",
"type": "task",
"priority": "critical",
"why": "<why>",
"what": "<what>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets", {
body: {
title: "Morning class",
section: "customer",
type: "task",
priority: "critical",
why: "<why>",
what: "<what>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.createBoardTicket(
body: .json(.init(
title: "Morning class",
section: .customer,
type: .task,
priority: .critical,
why: "<why>",
what: "<what>"
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}R13-R15, the heart of the board redesign. Returns the ticket, its comments, its full history and its attachments, plus two deliberately asymmetric views of the same work: `human_context` is one short plain-English line saying what needs doing, and `ai_context` / `ai_context_markdown` is everything, assembled server-side, so an agent needs no other source.
Every column, PLUS the rendered `ai_context` bundle the sheet draws.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
Pre-denormalised media stored on the comment (may be empty).
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
R16/R17: every link resolved to a title and a column, so all four are tappable.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableSo a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableSo a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableWhat is waiting on THIS ticket.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableR5: the thread holding it, with live status and WIP. Null when nobody is.
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
R13/R14: the SHORT plain-English line, for a person. The authored one if there is one, else the best REAL line from the ticket's own fields, trimmed but never rewritten. Null when there is genuinely nothing, so the app shows a designed blank instead of an invented sentence.
Where human_context came from, so the UI can never pass a PR summary off as the founder's own words. Null exactly when human_context is null.
authoredsummarywhywhat_it_doesuser_storyResolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneNull when no pull request is linked. That is a real fact, not a missing field.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableR17: the sub-tasks, resolved to titles and columns.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableR16: what this is waiting on, resolved, not bare ids.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableWhat is waiting on THIS one, i.e. what it is blocking.
So a link can show the reopened badge without a second fetch.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneSo a linked sub-task or dependency can show that somebody is on it without a second fetch. Same computed field, same one definition.
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableR18: every comment, oldest first.
Pre-denormalised media stored on the comment (may be empty).
The FULL history of this ticket, oldest first.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
The thread that owns it, with its live status and WIP.
The human-readable name of the thread, or null if it never sent one.
'ai' or 'human'.
R24: not finished AND seen inside the staleness window. This is what "a live session" means.
R25: how many tickets this thread holds in Doing.
The mission this thread carries, in the words it was given ("fix click and collect bugs"). From the kit's `hello --goal`, or the spawn request's instructions for a phone-spawned thread. Null when never stated, a client renders nothing, never an invented mission.
Tickets this developer FILED, counted across every real linkage (content_source `thread:<label>` + activity `filed` rows), not just live session_id ownership. The zero-tickets fix (17 Aug 2026): 28 of 38 developers read zero under the session_id-only join while 27 of them had real history on other linkages.
Linked tickets now sitting in Done, across every linkage.
Distinct tickets with ANY real linkage to this developer (held / filed / touched / branch). Views excluded.
The ownership-and-movement slice of the history, so who-had-this-when is legible at a glance.
R15: the same bundle pre-rendered as one markdown string an agent can paste straight into its own context and start work from, with nothing else needed.
Who has OPENED this ticket (not just commented/moved), the surgical activity ask, 15 Aug 2026.
Stable machine key: the operator email (human) or the session id (AI). Render names from the roster.
'ai' or 'human'.
Every decision raised AGAINST this ticket (board_inbox rows whose ticket_id is this id), oldest first, the decision thread of the ticket story, for the history timeline. Sourced from board_inbox directly, never from board_activity (whose inbox rows carry ticket_id as `inbox:<uuid>` and would be silently missed).
The ticket id this inbox item re-opens, or null for a fresh item.
'untriaged', 'triaged' or 'answered'.
R19: the screenshots raised WITH it, before it is triaged into a ticket.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
Decision options, or null for a plain (non-decision) inbox item.
Stable machine key for this option (never shown to the founder).
The plain-English choice the founder reads and taps.
Optional one-line consequence under the label (DECISION-BRIEF step 4).
The asking thread's recommended option key, or null.
board_sessions.id of the thread that asked, so the answer can find its way back.
How the founder answers this item: single (default) | multi | rank. Meaningless on a plain, option-less inbox item, but always present (defaults to single at the DB layer).
singlemultirankThe founder's answer, or null while the decision is open.
The kind this was ANSWERED under, the truth of what was answered even if the item is later re-raised under a different decision_kind. Absent on rows answered before rich decisions shipped.
singlemultiranksingle: the chosen option key.
multi: the picked set (order not meaningful). rank: the FULL order of every option key, best first.
Free-text answer / rider, when given.
The verified operator email that answered, never client-supplied.
The founder asking the raising thread a question WITHOUT resolving the decision (board_inbox_chat). Null = no chat yet. Never touches answer/answered_at/status.
Who sent it, the verified operator email, or the asking thread's session id.
ISO timestamp.
Optional link to the board ticket this decision blocks.
curl -G "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/tickets/{id}", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.getBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
},
"comments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"author": "<author>",
"body": "<body>",
"media": [
{}
],
"created_at": "<created_at>"
}
],
"activity": [
{
"id": 1,
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"actor": "<actor>",
"verb": "<verb>",
"detail": "<detail>",
"created_at": "<created_at>"
}
],
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"links": {
"parent": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
},
"subtasks": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"depends_on": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"blocks": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
]
},
"session": {
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
},
"human_context": "<human_context>",
"human_context_source": "authored",
"ai_context": {
"generated_at": "<generated_at>",
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
null
]
}
],
"generated_at": "<generated_at>"
}
},
"lifecycle": "awaiting_triage",
"pr": {
"number": -9007199254740991,
"state": "<state>",
"branch": "<branch>",
"url": "https://example.com/image.jpg"
},
"epic": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"color": "<color>"
},
"parent": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
},
"children": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"dependencies": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"dependents": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"col": "<col>",
"type": "<type>",
"prio": "<prio>",
"reopen_count": 1,
"lifecycle": "awaiting_triage",
"work_state": "working_now"
}
],
"comments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"author": "<author>",
"body": "<body>",
"media": [
{}
],
"created_at": "<created_at>"
}
],
"activity": [
{
"id": 1,
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"actor": "<actor>",
"verb": "<verb>",
"detail": "<detail>",
"created_at": "<created_at>"
}
],
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"session": {
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"label": "<label>",
"kind": "<kind>",
"branch": "<branch>",
"worktree": "<worktree>",
"started_at": "<started_at>",
"last_seen_at": "<last_seen_at>",
"finished_at": "<finished_at>",
"is_live": true,
"ticket_ids": [
"fbc29b2e-0000-4000-8000-d0c5000000fb"
],
"in_progress": -9007199254740991,
"in_review": -9007199254740991,
"testing": -9007199254740991,
"todo": -9007199254740991,
"done": -9007199254740991,
"goal": "<goal>",
"filed_count": 1,
"finished_count": 1,
"history_count": 1
},
"assignment_history": [
{
"id": 1,
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"actor": "<actor>",
"verb": "<verb>",
"detail": "<detail>",
"created_at": "<created_at>"
}
],
"labels": [
"<label>"
],
"notes": "Added at the front desk"
},
"ai_context_markdown": "<ai_context_markdown>",
"viewers": [
{
"actor": "<actor>",
"kind": "<kind>",
"first_viewed_at": "<first_viewed_at>",
"last_viewed_at": "<last_viewed_at>",
"view_count": 1
}
],
"decisions": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"title": "Morning class",
"body": "<body>",
"section_hint": "<section_hint>",
"priority_hint": "<priority_hint>",
"reopen_of": "<reopen_of>",
"status": "<status>",
"created_at": "<created_at>",
"triaged_at": "<triaged_at>",
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
],
"options": [
{
"key": "<key>",
"label": "<label>",
"detail": "<detail>"
}
],
"recommended_option": "<recommended_option>",
"asking_session_id": "50436e9a-0000-4000-8000-d0c500000050",
"decision_kind": "single",
"answer": {
"kind": "single",
"option_key": "<option_key>",
"option_keys": [
"<option_key>"
],
"text": "<text>",
"answered_by": "<answered_by>"
},
"answered_at": "<answered_at>",
"chat": [
{
"text": "<text>",
"by": "<by>",
"at": "<at>"
}
],
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb"
}
]
}Partial patch: a key PRESENT sets it (null clears), a key ABSENT is left untouched. The first six keys are the founder overlay; human_context, parent_id and depends_on are structural facts about the work and land on the ticket itself. summary / why / what_it_does / user_story / section are founder field OVERRIDES stored beside the overlay (sync-proof by construction); initiative / merchant are label corrections the sync already never overwrites.
Founder title override; null clears it.
R2: the ticket type; null clears the override.
taskbugfeatureepicchoreFull label set; null or omitted leaves it untouched.
R14: the SHORT plain-English line, in his words. Empty or null clears it and the read falls back to deriving one from the ticket's own fields.
R17: make this a sub-task of another ticket; null detaches it. Refused if the parent does not exist, is the ticket itself, or already sits below it (that would be a cycle).
R16: the FULL set of ticket ids this one waits on, replacing whatever was there. Every id must be a real ticket, so a dependency link always leads somewhere.
Founder 'In short' override (15-140 chars, the kit's own bounds); null clears it back to the synced line.
Founder why-this-exists override (≥20 chars, the NO-BLANKS law); null reverts to the synced text.
Founder what-it-does override (≥20 chars); null reverts to the synced text.
Founder user-story / done-when override; null reverts to the synced text.
Founder section filing override (same set file-ticket accepts); null reverts to the synced section.
customerbusinesspaymentsverticalsplatformhqThe ONE initiative this ticket serves ('platform' = honest cross-cutting none).
gymclick-collectbookingsrestaurantsalonbarberloyaltyplatformCanonical store slug (busy-beans, tc60, flour-bloom, …) when the work is about ONE store.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X PATCH "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"overlay": {
"display_title": "<display_title>",
"notes": "Added at the front desk",
"type_override": "task",
"prio_override": "<prio_override>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"labels": [
"<label>"
],
"human_context": "<human_context>",
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"depends_on": [
"<depends_on>"
],
"summary": "<summary>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"section": "customer",
"initiative": "gym",
"merchant": "<merchant>"
}
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PATCH("/api/v1/board/tickets/{id}", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
overlay: {
display_title: "<display_title>",
notes: "Added at the front desk",
type_override: "task",
prio_override: "<prio_override>",
epic_id: "a3ea3c95-0000-4000-8000-d0c5000000a3",
labels: [
"<label>"
],
human_context: "<human_context>",
parent_id: "7b66b0d0-0000-4000-8000-d0c50000007b",
depends_on: [
"<depends_on>"
],
summary: "<summary>",
why: "<why>",
what_it_does: "<what_it_does>",
user_story: "<user_story>",
section: "customer",
initiative: "gym",
merchant: "<merchant>"
}
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.editBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
overlay: .init(
displayTitle: "<display_title>",
notes: "Added at the front desk",
typeOverride: .task,
prioOverride: "<prio_override>",
epicId: "a3ea3c95-0000-4000-8000-d0c5000000a3",
labels: ["<label>"],
humanContext: "<human_context>",
parentId: "7b66b0d0-0000-4000-8000-d0c50000007b",
dependsOn: ["<depends_on>"],
summary: "<summary>",
why: "<why>",
whatItDoes: "<what_it_does>",
userStory: "<user_story>",
section: .customer,
initiative: .gym,
merchant: "<merchant>"
)
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}R4-R7. Replaces claim (R12): a thread is given work, or takes it by moving it to Doing. Assigning to the pool (kind='ai', no session) deliberately leaves `assignee` null, which is what keeps the ticket eligible in the pick-next ranking so any thread can genuinely take it.
'human', 'ai', or null to unassign.
humanaiThe person's name. REQUIRED when kind is 'human'. Ignored when kind is null.
The AI thread that owns it. Only meaningful when kind is 'ai': a value names that thread, null or omitted puts it in the pool for any thread. A session that has never sent a heartbeat is refused with 404 rather than written, so a ticket can never be owned by a thread that does not exist.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X PATCH "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/assign" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"kind": "human"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PATCH("/api/v1/board/tickets/{id}/assign", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
kind: "human"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.setBoardTicketOwner(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
kind: .human
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}Superseded by setBoardTicketOwner (PATCH on the same path), which can also express AI ownership. Kept alive because an installed HQ binary calls it; behaviour is unchanged.
Who owns it now; null unassigns. Treated as a person.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/assign" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"assignee": "<assignee>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/assign", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
assignee: "<assignee>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.assignBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
assignee: "<assignee>"
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}The same list getBoardTicket embeds, on its own so the app can re-sign expired URLs without refetching the whole ticket. Signed URLs last an hour.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
curl -G "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/attachments" \
-H "Authorization: Bearer $MEMBBER_TOKEN"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/board/tickets/{id}/attachments", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listBoardTicketAttachments(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"attachments": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
]
}R19, stated by the founder with "extreme importance": "I will add bugs into this app when using the app. So I'll be taking screenshots and then transcribing them into it. So that's how we add bugs in." The parent is in the PATH, never the body, so a client cannot violate the one-parent-only constraint by sending both or neither. Stored in the PRIVATE board-media bucket and read back as one-hour signed URLs, because these are screenshots of an unreleased product that routinely show real rows. A `kind: "link"` body attaches a pure URL (Figma, Loom, a doc), nothing is uploaded and the destination comes back as `url`.
The object in the private bucket. Stable; `url` is derived from it on every read. Null on a pure-link row.
The destination of a `kind='link'` row (a Figma page, a Loom). Null on stored rows.
For stored rows: a signed URL valid for one hour (null if signing failed; the row is still real). For a link row: `external_url` passed through untouched, one field to open, whatever the kind.
MIME type of the stored object (e.g. video/quicktime, application/pdf). Null on legacy image rows.
Original filename as picked on the device, for display. Null on screenshots.
Verified size of the stored object in bytes. Null on legacy rows.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/attachments" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '"<value>"'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/attachments", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: "<value>",
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.attachBoardTicketMedia(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json("<value>")
).ok.body.json
print(response){
"attachment": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"inbox_id": "03d7f974-0000-4000-8000-d0c500000003",
"kind": "<kind>",
"caption": "<caption>",
"width": -9007199254740991,
"height": -9007199254740991,
"created_at": "<created_at>",
"storage_path": "<storage_path>",
"external_url": "https://example.com/image.jpg",
"url": "https://example.com/image.jpg",
"content_type": "<content_type>",
"filename": "<filename>",
"byte_size": -9007199254740991
}
}Step 1 of the large-media path (17 Aug 2026): videos and files are too big for a JSON body in production, so the bytes go phone → storage directly. PUT the bytes to `signed_url`, then POST …/attachments with `storage_path` to make the object a real, kept attachment. The path is server-minted under the ticket's own prefix, a client can never write outside it.
What the bytes are. Drives validation + how the app renders the tile.
imagevideofileThe file's MIME type (e.g. video/quicktime, application/pdf).
Size of the upload in bytes. Images cap at 15 MB, videos/files at 50 MB (the storage ceiling).
Original filename, for display on the ticket.
The object path the upload MUST land at, registration refuses any other.
Signed-upload token (storage `upload/sign`).
Absolute URL to PUT the bytes to.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/attachments/upload-url" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"kind": "image",
"content_type": "<content_type>",
"byte_size": 1
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/attachments/upload-url", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
kind: "image",
content_type: "<content_type>",
byte_size: 1
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.createBoardTicketUploadUrl(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
kind: .image,
contentType: "<content_type>",
byteSize: 1
))
).ok.body.json
print(response){
"path": "<path>",
"token": "<token>",
"signed_url": "https://example.com/image.jpg"
}The comment text (non-empty).
Optional pre-uploaded media descriptors to denormalise onto the comment.
Client-generated idempotency key (same pattern as the messaging architecture's client_message_id). A retry with the same key on the same ticket is a no-op that returns the ORIGINAL comment, never a duplicate, this route is declared idempotency:{mode:"rpc"}, which board_comment now actually enforces.
Pre-denormalised media stored on the comment (may be empty).
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/comments" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"body": "<body>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/comments", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
body: "<body>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.commentBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
body: "<body>"
))
).ok.body.json
print(response){
"comment": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"ticket_id": "fbc29b2e-0000-4000-8000-d0c5000000fb",
"author": "<author>",
"body": "<body>",
"media": [
{}
],
"created_at": "<created_at>"
}
}R3 + R9. Every column is reachable (blocked, todo, doing, review, testing, done). Moving OUT of Done into any active column stamps `reopened_at` and increments `reopen_count`, because a ticket that came back is not a new ticket. Widened from the original To-do/Blocked-only contract; the two old values still behave exactly as they did, so the installed HQ binary is unaffected. Moving to `blocked` REQUIRES `block_reason` + `block_condition` + `block_ref` (20260819_320): a block takes the ticket out of every developer's queue, so it has to say what it is waiting for and how that clears. A `waiting_on_ticket` block also writes `depends_on`. A block on a decision the founder has ALREADY answered is refused. Blocks reverse themselves: answering the decision, or finishing the depended-on ticket, returns the ticket to To-do automatically with an honest activity row.
R3: any column on the board. From the card you can move it anywhere.
blockedtododoingreviewtestingdoneFractional sort index within the target column. Omit to leave the existing position alone.
The thread doing the moving. Moving an UNOWNED ticket to `doing` with this set hands that thread ownership in the same write, which is what replaced claim (R12). It never takes a ticket off someone else. A session that has not sent a heartbeat is refused (404), never written.
MANDATORY when `col` is `blocked`. Why this is waiting, in a sentence a person can read. A block removes the ticket from EVERY developer's queue, so it has to say what it is waiting for. A block without one is refused (400 BLOCK_NEEDS_REASON).
MANDATORY when `col` is `blocked`, the machine-readable condition that will clear the block. Refused without one (400 BLOCK_NEEDS_CONDITION), because a block nothing can clear never comes back.
waiting_on_founderwaiting_on_ticketwaiting_on_assetwaiting_on_externalMANDATORY when `col` is `blocked`, what the condition points at: a board_inbox id, a ticket id, or a description of the asset/external thing.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/move" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"col": "blocked"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/move", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
col: "blocked"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.moveBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
col: .blocked
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}Writes board_task_state.founder_ord via the atomic board_reorder RPC. Unlike move, this never touches the column, it only ranks the ticket within its group. Worker threads honour founder_ord when picking what to build next (board_next_candidates already folds founder-ranked items to the top).
The ticket ABOVE the drop position (higher priority), if any.
The ticket BELOW the drop position (lower priority), if any.
Materialisation fallback: if `before` has never been ranked, the server gives it this founder_ord first (derived client-side from its current display position) so the drop lands durable and exact even on a never-ranked board. Ignored when `before` already has a rank.
Materialisation fallback for `after`, same rules as before_ord.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/reorder" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"before": "<before>",
"after": "<after>",
"before_ord": 1,
"after_ord": 1
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/reorder", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
before: "<before>",
after: "<after>",
before_ord: 1,
after_ord: 1
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.reorderBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
before: "<before>",
after: "<after>",
beforeOrd: 1,
afterOrd: 1
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}Refuses any row not in col 'triage' (NOT_A_CAPTURE, the harmless triage race: the second developer just picks other work). Enforces every file-ticket floor server-side, sets col='todo' + the full brief + labels, KEEPS capture_text (his words are the record), and writes a 'triaged' activity row so the history reads captured → triaged → … chronologically. Distinct from triageBoardInbox (inbox HINTS only).
The real ticket title the developer writes.
Which area this belongs to (same set file-ticket accepts).
customerbusinesspaymentsverticalsplatformhqR2: task | bug | feature | epic | chore.
taskbugfeatureepicchoreThe board priority scale.
criticalhighnormallowThe plain-terms "In short" line (15-140 chars, mandatory, founder law, 16 Aug 2026).
NO-BLANKS law: why this exists, in real words (≥20 chars).
NO-BLANKS law: what the change does (≥20 chars). Stored as what_it_does.
Mandatory label: the ONE initiative this serves; 'platform' is the honest cross-cutting choice.
Canonical store slug when it is about ONE store.
Add-on slugs this touches ([] = classified as none).
The thread doing the triage, recorded on the activity row. A session that has never sent a heartbeat is refused (404), never written. Triage does NOT claim the ticket.
Resolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/triage" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"title": "Morning class",
"section": "customer",
"type": "task",
"priority": "critical",
"summary": "<summary>",
"why": "<why>",
"what": "<what>",
"initiative": "<initiative>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/triage", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
body: {
title: "Morning class",
section: "customer",
type: "task",
priority: "critical",
summary: "<summary>",
why: "<why>",
what: "<what>",
initiative: "<initiative>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.triageBoardTicket(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000"),
body: .json(.init(
title: "Morning class",
section: .customer,
type: .task,
priority: .critical,
summary: "<summary>",
why: "<why>",
what: "<what>",
initiative: "<initiative>"
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}Fire-and-forget from the ticket sheet. The actor is the VERIFIED operator email (kind human), never client-supplied; AI threads record their own views through the thread kit (scripts/board-thread.mjs seen), which stamps the session id with kind ai.
The fresh who-opened-this list, so the sheet can render it at once.
Stable machine key: the operator email (human) or the session id (AI). Render names from the roster.
'ai' or 'human'.
curl -X POST "https://www.membber.com/api/v1/board/tickets/00000d1b-0000-4000-8000-d0c500000000/viewed" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/{id}/viewed", {
params: { path: { id: "00000d1b-0000-4000-8000-d0c500000000" } },
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.recordBoardTicketView(
path: .init(id: "00000d1b-0000-4000-8000-d0c500000000")
).ok.body.json
print(response){
"viewers": [
{
"actor": "<actor>",
"kind": "<kind>",
"first_viewed_at": "<first_viewed_at>",
"last_viewed_at": "<last_viewed_at>",
"view_count": 1
}
]
}Inserts source='founder-capture', col='triage', id `F-<md5(client_request_id) prefix>`, capture_text verbatim, raw_title = the first 140 chars of his words (real data, never an invented title). Chips are optional and never guessed. The row is EXCLUDED from pick/claim until a developer triages it into a full brief (triageBoardTicket). Writes a 'captured' activity row, the first line of the ticket's story. Media rides the existing POST /tickets/{id}/attachments route straight after, on the echoed id.
The founder's words, verbatim. The only mandatory field, the capture IS his words.
Minted ONCE per capture and reused on every retry, the ticket id derives from it, so a retry returns the original row instead of a duplicate.
Optional chip: which app/area this is about. Unset = the triaging developer decides.
customerbusinesspaymentsverticalsplatformhqOptional chip: the ONE initiative this serves (gym | click-collect | …). Never guessed.
Optional chip: canonical store slug when it is about ONE store.
Optional chips: add-on slugs this touches.
Optional chip: how urgent it felt in the moment. Defaults to normal.
criticalhighnormallowResolved column: the founder override (founder_col) if set, else the synced col.
The founder column override, or null when the item is not founder-pinned.
Resolved type: type_override if set, else the synced type.
Resolved priority: prio_override if set, else the synced prio.
Resolved title: display_title ?? title ?? raw_title.
The plain-terms "In short" TL;DR every ticket opens with.
Founder-action rows: "What happens after you tick it", the post-done "Next:" card.
ISO date (YYYY-MM-DD) the work landed / was raised.
ISO timestamp of the last sync write.
R16: ticket ids this one is waiting on. Empty = not blocked.
The time dimension: when the item was born (PR createdAt / FA added date). Null when the source has no date.
When the item last moved at its source (PR updatedAt). Null when unknown.
The ONE initiative/vertical this ticket serves: gym | click-collect | bookings | restaurant | salon | barber | loyalty | platform. 'platform' is the honest cross-cutting none. Null = never classified. (Read kept permissive: the vocabulary may grow without a migration.)
Canonical store slug when the ticket is about ONE real store (busy-beans, tc60, flour-bloom, larkfield, …). Null = no single store / never classified.
Add-on slugs from the real addons catalog (order_collect, bookings, gym_management, own_app, retention_engine) plus family-level 'moments'. Null = never classified; [] = classified as none, the distinction is deliberate and kept on the wire.
R17: the ticket this is a sub-task of, or null when it stands alone.
R14: the SHORT plain-English line, AS AUTHORED. Null when nobody has written one. getBoardTicket returns a derived fallback in its own top-level human_context field; this column stays honest about whether a human actually wrote it.
'human' with an assignee = a person owns it. 'ai' with a session_id = THAT thread owns it. 'ai' with no session_id = the pool, any thread may pick it up. null = unassigned.
humanaiR9: when it last came back out of Done, or null if it never has.
R9: how many times it has come back out of Done. 0 for most tickets.
Founder quick capture (18 Aug 2026): his VERBATIM words at capture time (source 'founder-capture'). Kept forever, even after triage fills the brief, his words are the record. Null on every other source.
R17: ids of the tickets whose parent_id is this one. Computed on read, never written.
Honest per-ticket progress (19 Aug 2026): how many of subtask_ids sit in a RESOLVED column of 'done', the DONE half of the card's "3 of 8". The TOTAL is subtask_ids.length, so both halves have one source and cannot disagree. 0 when there are no children. Never a percentage.
True when the FOUNDER filed this row himself, across all three intake paths: quick capture (source='founder-capture'), the ⊕ New-ticket sheet (source='manual' AND content_source='founder'), or a bug he raised that was later filed against a board_inbox row with asking_session_id NULL. Computed by the one server-side predicate (isFounderCreated in src/lib/board/resolve.ts), never re-derived from the id prefix, which is wrong on the live board.
True when the row is HIS: everything created_by_founder covers, PLUS his own errand list (source='founder-action', the FA items). Strictly wider than created_by_founder and never narrower. Two fields because the questions differ, provenance decides whether a trust-loop push fires (only for what he actually filed), while the board's "Yours" view decides what he sees. Computed by isFounderOwned in src/lib/board/resolve.ts.
Computed, never written. reopened = sat in To-do having already been Done at least once; once a thread picks it back up it reads in_progress and reopen_count carries the history. awaiting_triage = a founder capture (col 'triage') no developer has shaped into a brief yet.
awaiting_triagenot_startedreopenedin_progressin_reviewtestingblockeddoneComputed, never written. working_now = a LIVE session (silent < 45 min, the board_liveness_window) holds it by session_id OR assignee=label OR branch=branch, the ONLY state a screen may present as being worked. started_nobody_on_it = in a started column and NO session has ever held it. abandoned_was_held = a session did hold it and that thread is finished or long silent. not_applicable = not in a started column at all (To-do, Done, Blocked, Triage have no liveness question to answer).
working_nowstarted_nobody_on_itabandoned_was_heldnot_applicableThe live developer's name when work_state is working_now ("Tomas Ashworth"), else null. The ONLY field a card may use to claim somebody is working, there is no client-side fallback, by design.
The name of the developer who HAD it, when nobody has it now. Null while someone is on it, and null when nobody ever was, so an abandoned card can say "Hugh Draper stopped" instead of going anonymous.
When that developer was last seen, so a card can say "quiet since 09:14".
When this ticket was last taken off a dead thread and handed back. claimed_at is PRESERVED alongside it, so "started" and "given back" are both readable.
R15, DETAIL ONLY. Present on getBoardTicket, ABSENT on a list row and on a mutation echo: rendering it for 123 rows would be megabytes on every board refresh. A client keeps the copy it loaded when a fresher row arrives without one, rather than blanking the brief mid-session.
The whole brief as one paste-and-go document. This is what an agent is handed.
Stable and machine-readable: what_needs_doing, at_a_glance, pr, history, ...
List-shaped sections (dependencies, sub-tasks, history) come back as items.
When the server composed it, so a stale brief is visible as stale.
curl -X POST "https://www.membber.com/api/v1/board/tickets/capture" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"capture_text": "<capture_text>",
"client_request_id": "8e99e5bf-0000-4000-8000-d0c50000008e"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/board/tickets/capture", {
body: {
capture_text: "<capture_text>",
client_request_id: "8e99e5bf-0000-4000-8000-d0c50000008e"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.captureBoardTicket(
body: .json(.init(
captureText: "<capture_text>",
clientRequestId: "8e99e5bf-0000-4000-8000-d0c50000008e"
))
).ok.body.json
print(response){
"ticket": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"source": "<source>",
"pr_number": 1,
"branch": "<branch>",
"section": "<section>",
"col": "<col>",
"founder_col": "<founder_col>",
"pr_state": "<pr_state>",
"type": "<type>",
"prio": "<prio>",
"title": "Morning class",
"raw_title": "<raw_title>",
"summary": "<summary>",
"after_done": "<after_done>",
"why": "<why>",
"what_it_does": "<what_it_does>",
"user_story": "<user_story>",
"epic_id": "a3ea3c95-0000-4000-8000-d0c5000000a3",
"when_ts": "<when_ts>",
"content_source": "<content_source>",
"synced_at": "<synced_at>",
"assignee": "<assignee>",
"claimed_at": "<claimed_at>",
"session_id": "630ddf64-0000-4000-8000-d0c500000063",
"depends_on": [
"<depends_on>"
],
"labels": [
"<label>"
],
"notes": "Added at the front desk",
"founder_ord": 1,
"created_ts": "<created_ts>",
"updated_ts": "<updated_ts>",
"initiative": "<initiative>",
"merchant": "<merchant>",
"addons": [
"<addon>"
],
"parent_id": "7b66b0d0-0000-4000-8000-d0c50000007b",
"human_context": "<human_context>",
"assignee_kind": "human",
"reopened_at": "<reopened_at>",
"reopen_count": 1,
"capture_text": "<capture_text>",
"subtask_ids": [
"e8d74bd5-0000-4000-8000-d0c5000000e8"
],
"children_done": -9007199254740991,
"created_by_founder": true,
"founder_owned": true,
"lifecycle": "awaiting_triage",
"work_state": "working_now",
"worked_by": "<worked_by>",
"was_worked_by": "<was_worked_by>",
"went_quiet_at": "<went_quiet_at>",
"released_at": "<released_at>",
"ai_context": {
"markdown": "<markdown>",
"sections": [
{
"key": "<key>",
"title": "Morning class",
"body": "<body>",
"items": [
"<item>"
]
}
],
"generated_at": "<generated_at>"
}
}
}