Team inbox on Kinetix Channels lines: conversations, per-user unread, line agents, replies with files and quotes, quick replies, internal notes, assignment, archive, receipts. Activates when working with KinetixInbox, useKinetixInbox, conversations, ConversationMessageCreated or line agents.
php artisan boost:add-skill happones/kinetix
Save this content to: .claude/skills/kinetix-inbox/SKILL.md
# Kinetix Inbox Development
## When to Apply
Activate this skill when:
- Mounting `<KinetixInbox>` or using `useKinetixInbox`.
- Working with `Conversation` / `ConversationMessage`, `InboxManager`, line agents or `ConversationMessageCreated`.
- Changing how messages are recorded, sent, or how receipts and media flow.
## Documentation
For full details, reference `docs/inbox.md` (published at https://happones.github.io/kinetix/inbox).
## Configuration
```bash
php artisan vendor:publish --tag=kinetix-inbox-migrations # needs kinetix-channels-migrations
```
```php
'inbox' => [
'enabled' => env('KINETIX_INBOX_ENABLED', false), // also needs kinetix.channels.enabled
'disk' => null, // null = filesystem.private_disk
'directory' => 'kinetix/inbox',
'max_upload_kb' => 16384, 'accepted_mimes' => [...],
'members' => null, 'members_relation' => 'members', 'name_attribute' => 'name',
'contact_resolver' => null, // [Class::class, 'method'](Conversation) → ?Model
'poll' => ['list' => 15, 'thread' => 5],
],
```
## Rules
- **Tables**:
- `kinetix_conversations` (line × `contact_key`, unique)
- `kinetix_conversation_messages` (direction `in|out|note`; status `received|pending|sent|delivered|read|failed`; unique `conversation_id+external_id`)
- `kinetix_conversation_reads` (per-user cursor)
- `kinetix_channel_agents`
- `kinetix_quick_replies` (team × `shortcut`, unique)
- **Visibility**: always go through `InboxManager::conversationsFor($user, $teamKey)` and `visibleChannelIds()`.
- `conversations.view_all` or `channels.manage` sees every line.
- `conversations.view` sees only lines where the user is an agent.
- The same rules apply to endpoints, media and the broadcast channel `kinetix.inbox.{channelId}`.
- **Recording**: listeners on `ChannelMessageReceived` / `ChannelMessageStatusUpdated` call `recordInbound` / `recordStatus`.
- Dedupe by `external_id`.
- Media goes through the queued `DownloadConversationMedia` → driver `DownloadsMedia`, onto the private disk.
- Receipts only move forward (`MessageStatus::rank()`).
- **Sending**: `InboxManager::send($conversation, $author, $text, $file, $replyTo, $note)`.
- It stores the message first (pending), then `deliver()` → driver `SendsMessages::send(OutgoingMessage)` returns the external id.
- Failures stay `failed` with an `error`; retry with `deliver()`.
- Notes never call the provider.
- **Broadcast** payloads are ids only (`conversationId`, `channelId`, `messageId`); the client refetches through the authorized endpoints.
- **Hook for bots**: `ConversationMessageCreated` (contact messages only). Reply via `InboxManager::send` so it shows in the thread.
- **Frontend**:
- `<KinetixInbox>` = `Inbox/KinetixInboxList` + `KinetixInboxThread` (+ `KinetixInboxBubble`, `KinetixInboxComposer`).
- Optimistic send uses `localId`.
- Images dispatch a cancelable `kinetix:preview`; if unhandled, they open in a new tab.
- **Quick replies**:
- Endpoints: `inbox/replies` (GET `{replies, canManage}` for `conversations.view`; POST/PUT/DELETE behind `conversations.quick_replies`).
- Shortcuts are stored lowercased without the `/`; `[\pL\pN_-]+`, unique per team.
- The composer suggests on `/word` before the caret and fills `{{contact.name}}`, `{{contact.key}}`, `{{agent.name}}`, `{{line.name}}` on insert. `KinetixInboxQuickReplies` is the manager modal.
- **Messages sent outside the inbox** (WhatsApp `fromMe` on `messages.upsert`, and `send.message`):
- The driver returns them as `WebhookResult::$sent` → `ChannelMessageSent` → `InboxManager::recordSent()` (`inbox.mirror_sent`, default on).
- The origin comes from the event, not from whether the line has a bot: `InboundMessage::$fromPhone` is true on `messages.upsert` `fromMe` (typed on the phone), false on `send.message` (the server sent it).
- Phone sends get `meta.from_phone` and fire `ConversationReplied`, even on a bot line. Server sends on a line with `meta.bot` get `meta.bot` + `meta.bot.name`; on a line without one they're unsigned (no meta) and fire nothing. Custom drivers set `fromPhone` on what a person typed.
- The inbox's own sends coming back are deduped by `external_id`, or claimed by `claimOwnSend()` (oldest recent pending/failed Out message, same body). Never count as unread.
- **Deleted for everyone**: WhatsApp `messages.delete` (one key, a list, or `{keys: [...]}`) / REVOKE `protocolMessage` → `ChannelMessageDeleted` → `InboxManager::recordDeleted()` sets `meta.deleted_at` (payload `deletedAt`); the message stays.
- **Writing first**: `InboxManager::conversationWith($line, $contactKey, $name)` + `send($conversation, null, $text)` (no author = no `ConversationReplied`).
- **Variables**: `send()` fills `{{contact.name|contact.key|agent.name|line.name}}` plus `inbox.variables` (callable(Conversation, ?author): array); unknown stay.
- **Line visibility**: `channels.visible` callable(Builder $lines, ?user) via `Channel::visibleTo()` in the manager, the plan count, agents, the inbox and flows.
Activate this skill when:
<KinetixInbox> or using useKinetixInbox.Conversation / ConversationMessage, InboxManager, line agents or ConversationMessageCreated.For full details, reference docs/inbox.md (published at https://happones.github.io/kinetix/inbox).
php artisan vendor:publish --tag=kinetix-inbox-migrations # needs kinetix-channels-migrations
'inbox' => [
'enabled' => env('KINETIX_INBOX_ENABLED', false), // also needs kinetix.channels.enabled
'disk' => null, // null = filesystem.private_disk
'directory' => 'kinetix/inbox',
'max_upload_kb' => 16384, 'accepted_mimes' => [...],
'members' => null, 'members_relation' => 'members', 'name_attribute' => 'name',
'contact_resolver' => null, // [Class::class, 'method'](Conversation) → ?Model
'poll' => ['list' => 15, 'thread' => 5],
],
kinetix_conversations (line × contact_key, unique)kinetix_conversation_messages (direction in|out|note; status received|pending|sent|delivered|read|failed; unique conversation_id+external_id)kinetix_conversation_reads (per-user cursor)kinetix_channel_agentskinetix_quick_replies (team × shortcut, unique)InboxManager::conversationsFor($user, $teamKey) and visibleChannelIds().
conversations.view_all or channels.manage sees every line.conversations.view sees only lines where the user is an agent.kinetix.inbox.{channelId}.ChannelMessageReceived / ChannelMessageStatusUpdated call recordInbound / recordStatus.
external_id.DownloadConversationMedia → driver DownloadsMedia, onto the private disk.MessageStatus::rank()).InboxManager::send($conversation, $author, $text, $file, $replyTo, $note).
deliver() → driver SendsMessages::send(OutgoingMessage) returns the external id.failed with an error; retry with deliver().conversationId, channelId, messageId); the client refetches through the authorized endpoints.ConversationMessageCreated (contact messages only). Reply via InboxManager::send so it shows in the thread.<KinetixInbox> = Inbox/KinetixInboxList + KinetixInboxThread (+ KinetixInboxBubble, KinetixInboxComposer).localId.kinetix:preview; if unhandled, they open in a new tab.inbox/replies (GET {replies, canManage} for conversations.view; POST/PUT/DELETE behind conversations.quick_replies)./; [\pL\pN_-]+, unique per team./word before the caret and fills {{contact.name}}, {{contact.key}}, {{agent.name}}, {{line.name}} on insert. KinetixInboxQuickReplies is the manager modal.fromMe on messages.upsert, and send.message):
WebhookResult::$sent → ChannelMessageSent → InboxManager::recordSent() (inbox.mirror_sent, default on).InboundMessage::$fromPhone is true on messages.upsert fromMe (typed on the phone), false on send.message (the server sent it).meta.from_phone and fire ConversationReplied, even on a bot line. Server sends on a line with meta.bot get meta.bot + meta.bot.name; on a line without one they're unsigned (no meta) and fire nothing. Custom drivers set fromPhone on what a person typed.external_id, or claimed by claimOwnSend() (oldest recent pending/failed Out message, same body). Never count as unread.messages.delete (one key, a list, or {keys: [...]}) / REVOKE protocolMessage → ChannelMessageDeleted → InboxManager::recordDeleted() sets meta.deleted_at (payload deletedAt); the message stays.InboxManager::conversationWith($line, $contactKey, $name) + send($conversation, null, $text) (no author = no ConversationReplied).send() fills {{contact.name|contact.key|agent.name|line.name}} plus inbox.variables (callable(Conversation, ?author): array); unknown stay.channels.visible callable(Builder $lines, ?user) via Channel::visibleTo() in the manager, the plan count, agents, the inbox and flows.How can I help you explore Laravel packages today?