Primitive spec
Elar
Two-way messaging. Elar owns the conversation — a thread between people — in two kinds that share one model: a member and the business, and employees among themselves. It holds the participants, the append-only messages, who has read what, and the unread badge, and it delivers a new message live to the people who are in the thread and to nobody else.
kind, memberPersonId, the booking attachment, the shared desk cursor), but no member conversation can be created yet, and the site widget, booking attachment and notifications land with it.
What it owns
A conversation, its participants, its messages, the thing it is about (a booking, once member messaging lands), and read state. That is the whole of it. A conversation has no lifecycle — it is read, or it is not — which is why messaging is its own primitive rather than a note type on a support case: the next thing that needs to message someone gets a service to call, not a queue to inherit.
Elar does not own identity or the staff roster (Terminus answers “is this person on staff” on every read), consent (the same Terminus choke point), outbound notifications (Speaker), or support cases (Bayta — a conversation may be linked to a case and is never itself one). Where the member-facing affordance renders on a published site is Radiant’s.
Concepts
- Conversation
- The thread.
kindisteamormember;audienceisall_stafforselected;subjectis display-only, up to 200 characters.lastActivityAtcarries microsecond precision and is meant to be echoed back verbatim when you mark the thread read — treat it as opaque. Nothing on a conversation is editable or deletable; a correction is a new message. - Participant
- Only a
selectedconversation has participant rows, and they are frozen at creation: no additions, no manager override. “Just these people” means those people, and the author is always one of them whether or not they listed themselves. Anall_staffconversation has no participant rows by design — membership is answered live from the staff roster, so someone hired after the thread started simply sees it. Either way, access ends with employment: a terminated or suspended employee loses the thread on the API and on the live channel alike. - Message
- Append-only.
authorKindisstaff,memberorplatform_support;visibilityisinternalormember_visible; the body is free text up to 64 KB. A message body is never logged and never rides an event payload — it is readable only through its thread. - Read state
- Two mechanisms, deliberately. A team conversation keeps a cursor per person, so your reading does not clear a colleague’s badge. A member conversation keeps one shared desk cursor (
staffReadAt): there is one front desk, and any staffer opening it clears it for everyone. Mark-read takesseenThrough— thelastActivityAtyou actually rendered — and the cursor lands at the earlier of that and the current activity, so a reply that committed after your render stays unread. - Unread counts
GET /elar/v1/unreadreturns{team: n}today; the member count arrives with the member queue. The count is capped at 100 (render “99+”) and floored at 90 days of activity: a conversation nobody has touched for 91 days stops contributing to the badge while staying in the queue, unread.- The two switches
memberMessagingEnabledandteamMessagingEnabled, per location, both off by default. Reads resolve the location’s own row, then the business-wide row, then off;scopeKeyin the response says which one answered ("*"means business-wide). Turning a switch off refuses new conversations and new replies (403 elar-messaging-disabled) and hides nothing — managers need the history, so list, detail and mark-read stay open.- Live delivery
- Each thread has one live channel, carried by Palver. Subscribing passes the same membership check as reading the thread over HTTP, so the two transports cannot disagree about who is in a conversation. The push is an invalidation signal, not a message transport: it names the conversation and carries no body, and a client that receives one refetches the thread. Only the open thread is live today; the queue is not.
- Who the caller is
- Membership is a fact about a person, and it is never taken from a request body — no Elar request has a
personIdfield. The caller is resolved from the verified session, and every staff endpoint additionally requires that operator to be an active employee of the business. Console operators are provisioned as staff automatically; an operator whose employment a human ended sees an empty queue until they are re-hired.
API surface
Endpoints are versioned under /elar/v1/ and all are staff-facing today. The two writes honour Idempotency-Key: a retried key replays the stored entity with the same id, and a message-side key is bound to its conversation, so reuse across threads cannot replay across them. Conversation ids are ecnv…, message ids emsg…, participant ids eprt….
Quick reference
| Method | Path | Purpose |
|---|---|---|
POST | /elar/v1/conversations | Start a team conversation. kind accepts team only (members start their own); audience is all_staff or selected with 1–200 participantPersonIds. 201 + Location. |
GET | /elar/v1/conversations | The queue for one tab — ?kind=team|member (default team), oldest activity first. ?limit= (default 50, max 200) and ?offset=; no total is returned — “more” is items.length === limit. |
GET | /elar/v1/conversations/{id} | The conversation, its participants, and the 50 most recent messages, oldest-first within that page. 404 for an unknown id, 403 conversation-forbidden for a real thread the caller is not in — never “not found”. |
POST | /elar/v1/conversations/{id}/messages | Append a message: {body}, non-blank after trimming. 201; 403 elar-messaging-disabled while the thread’s switch is off. |
POST | /elar/v1/conversations/{id}/read | Mark read for the caller. Optional {seenThrough}; a bare POST uses the watermark the request loaded. 204, empty body. |
GET | /elar/v1/unread | Badge counts: {team: n}, capped at 100. |
GET | /elar/v1/settings | The effective switches for the caller’s scope, plus scopeKey — the scope that answered. |
PUT | /elar/v1/settings | Set both switches for the caller’s own scope. An owner-level write; front-desk roles can read but not change them. |
Failures are RFC 7807 application/problem+json. Branch on the type, not the status: three different things return 403 — forbidden (wrong role, or the operator is not an active employee), elar-messaging-disabled (hide the composer, keep the history), and conversation-forbidden (a real thread the caller is not in). 422 conversation-participants-invalid means a chosen person is not active staff, or a selected audience was sent with nobody in it.
Example: a court-maintenance thread for two people
A racquet club’s front desk opens a “just these people” thread with the facilities lead and the head coach. The author is added automatically; the two named people are validated as active staff in one batched roster check.
POST /elar/v1/conversations
Idempotency-Key: 6d1f4c0e-court3-net
{
"kind": "team",
"audience": "selected",
"subject": "Court 3 net is loose",
"participantPersonIds": ["prsn01J8W2…", "prsn01J9C4…"]
}
→ 201 Created
Location: /elar/v1/conversations/ecnv01JB7Q…
{
"id": "ecnv01JB7Q…",
"tenantId": "t_01J8K2…",
"subTenantId": null,
"kind": "team",
"audience": "selected",
"memberPersonId": null,
"subject": "Court 3 net is loose",
"attachmentType": null,
"attachmentId": null,
"lastActivityAt": "2026-09-03T14:02:11.418206+00:00",
"staffReadAt": null,
"createdAt": "2026-09-03T14:02:11+00:00",
"updatedAt": "2026-09-03T14:02:11+00:00"
}
The facilities lead replies. The message is append-only; visibility is internal because no member is in a team thread. The reply pushes an invalidation to the thread’s live channel, and the head coach’s open screen refetches.
POST /elar/v1/conversations/ecnv01JB7Q…/messages
{ "body": "Maintenance is on it. Court 3 is closed until 4pm." }
→ 201 Created
{
"id": "emsg01JB7R…",
"conversationId": "ecnv01JB7Q…",
"authorPersonId": "prsn01J8W2…",
"authorKind": "staff",
"visibility": "internal",
"body": "Maintenance is on it. Court 3 is closed until 4pm.",
"actingStaffUserId": null,
"actingStaffLabel": null,
"createdAt": "2026-09-03T14:05:40+00:00"
}
The head coach’s badge shows one unread thread. Once the reply is on screen, the client marks read with the lastActivityAt it rendered; a further reply landing between the render and the mark stays unread.
GET /elar/v1/unread
→ 200 OK
{ "team": 1 }
POST /elar/v1/conversations/ecnv01JB7Q…/read
{ "seenThrough": "2026-09-03T14:05:40.102337+00:00" }
→ 204 No Content
How it fits with the rest
flowchart TB Op[Console operator] --> E(Elar) Mem[Member on the site] -. next phase .-> E E --> Te[Terminus
is this person active staff?] E --> Pv[Palver
one live channel per thread] Pv -. same membership check .-> E E -. linked, never owned .-> By[Bayta case] E -. notifications, next phase .-> Sp[Speaker]
Terminus is asked “is this person on staff” on every read and every subscribe, which is what makes membership live for an all_staff thread and what ends access when employment ends — the same predicate that refuses a suspended employee at a Trantor-controlled door. Palver carries the per-thread live signal behind that check. A conversation can be linked to a Bayta case without becoming one, and member notifications will go out through Speaker’s consent gate when member messaging lands.