@specfork/support-inbox
Shared customer-support inbox with contacts, conversations, email and chat-widget channels, assignment, groups, labels, canned replies, business hours and reports.
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_resolvedapply 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.updatedis 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_sinceon every new conversation.
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
sinceoruntilis 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_conversationis a state-changing POST; the source routed it as GET.
From Specfork/support-inbox at b49da55, licence CC-BY-4.0.