Specfork

@specfork/support-inbox

Shared customer-support inbox with contacts, conversations, email and chat-widget channels, assignment, groups, labels, canned replies, business hours and reports.

saascustomer-support Version 0.1.1 143 requirements Licence CC-BY-4.0 Specfork/support-inbox

Support inbox

A support team answers its customers in one shared place. Messages arrive through inboxes (email, a website chat widget, a generic API channel, or any channel adapter). They become conversations with a contact, and agents assign, label, reply to and resolve them. Behaviour derived from Chatwoot's open-source code (MIT), written fresh and stack-free. Files under Chatwoot's enterprise licence (enterprise/: SLA, custom roles, capacity limits, AI assistant, audit logs, SAML) were not used. Not affiliated with or endorsed by Chatwoot Inc. The name is used only to credit the source.

Area Prefix Covers
inboxes.md INB Inboxes, settings, members, business hours, greeting and away messages
channels.md CHN Channel adapter contract, contact identities, routing, delivery, API channel
widget.md WID Website chat widget: visitor tokens, identity check, pre-chat form, continuity
email.md EML Email channel: inbound routing, threading, polling, outbound headers
contacts.md CON Contacts, identity matching, merge, notes, block, import and export
conversations.md CONV Conversation fields, statuses, snooze, auto-resolve, lists, filters, unread, bulk
messages.md MSG Message types, private notes, attachments, mentions, activity log, delivery status
assignment.md ASN Assignees, groups, availability, round-robin auto-assignment
productivity.md PRD Labels, canned responses, variables, macros, custom attributes, saved filters
events.md EVT Webhook events, payloads, inbox filter, live updates, notification types and recipients
reports.md RPT Response and resolution metrics, business-hours values, summaries, satisfaction surveys
permissions.md PERM Team roles (with viewer) applied to inboxes, conversations and settings

A workspace is a team (@specfork/teams); agents are its accepted members; all data belongs to exactly one workspace. A group is a named set of agents inside a workspace (the source calls it a team). All times are stored in UTC; API names are snake_case.

Assignment

Status: Draft · Prefix: ASN

Purpose

Who owns a conversation: one assignee, an optional group, and fair automatic distribution among available agents.

Requirements

ID Requirement Verified by
ASN-1 A conversation has at most one assignee. Assign {assignee_id}: the user must be an accepted workspace member with role member+ who is an inbox member or admin+, else 422 invalid_assignee; null unassigns. The change is made under a row lock; the last committed write wins. assign non-member of the inbox → 422
ASN-2 Group: {id, name (1–100, trimmed, unique per workspace case-insensitive), description?, allow_auto_assign (default true)}; members (group, user) unique, each an accepted workspace member. Create, update, delete and membership: admin+; any agent lists groups and members. duplicate name Billing vs billing → refused
ASN-3 Set group {group_id} (null removes): the assignee is cleared when not a group member; then, if unassigned, the group has allow_auto_assign and the inbox has auto_assign, one agent is picked per ASN-6. Deleting a group clears it on its conversations; assignees stay. move to Billing, assignee not in Billing → unassigned, then auto-picked
ASN-4 Availability per workspace member: availability: online|busy|offline (default online), auto_offline (default true); set by the member, or by admin+ for others. Presence: clients ping every 20 s; a user is present if pinged in the last 60 s. Effective status = stored availability if auto_offline is false; else stored availability while present, offline when not. auto_offline, no ping 2 min → offline
ASN-5 Auto-assignment runs when the inbox has auto_assign and a conversation becomes open (created or reopened) with no assignee, or with an assignee who lost access, and the same change does not set an assignee. pending, snoozed and resolved conversations are never auto-assigned. new open conversation → assigned
ASN-6 Candidates: member+ inbox members whose effective status is online (not busy), limited to the group's members when the conversation has a group, and none when that group disallows auto-assign. Pick round-robin: the candidate assigned least recently in this inbox; a newly added inbox member counts as just assigned. A got the last one, B online → B
ASN-7 Fair limit per inbox: assign_limit (default 100) auto-assignments per agent per assign_window_seconds (default 3,600, sliding); agents at the limit are skipped. Manual assignment is not limited or counted. limit 1, A got one → next goes to B
ASN-8 No candidate → the conversation stays unassigned. A sweep at least every 30 minutes, and whenever a member of the inbox becomes online, retries open unassigned conversations of inboxes with auto_assign whose last activity is within 168 hours, oldest created_at first. nobody online, A comes online → assigned to A
ASN-9 Removing a user from an inbox unassigns them from that inbox's non-resolved conversations unless they are admin+; on leaving or removal from the workspace (@specfork/teams:TEAM-15) all their conversations are unassigned and their inbox and group memberships removed. Each unassignment writes activity and events like a manual one. remove A from inbox → A's open conversations unassigned
ASN-10 Every assignee change adds the new assignee as a participant (CONV-14), writes an activity message (MSG-7) with actor = acting user or system, and emits conversation.updated (EVT-3) with automatic true when made by auto-assignment. auto-assign → event automatic true

Out of scope

  • Per-agent capacity limits, balanced or weighted assignment orders (enterprise in the source), agent bots as assignees, skills-based routing.

Decisions

  • Assignees must have access to the inbox; the source accepted any account user.
  • Removing an agent unassigns their conversations at once and with normal events; the source left stale assignees in inboxes and unassigned silently on account removal.
  • A group's auto-pick respects the inbox's auto_assign; the source ignored it.
  • Presence window 60 s with a 20 s ping; the source used 20 s for both, so agents flapped offline.
  • One assignment model with the fair limit and sweep always on; the source had two versions behind a feature flag.

Channels

Status: Draft · Prefix: CHN

Purpose

How messages enter and leave an inbox: one adapter contract for every channel, plus the generic API channel.

Requirements

ID Requirement Verified by
CHN-1 Each inbox has exactly one channel. Built-in types: web_widget (WID), email (EML), api (CHN-9). Other types are channel adapters that implement CHN-3, CHN-5, CHN-6 and optionally CHN-7; an adapter declares {type, identity_kind: uuid|email|phone|external, reply_window_hours?}. register adapter sms (phone) → inbox of type sms creatable
CHN-2 Contact identity: {contact, inbox, source_id, verified (default false)}, (inbox, source_id) unique. Default source_id by identity kind: uuid → random UUID; email → the contact's lower-cased email; phone → the contact's E.164 phone. Missing email or phone for those kinds → identity_missing. email inbox, contact without email → identity_missing
CHN-3 Inbound: the adapter passes {source_id, contact hints {name?, email?, phone?, identifier?, avatar_url?}, message {source_id, content?, attachments[], in_reply_to_source_id?, sent_at}}. The system finds the identity by source_id, else matches or creates the contact (CON-5) and creates the identity, selects a conversation (CHN-4) and stores an incoming message. An inbound message whose source_id already exists in the same inbox is ignored (at-least-once adapters). new sender → contact, identity, conversation, message created
CHN-4 Conversation selection for an identity: if lock_to_single_conversation, its latest conversation whatever the status; else its latest conversation that is not resolved; else a new one. A message into a resolved conversation is refused (conversation_closed) when allow_messages_after_resolved is false. lock off, last one resolved → new conversation
CHN-5 Outbound: after commit, each public outgoing or template message without a source_id is handed to the adapter once; success stores the provider id as source_id; failure sets status = failed and external_error. Private notes and activity messages are never handed over. adapter error → message failed with error text
CHN-6 Delivery updates from the adapter, by message source_id, follow MSG-10; an unknown source_id is ignored. read for unknown id → no change
CHN-7 Reply window: if the channel declares reply_window_hours, a public outgoing message more than that many hours after the conversation's last incoming message, or with no incoming message, is refused (reply_window_closed); private notes are always allowed. No window → no limit. 24 h window, last incoming 25 h ago → refused
CHN-8 An agent with access to the inbox may start a conversation for a contact that has the identity the channel needs (email, phone, or an existing identity for uuid kinds), with a first outgoing message; such a conversation starts with waiting_since empty. email inbox, contact with email → conversation created
CHN-9 API channel: {identifier (public, ≥ 128 random bits), hmac_token (≥ 256 bits), hmac_required (default false), webhook_url? (https, ≤ 2048), secret (≥ 256 bits), reply_window_hours? (> 0)}. reply_window_hours 0 → refused
CHN-10 API channel outbound: conversation, message and typing events of the inbox (EVT-2, private notes and activity excluded) are POSTed to webhook_url signed with secret per @specfork/webhooks:HOOK-3, retried per HOOK-5; when all attempts for a message.created delivery fail the message becomes failed. receiver down → message failed after last retry
CHN-11 Public inbox API (API channel and widget), authenticated only by inbox identifier and the identity's source_id: create contact (returns source_id, default random UUID), update contact (identifier immutable), list and create conversations, list messages (no private or activity), create incoming messages with attachments, resolve a conversation (actor: contact), typing on/off, mark seen. unknown source_id → 404
CHN-12 Identity check (widget and public API): when an identifier is sent with identifier_hash, or the channel has hmac_required, identifier_hash must equal hex HMAC-SHA256(key = hmac_token, message = identifier), compared in constant time; mismatch → 401; match sets the identity verified. A verified identity lists all conversations of the contact in that inbox; an unverified one only its own. Admin+ can rotate an inbox's hmac_token (widget, api) and secret (api); the old value stops working immediately. wrong hash → 401

Out of scope

  • Specific third-party adapters (social networks, messaging apps, SMS and voice providers), provider message templates and provider sign-up flows.

Decisions

  • One adapter contract instead of per-provider code; reply windows are declared data, not hard-coded per provider.
  • Conversation selection and allow_messages_after_resolved apply to every channel; the source applied them per channel and the widget ignored the lock setting.
  • API channel deliveries are signed and retried like other webhooks; the source sent once and did not retry.
  • A verified hash at contact creation sets verified; the source missed this on the public API path.

Contacts

Status: Draft · Prefix: CON

Purpose

The people a workspace talks to: one record per person across channels, with notes, labels and attributes.

Requirements

ID Requirement Verified by
CON-1 Contact: {id, name (≤ 255), email?, phone?, identifier?, avatar?, blocked (default false), contact_type: visitor|lead|customer (default visitor), additional_attributes {city?, country_code?, company_name?, description?, social profiles?}, custom_attributes ([PRD-9](#PRD-9)), labels[], last_activity_at?, created_at}. Blank email, phone and identifier are stored as null. Avatar: jpeg, png, gif or webp, ≤ 15 MB, or fetched once from avatar_url when none is set. create with email "" → email null
CON-2 email: valid address, lower-cased, unique per workspace. phone: E.164 (+, then 2–15 digits, first digit 1–9), unique per workspace. identifier (the customer's own user id, ≤ 255): unique per workspace, case-sensitive. Clash → taken naming the field. second contact with ANN@x.com → taken (email)
CON-3 Type: a visitor becomes lead when saved with an email, phone, identifier or social profile, or when a conversation is created for it; types never go down automatically; customer is set only by an agent. Contacts created by agents or import start as lead. visitor gives email → lead
CON-4 Update merges custom_attributes and additional_attributes key by key; keys are removed only by a delete call listing them. Any message from the contact sets last_activity_at. update {plan: pro} → other keys kept
CON-5 Matching on inbound traffic and identify: find by identifier, then email (case-insensitive), then phone; never match a contact whose identifier differs from a non-empty given one; never match by phone when the given email differs from the contact's non-empty email. A match is updated with the given non-blank values (attributes deep-merged); no match → new contact (generated name when none given). identify email of existing lead → same contact updated
CON-6 List: 15 per page; sort by name, email, phone, city, country_code, company_name, created_at, last_activity_at (asc/desc, nulls last; default last_activity_at desc); filter by labels (any of). By default only contacts with email, phone or identifier are listed; include_anonymous=true lists all. anonymous visitor → hidden by default
CON-7 Search: q required (else 422); case-insensitive substring on name, email, phone and identifier; 15 per page with has_more instead of a total. q=ann → Ann, Joanne
CON-8 Filter: condition list (CONV-10) over name, email, identifier, phone, city, country_code, company_name, labels, blocked, created_at, last_activity_at and contact custom attributes. email contains @acme → Acme people
CON-9 Merge {base_id, mergee_id} (admin+): both in the workspace and different, else 422. In one transaction the mergee's conversations, messages, identities, notes and labels move to the base; fields: base value kept when present, else mergee's; attributes deep-merged with base winning; type = the higher; the mergee is deleted. Emits contact.merged then contact.updated with the final base. base phone empty, mergee has one → base gets it
CON-10 Delete (admin+) removes the contact with its conversations, messages, identities and notes in the background, and emits contact.deleted with the last state. delete → conversations gone
CON-11 Blocked contact: new conversations start resolved; incoming messages are stored but do not reopen (CONV-4); notifications only for mentions (EVT-10). Mute a conversation = block its contact and resolve it; unmute = unblock, no reopen. blocked contact writes → conversation stays resolved
CON-12 Notes: {id, contact, author, content (1–20,000), created_at, updated_at}, newest first; any agent adds; author or admin+ edits and deletes. member edits another's note → 403
CON-13 Import (admin+): CSV with header row, UTF-8 (BOM stripped). Columns name, email, phone, identifier, company_name, city, country_code, labels (comma list); other columns become custom attributes only when a contact definition with that key exists, else ignored. Each row matches by identifier, email, phone (a phone missing + gets one); matched → updated, else created as lead. Status: pending|processing|completed|completed_with_errors|failed; rejected rows (invalid values, undefined labels) downloadable as CSV with an errors column. row with label ghost → rejected, others imported
CON-14 Export (admin+): CSV of the current list or filter, columns chosen from contact fields plus labels (default id, name, email, phone, labels), formula-safe (cells starting = + - @ escaped), UTF-8 with BOM, delivered as a download link to the requester. name =SUM(1) → exported as text

Out of scope

  • Companies as records, contact enrichment, CRM sync, contact calls.

Decisions

  • Merge needs admin+ like delete; the source let any agent merge (and so delete) contacts.
  • Unknown import columns are ignored unless defined; the source turned every unknown column into a custom attribute.
  • Identifier search is case-insensitive like the other fields; the source was case-sensitive there only.
  • contact.updated is emitted after the merge so receivers see the final base; the source emitted the event before updating it.

Conversations

Status: Draft · Prefix: CONV

Purpose

A conversation is one support thread between a contact and the team in one inbox, moved through statuses until resolved.

Requirements

ID Requirement Verified by
CONV-1 Conversation: {id, display_id, uuid, inbox, contact, identity ([CHN-2](#CHN-2)), status: open|resolved|pending|snoozed (default open), priority?: low|medium|high|urgent, assignee?, group?, labels[], participants[], snoozed_until?, waiting_since?, first_reply_at?, last_activity_at, status_changed_at, agent_last_seen_at?, assignee_last_seen_at?, contact_last_seen_at?, additional_attributes, custom_attributes, created_at}. display_id is a per-workspace sequence from 1, never reused, and is the id in every API path; uuid is random and used in public links. additional_attributes (browser, referer URL, mail_subject, …) and custom_attributes are maps; string values ≤ 1,500 chars, integers ≤ 9,999,999,999; an invalid referer URL is stored as null. 2nd conversation in a new workspace → display_id 2
CONV-2 Initial status: resolved for a blocked contact (CON-11); else pending when the creator asks for it (a bot or triage step), else open. last_activity_at = creation; waiting_since = creation for contact-started conversations. conversation for blocked contact → resolved
CONV-3 Any agent with access may set any status. snoozed_until (future time, optional: empty = until the next incoming message) is kept only while snoozed and cleared otherwise. Toggle without a target: open → resolved, any other → open. Each change sets status_changed_at; resolving clears waiting_since. snoozed → open → snoozed_until null
CONV-4 Incoming message: snoozed or resolved → open; open and pending unchanged; a blocked contact's conversation is not reopened. Outgoing messages and notes never change status. message into resolved → open
CONV-5 Snoozed conversations whose snoozed_until has passed are reopened within 5 minutes, however late the job runs. scheduler down 4 days → reopened on restart
CONV-6 waiting_since is set by an incoming message when empty and cleared by a human reply (MSG-6); private notes, activity and template messages never clear it. The first human reply sets first_reply_at once. note only → still waiting
CONV-7 Auto-resolve, workspace settings: auto_resolve_after_minutes (null = off, 10–1,438,560), auto_resolve_message?, auto_resolve_label?, auto_resolve_skip_waiting (default false). At least every 5 minutes, open conversations with last_activity_at older than the limit (and, when skipping, with no waiting_since) get the message, then the label, then resolved with actor system. 60 min limit, idle 61 min → resolved
CONV-8 List: status (default open; all = any), assignee_type me|unassigned|assigned|all (unassigned = no assignee), inbox_id, group_id, labels (any of), conversation_type mentioned|participating|unattended (no first reply, or waiting), q (case-insensitive substring of incoming and outgoing message text; ignores status). Only conversations the user may access (PERM-3). Every list response has counts mine, unassigned, assigned, all computed after all filters except assignee_type; 25 per page. assignee_type=me → only mine; 3 mine, 2 unassigned → all 5
CONV-9 Sort: last_activity_at desc (default) or asc, created_at asc/desc, priority desc/asc (nulls last, then last activity desc), waiting_since asc/desc (nulls last), unread (unread count desc, then last activity desc); ties by id. Unknown sort → default. priority desc → urgent first, none last
CONV-10 Condition list (conversation and contact filters, saved filters): ordered {attribute_key, filter_operator, values[], query_operator?: and|or} joining each condition to the next; the last has none. Operators: equal_to, not_equal_to, contains, does_not_contain, is_present, is_not_present, is_greater_than, is_less_than, days_before (integer 1–998, in the request timezone). Values are scalars and required except for is_present/is_not_present; text comparisons are case-insensitive; unknown key or disallowed operator → 422 naming it. last condition with and → 422
CONV-11 Conversation filter keys: status, priority, contact_id, browser_language (equality); assignee_id, inbox_id, group_id, labels (equality, presence); display_id, referer, mail_subject (equality, contains); created_at, last_activity_at (greater, less, days_before); conversation custom attributes by their type (PRD-9). created_at contains → 422
CONV-12 Unread = incoming messages after the user's agent_last_seen_at (all if null), counted without cap. Opening a conversation sets agent_last_seen_at (and assignee_last_seen_at for the assignee) and marks its notifications read; mark unread sets them to just before the last incoming message. open → unread 0
CONV-13 When the contact views the conversation, contact_last_seen_at = now and its outgoing messages in sent or delivered up to then become read. widget opened → agent replies read
CONV-14 Participants: users who follow a conversation; the assignee and mentioned users are added automatically; agents add, remove or replace them; each must have access to the conversation, else 422. add user without access → 422
CONV-15 Update: priority (value or null), custom_attributes (replace, or merge with merge=true; delete by key list); other fields change only through status, assignment and label endpoints. priority extreme → 422
CONV-16 Delete (admin+): removes the conversation and its messages in the background; for email inboxes the Message-IDs are remembered so polling does not import them again. delete, next poll → not recreated
CONV-17 Bulk: ≤ 100 display_ids with any of status, snoozed_until, assignee_id, group_id, labels {add[], remove[]}; each change runs with the same rules, permissions, events and activity as the single call; conversations the user cannot access are skipped and reported. 101 ids → 422

Out of scope

  • Agent bots and bot hand-off rules, AI assistants, conversation drafts, transcripts sent by email, translation.

Decisions

  • No implicit self-assignment when an agent reopens a conversation; the source made a reopening agent the assignee, taking it from a colleague.
  • Snoozes past due always reopen; the source skipped snoozes more than 3 days late.
  • Unread counts are uncapped; the source capped them at 10.
  • Bulk changes run the normal rules and are capped at 100; the source bypassed assignment checks and had no cap.
  • Agent-started conversations do not start waiting; the source set waiting_since on every new conversation.

Email

Status: Draft · Prefix: EML

Purpose

An inbox fed by a mailbox, by forwarding or polling, whose replies go out as threaded email.

Requirements

ID Requirement Verified by
EML-1 Email channel: {email (lower-cased, unique across the instance), forward_address (generated on create: ≥ 64 random bits @ the instance's inbound domain, unique), imap {enabled (default false), host, port, login, password, ssl (default true)}, smtp {enabled (default false), host, port, login, password, auth: plain|login|cram_md5, starttls (default true), ssl (default false)}, reauth_required (default false)}. Passwords encrypted at rest. Saving with IMAP or SMTP enabled first tests the connection (10 s timeout); failure → mail_connection_failed, nothing saved. Identity kind: email. wrong IMAP password → refused, inbox unchanged
EML-2 Inbound routing: a received mail belongs to the email inbox whose email or forward_address is among its To, Cc, Bcc or original-recipient addresses (case-insensitive, +tag ignored), or to the conversation named by a reply address reply+<conversation uuid>@<inbound domain>. Other mail is dropped. mail to Support+x@Acme.com → acme inbox
EML-3 Threading, first match wins, always within the receiving workspace: (1) reply address uuid; (2) In-Reply-To equals a Message-ID this system generated (EML-7) or a message source_id in the same inbox; (3) any References id, same rules; (4) else a new conversation. A mail matching a forwarded message's id does not thread into it. reply to our email → same conversation
EML-4 New conversation: contact matched by sender email in the workspace, else created with the sender's display name; additional_attributes.mail_subject = Subject. first mail from new address → new contact
EML-5 Ignored mail: sender not a valid address (bounces), mail sent by this system's own notifications, duplicate Message-ID in the same inbox. Auto-replies (Auto-Submitted not no, or vacation headers) are stored with auto_reply = true. same Message-ID twice → one message
EML-6 Stored as incoming, content_type incoming_email: content = reply text without quoted history (≤ 150,000), content_attributes.email {from, to, cc, bcc, subject, message_id, in_reply_to, references, html, text}; NUL bytes stripped; at most the last 15 attachments kept. Message-ID becomes source_id. mail with 20 files → 15 attachments
EML-7 Outbound headers: Message-ID <conversation/<conversation uuid>/messages/<message id>@<domain>>; In-Reply-To = Message-ID of the last incoming mail, else <account/<workspace id>/conversation/<conversation uuid>@<domain>>; References = that mail's References plus In-Reply-To; Subject = Re: <mail_subject> once the thread has a prior message, else [#<display_id>] <inbox name> when no subject. agent reply → In-Reply-To = customer's Message-ID
EML-8 Outbound: To = contact email plus per-message cc, bcc (comma lists, each a valid address). With SMTP enabled, sent through it From the channel email; else From the workspace support address with Reply-To the reply address (EML-2). Display name: friendly = <agent name> from <business_name or inbox name>, professional = business or inbox name only. professional → From shows only business name
EML-9 IMAP polling: every minute per enabled inbox, mail since the previous day, deduplicated by Message-ID; one poll per inbox at a time; a mail failing 3 times within 6 hours is skipped. 10 consecutive authentication failures set reauth_required, stop polling and notify admins (EVT-9); saving new credentials clears it. 10 auth errors → polling stops
EML-10 Forward a message: an agent may forward a public message of an email conversation to ≥ 1 address; the copy is an outgoing message marked forwarded, with its own Message-ID and no In-Reply-To. forward without recipient → refused

Out of scope

  • Provider OAuth mailbox connection (Google, Microsoft), email templates and branded layouts, mailbox bulk import of history.

Decisions

  • Threading never looks outside the receiving workspace; the source matched reply addresses and In-Reply-To across all accounts.
  • Replies into resolved email conversations follow CHN-4 like every channel; the source ignored inbox settings for polled mail.

Events

Status: Draft · Prefix: EVT

Purpose

What the inbox tells the outside world (webhooks), open screens (live updates) and agents (notifications) when something happens.

Requirements

ID Requirement Verified by
EVT-1 Webhook subscriptions (@specfork/webhooks:HOOK-1) have scope = workspace, list event names or patterns (@specfork/webhooks:HOOK-12) from EVT-2, and are managed by admin+. Filter inbox_ids (list, @specfork/webhooks:HOOK-13): only events whose inbox_id is listed are sent; events without one (contact) are not. filter inbox 1 → inbox 2 messages and contact events not sent
EVT-2 Events: conversation.created, conversation.updated, conversation.status_changed, conversation.typing_on, conversation.typing_off, message.created, message.updated, contact.created, contact.updated, contact.merged, contact.deleted, inbox.created, inbox.updated, widget.triggered. A failed or rolled-back write emits nothing. create conversation with first message → conversation.created, message.created
EVT-3 conversation.updated fires only when one of assignee, group, status, snoozed_until, priority, labels, custom_attributes, waiting_since or first_reply_at changed; conversation.status_changed also fires on status change. Both carry changed_attributes {field: {previous, current}}; assignee changes carry automatic (ASN-10). seen-time update only → no event
EVT-4 Conversation data: {display_id, uuid, inbox_id, status, priority, labels, assignee {id, name, email}?, group {id, name}?, contact {id, name, email, phone, identifier}, verified, snoozed_until, waiting_since, first_reply_at, last_activity_at, unread_count, custom_attributes, additional_attributes, last_message, created_at, workspace {id, name}}. payload has display_id, not internal id
EVT-5 Message events are emitted for incoming, outgoing and template messages, not activity: data {id, conversation (display_id, uuid), inbox_id, message_type, content_type, content, content_attributes, private, status, source_id, sender {type: user|contact, id, name}, attachments [{id, file_type, url, size}], created_at}. Private notes are delivered to workspace subscriptions with private true and never to channel endpoints (CHN-10). note → workspace hook gets private: true, API inbox URL gets nothing
EVT-6 contact.updated and inbox.updated fire only when a stored field changed and carry changed_attributes; contact.deleted carries the last state; contact.merged carries {base, mergee}. save contact unchanged → no event
EVT-7 Live updates to agents, within 5 s: conversation and message events go to every user with access to that conversation (PERM-3); private notes and activity included; contact events go to all workspace members; typing goes to all except the typer. Each payload names the acting user (performer) when there is one. Typing state is never stored. member outside the inbox → no live event
EVT-8 Live updates to the contact's open widget or public API sessions: messages that are not private and not activity, status changes, agent typing that is not marked private, and read receipts. A verified contact receives them on all its verified sessions in the inbox; an unverified one only on its own. agent note → visitor sees nothing
EVT-9 Notification types (@specfork/notifications:NTFY-1) and recipients: conversation_created → inbox members; conversation_assigned → the new assignee; conversation_mention → each mentioned user (MSG-13); assigned_conversation_new_message → the assignee; participating_conversation_new_message → participants; mailbox_reauth_required (EML-9) and webhook_disabled (@specfork/webhooks:HOOK-7) → admin+. New-message types fire only for public incoming and outgoing messages. assign to Ann → Ann notified
EVT-10 Recipient rules: no conversation_created or conversation_assigned while the conversation is pending; for a blocked contact only mentions; the recipient must have access to the conversation (PERM-3); one type per user per message (assignee type wins over participant). Ann is assignee and participant → only assigned_conversation_new_message
EVT-11 Subject {kind: conversation, id: display_id} (mailbox_reauth_required: the inbox; webhook_disabled: the subscription); title = #<display_id> <contact name>; body = message text. Opening a conversation marks the user's notifications for it read (CONV-12). open conversation → its notifications read
EVT-12 Preferences (@specfork/notifications:NTFY-7): in-app on for every type; email instant for conversation_assigned, conversation_mention, mailbox_reauth_required and webhook_disabled, off for the rest. new user, assigned → email within 1 min

Out of scope

  • Push and mobile notifications; automation-rule webhooks.

Decisions

  • Dotted event names like other Specfork specs; the source used underscores.
  • Workspace webhooks are signed and retried by the base; the source sent once.
  • Inbox webhooks become one workspace subscription filtered by inbox_ids; notification inbox, grouping and email come from @specfork/notifications.
  • Users who can open a conversation by group membership also see it in lists and get its live updates; the source granted only opening it.

Inboxes

Status: Draft · Prefix: INB

Purpose

An inbox is one entry point for customer messages (one channel), with its own agents, schedule and automatic messages.

Requirements

ID Requirement Verified by
INB-1 Inbox: {id, name (1–255), channel ([CHN-1](#CHN-1)), time_zone (IANA, default the workspace's, @specfork/teams:TEAM-13), auto_assign (default true), greeting_enabled (default false), greeting_message? (≤ 10,000), working_hours_enabled (default false), out_of_office_message? (≤ 10,000), csat_enabled (default false), csat_rules ([RPT-10](#RPT-10)), allow_messages_after_resolved (default true), lock_to_single_conversation (default false), email_collect (default true), assign_limit (default 100, > 0), assign_window_seconds (default 3,600, > 0), sender_name_type: friendly|professional (default friendly), business_name?}. create with timezone Mars/Base → refused
INB-2 Create, update and delete inboxes: admin+. Create takes a channel type from those registered (CHN-1); an unknown type → invalid_channel_type. Inbox, channel and schedule changes in one update are saved together or not at all. Channel secrets (identity-check key, API secret) are returned only to admin+; mail server passwords are write-only and never returned. update with invalid working hours → inbox name unchanged too
INB-3 Inbox members: (inbox, user) unique. Admins add, remove, or replace the whole set with user_ids; every id must be an accepted workspace member (@specfork/teams:TEAM-2), else the call fails with invalid_user_ids and nothing changes. replace with [Ann, stranger] → invalid_user_ids, set unchanged
INB-4 A member or viewer sees only inboxes they belong to; admin+ see all. Assignable agents of an inbox = its member+ members plus all admin+ members of the workspace. member lists inboxes → only own
INB-5 Deleting an inbox returns at once and removes, in the background, its channel, members, schedule, conversations, messages and contact identities (CHN-2); contacts stay. delete inbox → contact still listed, its conversations gone
INB-6 Schedule: each inbox has exactly 7 rows {day_of_week 0 (Sunday)–6, open_all_day (default false), closed_all_day (default false), open_hour, open_minute, close_hour, close_minute}, created with the inbox: Monday–Friday 09:00–17:00, Saturday and Sunday closed. new inbox → Monday 09:00–17:00
INB-7 Schedule rules: hours 0–23, minutes 0–59; close strictly after open (no overnight ranges); open_all_day and closed_all_day not both true; open_all_day stores 00:00–23:59; times are not required on closed days. A bulk update matches rows by day_of_week and is all-or-nothing. open 17:00, close 09:00 → refused
INB-8 Open now = today's row in the inbox time zone is open_all_day, or not closed and open time ≤ local time < close time. An inbox is away when working_hours_enabled is true and it is not open now. Mon 17:00 local, close 17:00 → away
INB-9 Away message: on a new incoming message, if the inbox is away and out_of_office_message is non-empty, post it as a template message (MSG-1), unless an agent sent a public reply in the last 5 minutes or the conversation already got a template message today (inbox time zone). 2 messages at night → one away reply
INB-10 Greeting: on an incoming message in a conversation that has no outgoing or template messages yet, post greeting_message as a template message when greeting_enabled and the text is non-empty. The away check runs first; an away message suppresses the greeting. first message in hours → greeting only
INB-11 No automatic message (greeting, away, email collect WID-12) is sent for email auto-replies (EML-5) or for conversations started by the workspace (CHN-8). auto-reply email at night → no away reply

Out of scope

  • Outbound campaigns, agent bots and bot triage, per-agent capacity limits and SLA policies (SLA and capacity are enterprise-only in the source).

Decisions

  • Close time is exclusive; the source counted the whole closing minute as open.
  • A failed bulk schedule update rolls back; the source could leave it half-applied.
  • Mail passwords are never returned; the source returned them to admins in plain text.

Messages

Status: Draft · Prefix: MSG

Purpose

What a conversation is made of: customer messages, replies, private notes, system activity and attachments.

Requirements

ID Requirement Verified by
MSG-1 Message: {id, conversation, message_type: incoming|outgoing|activity|template, content? (≤ 150,000), content_type (default text), private (default false), status: sent|delivered|read|failed (default sent), external_error?, source_id?, sender? (user|contact; none = system), in_reply_to?, content_attributes, attachments[], created_at}. Messages are ordered by created_at asc, then id. A message needs content or ≥ 1 attachment. in_reply_to (message id) or in_reply_to_source_id is resolved within the same conversation; no match → null, not an error. empty message, no file → refused
MSG-2 content_type: text, input_text, input_textarea, input_email, input_select, cards, form, article, incoming_email, input_csat. Interactive types need ≥ 1 item in content_attributes.items, each a map: select {title, value}; cards {title, description?, media_url?, actions[{type: link|postback, text, uri?, payload?}]}; form {name, type, label, placeholder?, required?, options?}; article {title, description?, link}; unknown keys → refused. cards item without actions → refused
MSG-3 Agent create: message_type defaults to outgoing, sender = the user; incoming only through a channel adapter or the public API (CHN-11), else incoming_not_allowed. Each new message sets the conversation's last_activity_at. An optional client echo_id is returned in the live event, not stored. agent posts incoming in email inbox → refused
MSG-4 Private note = outgoing with private true: visible only to agents with access, never sent to a channel, the widget or the public API, never reopens, notifies or counts as a reply. note → absent from widget list
MSG-5 Variables in outgoing and template content are replaced at creation: {{contact.name}}, {{contact.first_name}}, {{contact.last_name}}, {{contact.email}}, {{contact.phone}}, {{contact.custom_attribute.<key>}}, {{agent.name}}, {{agent.first_name}}, {{agent.last_name}}, {{agent.email}}, {{conversation.id}} (display id), {{conversation.custom_attribute.<key>}}, {{inbox.name}}, {{account.name}}; unknown → empty; text inside backticks and malformed templates are left as is. Hi {{contact.first_name}} to Ann Lee → Hi Ann
MSG-6 Human reply = public outgoing message sent by a user (or echoed by the channel as sent from its native app), not forwarded, not a template and not sent by the system (auto-resolve). Only human replies clear waiting_since and set first_reply_at (CONV-6). template greeting → still waiting
MSG-7 Activity messages (activity, sender none) are added for status change, assignee change, group change, label add/remove, priority change, mute/unmute, with content_attributes.activity {type, actor (user name or system), from?, to?}. A group change that also changes the assignee writes only the group entry. resolve by Ann → activity "resolved", actor Ann
MSG-8 Flood limit: a conversation accepts at most 200 messages per minute; more → 429 rate_limited. 201st in a minute → 429
MSG-9 Attachments: ≤ 15 per message; size ≤ the instance limit (default 40 MB); type image (jpeg, png, gif, bmp, webp), video (mp4, webm, ogg, quicktime), audio (any audio), file (pdf, txt, csv, rtf, json, xml, zip, 7z, rar, tar, office and open-document formats) or location {lat, lng}; file_type set from the MIME type; images get a 250 px wide thumbnail. 16 files → refused
MSG-10 Status moves only forward: sent → delivered → read; failed can be set from and left to any status; external_error is kept only while failed. Retry: an agent may resend a failed message once per request: status → sent, error cleared, handed to the channel again (CHN-5); other statuses → 422. delivered after read → stays read
MSG-11 Delete (soft): content replaced by a fixed deleted marker, content_type → text, content_attributes → {deleted: true}, attachments destroyed. Allowed for own outgoing messages and notes (member+) and any non-activity message (admin+); activity messages cannot be deleted. No content editing. member deletes colleague's reply → 403
MSG-12 Paging: newest 20 by default; before=<id> the 20 before; after=<id> the next 100; both → up to 1,000 between them; hide_internal=true drops notes and activity. before oldest id → empty
MSG-13 Mentions: a note may mention users or groups (mention://user/<id> or mention://group/<id> links); a group expands to its members; only users with access to the conversation count. Each mention records (user, conversation, mentioned_at) (unique, time refreshed), adds a participant and notifies (EVT-9). mention a group of 3 → 3 participants

Out of scope

  • Message translation, voice and video calls, stickers and provider-specific content types, rich-text editor details.

Decisions

  • Delete is limited to own messages for members and admin+ for others; the source let any agent delete any message, including customer messages.
  • The 15-attachment cap and size limit apply on every channel; the source checked size and type for the widget only and its count check could be bypassed.
  • Activity entries are written even without a user (actor system); the source skipped some.

Permissions

Status: Draft · Prefix: PERM

Purpose

Team roles (@specfork/teams:TEAM-2) applied to inboxes, conversations, contacts and settings.

Requirements

ID Requirement Verified by
PERM-1 Resource rows (@specfork/teams:TEAM-10). viewer+ (@specfork/teams:TEAM-11): read inboxes they belong to, accessible conversations and their messages, contacts, labels, groups, canned responses and custom attribute definitions; add private notes; own saved filters. member+: reply, label, assign, set status and priority on accessible conversations; bulk and macro runs on them; contacts create, update, notes, labels; canned responses create and own edits; own personal macros; own availability. admin+: inboxes create, update, delete, members, secrets; groups and members; labels; custom attribute definitions; global macros; others' canned responses and notes; contact delete, merge, import, export; conversation delete; message delete of others (MSG-11); others' availability; workspace support settings (auto-resolve, reporting time zone, inbound email domain); reports and surveys; webhook subscriptions. owner: nothing beyond @specfork/teams:TEAM-6. member deletes contact → 403; viewer replies → 403
PERM-2 Every inbox-scoped and conversation-scoped check uses the member's current role and memberships at request time, for sessions and API keys alike (@specfork/auth:AUTH-20); removal from an inbox or group ends access on the next request. removed from inbox → next read 403
PERM-3 Conversation access: admin+ all; a member when they belong to the conversation's inbox or to its group. The same rule governs reading by id, lists, counts, search, filters, bulk, macros, mentions, participants, notifications and live updates. member of group Billing, not of inbox → sees Billing conversations in lists
PERM-4 Contacts are workspace-wide: any member reads all contacts, even those whose conversations they cannot access; conversation lists inside a contact show only accessible ones. contact page → only accessible conversations
PERM-5 Personal resources (saved filters, personal macros) are visible and editable only by their owner, whatever their role. admin reads another's saved filter → 404
PERM-6 Public endpoints (widget, public inbox API) never accept agent credentials and reach only the identity's own data (CHN-11, WID-3); the workspace's require_2fa (@specfork/teams:TEAM-12) applies to agents only, never to them. widget token reads other visitor's conversation → 404

Out of scope

  • Custom roles and per-permission grants (enterprise-only in the source).

Decisions

  • Fixed roles from @specfork/teams; the source's agent maps to member and administrator to admin+. viewer (not in the source) reads and adds private notes but never replies, assigns or changes status, for supervisors and auditors.
  • Group membership grants full conversation access, consistently; the source granted it for opening by id only.
  • Labels are readable one by one by members; the source allowed listing but not showing a single label.

Productivity

Status: Draft · Prefix: PRD

Purpose

Tools that make agents faster and data consistent: labels, canned responses, macros, custom attributes and saved filters.

Requirements

ID Requirement Verified by
PRD-1 Label: {id, title (2–255, lower-cased, starts with a letter or digit, then letters, digits, _or-; Unicode letters allowed), description?, color (#RRGGBB, default #1f93ff), show_on_sidebar (default true)}; title unique per workspace. Create, update, delete: admin+; list and show: member+. title -vip → refused; VIP → stored vip
PRD-2 Conversations and contacts carry labels from the workspace's defined set only; undefined → 422 naming them. Calls: replace the set, add, remove. Each change on a conversation writes add/remove activity (MSG-7). add ghost → 422
PRD-3 Renaming a label updates it on every conversation and contact in the background, without activity messages; deleting a label removes it from all of them the same way. rename vip → gold → conversations show gold
PRD-4 Canned response: {id, short_code (1–100, no spaces, unique per workspace case-insensitive), content (1–20,000, may hold variables [MSG-5](#MSG-5)), created_by}. Any agent creates; author or admin+ edits and deletes. Variables are filled per conversation when the message is created. duplicate Refund vs refund → refused
PRD-5 Canned search: case-insensitive on short_code and content; ranked short_code prefix, then short_code substring, then content substring; no query → all, by short_code. ref → refund before pre-refund
PRD-6 Macro: {id, name (1–255), visibility: personal|global (default personal), actions[] (1–20, ordered), created_by}. Members create only personal macros. Listing shows global macros plus the user's own. Run and show: global or own; edit and delete: own personal, or admin+ for global. member creates global → stored personal
PRD-7 Macro actions {action, params}: assign_agent (user id, self, or null), assign_group (id or null), add_label, remove_label, change_status (open|pending|resolved|snoozed), resolve, snooze, change_priority (value or null), send_message (text with variables), add_private_note, send_attachment (stored file), mute. Unknown action or invalid params → refused on save. action teleport → refused
PRD-8 Running a macro on ≤ 100 conversations applies its actions in order to each, as the running user, with the same rules and permissions as the manual calls; a failing action is recorded and the rest continue; inaccessible conversations are skipped. macro with invalid assignee → labels still added
PRD-9 Custom attribute definition: {id, model: conversation|contact, key (PRD-10), display_name (1–255), display_type: text|number|currency|percent|link|date|list|checkbox, description?, values[] (list only, ≥ 1), pattern? (regex, text only), pattern_hint?}. Create, update, delete: admin+; list: member+. list type without values → refused
PRD-10 Definition key: letters, digits, _, ., -; unique per workspace and model; never equal to a standard filter key of that model (CONV-11, CON-8), checked on create and on rename. contact key email → refused; rename to email → refused
PRD-11 Values written to custom_attributes with a definition are checked: number, currency, percent → number; date → ISO-8601 date; link → absolute http(s) URL; checkbox → boolean; list → one of values; text → string matching pattern if set. Keys without a definition are kept as given. list value not in values → 422
PRD-12 Changing a definition updates widget pre-chat fields with the same key (WID-5); deleting it removes that pre-chat field and the filter key; stored values stay. delete definition → pre-chat field gone
PRD-13 Saved filter: {id, user, name (1–255), filter_type: conversation|contact|report, query (condition list [CONV-10](#CONV-10))}, private to its user; ≤ 1,000 per user per workspace; editing never counts against the limit. 1,001st → refused; edit at 1,000 → allowed

Out of scope

  • Automation rules (event-triggered actions) and their delays; help center and articles.

Decisions

  • Labels must be defined before use everywhere; the source checked this only in the widget and import.
  • Canned responses get an author; the source let any agent change any shared response.
  • Custom attribute values are validated by type on the server; the source checked them only in the client.
  • The standard-key clash is checked on rename too; the source checked it on create only.

Reports

Status: Draft · Prefix: RPT

Purpose

How fast and how well the team answers: response and resolution metrics, summaries and satisfaction surveys.

Requirements

ID Requirement Verified by
RPT-1 Reporting event, written when it happens and never changed: {name: conversation_opened|conversation_resolved|first_response|reply_time, value (seconds), value_in_business_hours (seconds), workspace, inbox, user?, conversation, started_at, ended_at}. resolve → one conversation_resolved event
RPT-2 conversation_opened: value 0 on creation; on reopen, seconds since the last resolve. conversation_resolved: seconds from the latest open (creation or reopen) to the resolve, user = assignee at that moment. created 10:00, resolved 10:30 → 1,800
RPT-3 first_response: seconds from the latest open to the first human reply (MSG-6) after it, user = replier. reply_time: for every later human reply, seconds from waiting_since to the reply, user = replier. wait 10:00, reply 10:05 → 300
RPT-4 Business-hours value counts only seconds inside the inbox's open schedule (INB-8) in its time zone; with working_hours_enabled false it equals value. asked Fri 16:50, replied Mon 09:10, 09–17 → 1,200
RPT-5 Metrics for a range: conversations_count (created), incoming_messages_count, outgoing_messages_count (public, by agents), avg_first_response_time, avg_resolution_time, avg_reply_time, resolutions_count; averages over the events in range, in seconds; business_hours=true uses business-hours values. 2 replies 60 s and 120 s → 90
RPT-6 Time series: since, until (unix seconds, required, until exclusive, else 422), group_by: hour|day|week|month|year (default day), timezone_offset or the workspace reporting_time_zone (IANA, default the team's, @specfork/teams:TEAM-13), scope workspace|inbox|agent|group|label with id; empty buckets returned as 0; averages also return a per-bucket count. 3-day range by day → 3 buckets
RPT-7 Agent scope = conversations assigned to the agent, messages the agent sent and events with user = the agent. Group and label scopes use the conversation's group and labels at report time. relabel later → counts move
RPT-8 Summaries per agent, inbox, group and label for a range ≤ 6 months (longer → 422): conversations_count, resolutions_count, avg_first_response_time, avg_resolution_time, avg_reply_time; every agent, inbox and group is listed, with zeros when idle. CSV download of each summary with durations formatted as hours, minutes, seconds. 7-month range → 422
RPT-9 Live counts: open, unattended (open, no first reply or waiting), unassigned (open), pending; also grouped by assignee or by group. 2 open unassigned → unassigned 2
RPT-10 Satisfaction survey: inbox csat_rules {message?, display_type: emoji|star (default emoji), label_operator: contains|does_not_contain, labels[]}. When a conversation becomes resolved in an inbox with csat_enabled, and no survey was ever sent in it, and the label rule passes (no labels = pass), a template message with content_type input_csat is sent; outside the reply window (CHN-7) an activity note records that it was not sent. resolve twice → one survey
RPT-11 Survey response: {conversation, message (unique), contact, assignee (at response time), rating 1–5, feedback? (≤ 2,000), created_at}; the contact may change it for 14 days after the survey was sent, then 403. rating 6 → refused
RPT-12 Survey report (admin+): responses filterable by range, agent, inbox, group, rating; 25 per page; metrics total_responses, ratings_count {1..5}, total_sent, response rate; CSV download. 4 sent, 2 answered → rate 50%

Out of scope

  • SLA reports, bot resolution and hand-off metrics, traffic heatmaps, pre-aggregated rollups.

Decisions

  • Resolution time counts from the latest open, so reopened conversations are not over-counted; the source counted from creation.
  • Reply time is credited to the replier, not the assignee.
  • Business-hours values equal raw values when the inbox has no schedule; the source stored 0.
  • A missing since or until is refused; the source silently returned nothing.
  • The 14-day edit window applies on every path; the source missed it in the widget.

Widget

Status: Draft · Prefix: WID

Purpose

A chat bubble embedded on a website: anonymous visitors chat at once, and signed-in users of that site can be identified.

Requirements

ID Requirement Verified by
WID-1 Widget channel: {website_url, color (#RRGGBB, default #1f93ff), website_token (public, ≥ 128 random bits), hmac_token, hmac_required (default false), reply_time: in_a_few_minutes|in_a_few_hours|in_a_day (default in_a_few_minutes), welcome_title?, welcome_tagline?, features {attachments, emoji_picker, end_conversation, allow_mobile_webview} (first three default true), allowed_domains[], pre_chat {enabled (default false), message?, fields[]}, email_continuity (default true)}. Identity kind: uuid. new widget → end_conversation on
WID-2 Visitor token: loading the widget with a missing, invalid or expired token creates an anonymous visitor contact with a generated name and an identity, and returns a signed token {source_id, inbox_id, issued_at, expires_at} valid 180 days. expired token → new visitor and token
WID-3 Every widget call resolves the inbox by website_token and the identity by the token's source_id within that inbox; unknown → 404. token from inbox A on inbox B → 404
WID-4 Identify: set_user {identifier, identifier_hash?, name?, email?, phone?, avatar_url?, custom_attributes?} follows CHN-12 and matches or merges contacts per CON-5. If the current contact already has a different identifier, a new contact identity and token are issued instead of overwriting. Invalid email or phone values are dropped, not fatal. logged-in user B on A's browser → new token for B
WID-5 Pre-chat field: {name, label, type: text|email|number|phone|list|checkbox|date, enabled, required, placeholder?, values[]? (list), pattern?, pattern_hint?}. Built-in names email, full_name, phone set the contact; other names set a contact or conversation custom attribute with the same key (PRD-10). Enabled required fields are checked server-side. required email missing → refused
WID-6 Start conversation: updates the contact from the pre-chat values (name only when empty), creates a conversation with additional_attributes {browser_language, browser, platform, referer, initiated_at} and the first incoming message. start → conversation with referer URL
WID-7 Send message: content and/or attachments (allowed only when features.attachments) into the conversation chosen by CHN-4; optional labels on a new conversation keep only labels defined in the workspace. label vip not defined → ignored
WID-8 The visitor lists only messages that are not private and not activity, paged as MSG-12; live updates follow EVT-8. private note → not in widget
WID-9 End conversation: resolves it with the contact as actor when features.end_conversation; else 403. Already resolved → no change. feature off → 403
WID-10 Embedding: with allowed_domains empty the widget may load on any site; else only in pages from those domains. With allow_mobile_webview, requests without a web origin (blank or file origin) are also allowed. page on other.com, allowed shop.com → not loaded
WID-11 Email continuity: when on and the contact has an email, public replies the contact has not seen (CONV-13) are emailed: one email per conversation per 2-minute batch, holding the new replies plus up to 10 earlier messages; replies to that email thread back per EML-3. 3 replies in 1 min, unseen → one email
WID-12 Email collect: in a widget inbox with email_collect, the first incoming message from a contact without email adds, once per conversation, a template message asking for an email (content_type input_email); submitting it sets the contact's email (CON-5 rules). visitor answers → contact email set
WID-13 Custom events: the page can send {name (1–255), info?}; each emits widget.triggered (EVT-2) with the identity, name, info and browser data. event opened_pricing → webhook delivered

Out of scope

  • Widget look and layout, help-center article search, campaigns shown in the widget, the widget's client SDK.

Decisions

  • The widget follows the inbox's conversation selection (CHN-4); the source always appended to the visitor's latest conversation.
  • end_conversation is a state-changing POST; the source routed it as GET.

From Specfork/support-inbox at b49da55, licence CC-BY-4.0.