# Messaging tabs

A `messaging`-category tab is a list of two-party conversation threads — the visitor talking to one or more humans on your side. Each row is one thread; clicking it opens a chat-style view with the entries inside. Compared to [default tabs](/oaura/tabs/default.md), the widget renders unread indicators, supports realtime updates, and gives you a reply composer.

Pick `messaging` whenever your data is conversation-shaped: customer support tickets that go back and forth, supplier chats, "Talk to the team" surfaces. Pick `default` for flat lists of independent records.

## Actions

| Action                                                          | HTTP   | Routing                       | What it does                                                              |
| --------------------------------------------------------------- | ------ | ----------------------------- | ------------------------------------------------------------------------- |
| [`get_items`](#get_items--fetch-the-conversation-list)          | `GET`  | Server-proxied (`tab-fetch`)  | Fetch the list of conversations. Required.                                |
| [`item_clicked`](#item_clicked--open-one-conversation)          | `GET`  | Server-proxied (`tab-action`) | Fetch one full thread when the visitor opens a row. Effectively required. |
| [`create_item`](#create_item--send-a-reply)                     | `POST` | Server-proxied (`tab-action`) | Visitor sends a reply via the composer.                                   |
| [`update_item`](#update_item--mark-read--per-message-edits)     | `POST` | Mixed — see action            | `MessagingView` auto-writes read receipts; per-row menu fires direct.     |
| [`delete_item`](#delete_item--remove-a-message-or-conversation) | `POST` | Mixed — see action            | Per-message delete inside `MessagingView`; per-row delete fires direct.   |

For the general definition of these actions and what "server-proxied" vs "direct" means, see [Prebuilt actions](/oaura/tabs/prebuilt-actions.md) and [Request format](/oaura/tabs/request-format.md).

***

## `get_items` — fetch the conversation list

The widget fires this when the visitor opens the tab, when it gains focus, when a [`realtime-publish`](/oaura/tabs/realtime-updates.md) broadcast lands, or when the visitor refreshes.

### Request

`GET <your-url>?…` with the standard server-proxied query parameters:

| Param                                      | Notes                           |
| ------------------------------------------ | ------------------------------- |
| `action`                                   | `get_items`                     |
| `agentId`, `agentName`, `tabId`, `tabName` | Caller context                  |
| `sessionId`                                | Widget session                  |
| `endUserId`, `endUserEmail`                | Verified from the visitor's JWT |
| `_ts`                                      | Cache-buster                    |

No request body. See [Request format](/oaura/tabs/request-format.md) for the full reference and credential-header behaviour.

### Response schema

The full response — envelope on the outside, item shape on the inside, as one JSON Schema:

```json
{
  "type": "object",
  "required": ["items"],
  "additionalProperties": false,
  "properties": {
    "items": {
      "type": "array",
      "items": { "$ref": "#/$defs/MessagingItem" }
    },
    "view": {
      "type": "string",
      "enum": ["list", "direct"]
    },
    "badge": { "$ref": "#/$defs/Badge" }
  },
  "$defs": {
    "MessagingItem": {
      "type": "object",
      "required": ["id"],
      "additionalProperties": true,
      "properties": {
        "id":               { "type": "string" },
        "title":            { "type": "string" },
        "preview":          { "type": "string" },
        "source":           { "type": "string" },
        "status":           { "type": "string" },
        "createdAt":        { "type": "string", "format": "date-time" },
        "updatedAt":        { "type": "string", "format": "date-time" },
        "counterpartyName": { "type": "string" },
        "owner":            { "$ref": "#/$defs/Owner" },
        "tags":             { "type": "array", "items": { "$ref": "#/$defs/Tag" } },
        "readBy":           { "type": "array", "items": { "$ref": "#/$defs/Read" } }
      }
    },
    "Owner": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "userId":  { "type": "string" },
        "email":   { "type": "string", "format": "email" },
        "display": { "type": "string" },
        "handle":  { "type": "string" }
      }
    },
    "Read": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "userId": { "type": "string" },
        "email":  { "type": "string", "format": "email" },
        "readAt": { "type": "string", "format": "date-time" }
      }
    },
    "Badge": {
      "type": "object",
      "required": ["color"],
      "properties": { "color": { "$ref": "#/$defs/PaletteColor" } }
    },
    "Tag": {
      "type": "object",
      "required": ["label", "color", "tooltip"],
      "additionalProperties": true,
      "properties": {
        "label":   { "type": "string" },
        "color":   { "$ref": "#/$defs/PaletteColor" },
        "tooltip": { "type": "string" }
      }
    },
    "PaletteColor": {
      "type": "string",
      "enum": ["green","yellow","red","blue","gray","purple","orange","teal","pink","brown"]
    }
  }
}
```

Use these keys exactly; aliases (`name`/`subject` for `title`, `description`/`body` for `preview`) are not accepted. `MessagingItem` is `additionalProperties: true` so anything extra you include is preserved on the item for diagnostics, but the row only renders the canonical fields.

The widget also accepts a bare array `[{…}, …]` as shorthand for `{ "items": [ … ] }` with no envelope. See [Response format](/oaura/tabs/response-format.md) for the full list of accepted wrapping shapes.

### Top-level fields

#### `items` — required

The conversation list. Each element matches `MessagingItem`. The widget does not sort — return in the order you want them rendered (typically by your equivalent of "last message activity" descending).

#### `view` — optional

Controls list rendering.

| Value              | Behaviour                                                                                                                                                                                                     |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"list"` (default) | Normal list rendering.                                                                                                                                                                                        |
| `"direct"`         | Skip the list. The widget auto-opens `item_clicked` on `items[0]` and drops the visitor straight into the conversation. The back button is hidden. Use for single-thread experiences like "Talk to the team". |

#### `badge` — optional

Colour of the unread badge on the tab strip. The count is derived from unread conversations (computed from `readBy`); you only supply the colour.

### `MessagingItem` sub-schema

#### `id` — required

Conversation id. Used as the React key, sent back to `item_clicked` as `id`, and used by [`realtime-publish`](/oaura/tabs/realtime-updates.md) to fire the per-conversation channel. The same id must round-trip identically across `get_items` → `item_clicked` → `realtime-publish`.

#### `title`

Primary label on the row. When missing, the row falls back to `Message N` (N = row position).

#### `preview`

Secondary line — usually the last message's content, truncated. No fallback.

#### `source`

Where the conversation originated. Common values: `"web"`, `"email"`, `"slack"`, `"teams"`. Surfaces in the avatar fallback chain and row metadata.

#### `status`

Free-form status string (`"open"`, `"resolved"`, `"escalated"`, `"pending"`). Rendered as a small badge next to the title.

#### `createdAt`, `updatedAt`

ISO date-times. The widget reads `updatedAt` first (falling back to `createdAt`) for the relative timestamp on the row.

#### `counterpartyName`

Display name of the *other* party in the conversation — the supplier, the customer, the operator. Used for the avatar initials and the subtitle on the row. If absent, the widget falls back to `owner.display`, then `source`, then `title`.

The visitor already knows who they are; the row should show who they're talking to.

#### `owner` (sub-schema)

The principal that owns the conversation on your side. The visitor never owns the row in this sense.

| Field     | Notes                                      |
| --------- | ------------------------------------------ |
| `userId`  | Stable id of the owner inside your store.  |
| `email`   | Owner's email. Optional.                   |
| `display` | Display name, used by the avatar fallback. |
| `handle`  | Short handle (`@alice`). Optional.         |

#### `tags` (sub-schema)

Coloured pill tags on the row. Each tag is `{ label, color, tooltip }` — all three required. Same fixed palette as `badge.color`. `tooltip` shows on hover.

#### `readBy` (sub-schema)

Per-conversation read receipts. The widget compares `userId` and `email` against the visitor's verified identity. If the visitor isn't in `readBy`, the row counts as unread.

When the visitor opens a conversation, the widget optimistically marks the row as read so the badge clears immediately. The next `get_items` replaces the optimistic state with whatever your store actually says.

### `PaletteColor` enum

Shared between `Badge.color` and `Tag.color`:

```
green | yellow | red | blue | gray | purple | orange | teal | pink | brown
```

Tailwind has no `brown` — the widget renders it as amber/stone (reads as brown in both themes). Unknown colours fall back to gray.

### Examples

**Minimum response:**

```json
{ "items": [] }
```

**Typical response:**

```json
{
  "items": [
    {
      "id":               "conv-771",
      "title":            "Booking 4571",
      "preview":          "Yes — gate code 8842, valid until checkout.",
      "source":           "web",
      "status":           "open",
      "createdAt":        "2026-05-18T11:02:00Z",
      "updatedAt":        "2026-05-20T08:21:11Z",
      "counterpartyName": "Garden staff",
      "owner": {
        "userId":  "u-4",
        "display": "Reception",
        "email":   "reception@example.com"
      },
      "tags": [
        { "label": "alert", "color": "yellow", "tooltip": "Awaiting your response" }
      ],
      "readBy": [
        { "userId": "u-99", "readAt": "2026-05-20T08:21:30Z" }
      ]
    }
  ],
  "view":  "list",
  "badge": { "color": "blue" }
}
```

**Direct-mode response** (skip the list, auto-open the first item):

```json
{
  "items": [
    { "id": "conv-771", "title": "Talk to the team" }
  ],
  "view": "direct"
}
```

***

## `item_clicked` — open one conversation

Fired when the visitor clicks a row, or — under `view: "direct"` — automatically on `items[0]` when the tab opens.

### Request

`GET <your-url>?…` with the standard server-proxied query parameters plus:

| Param | Notes                                                                 |
| ----- | --------------------------------------------------------------------- |
| `id`  | The conversation id from `MessagingItem.id` that the visitor clicked. |

No request body.

### Response schema

The full conversation. No `view` / `badge` envelope here — those exist only on `get_items`. A single-element outer array `[{…}]` is auto-unwrapped, so n8n's default works without changes.

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["id", "viewer", "parties", "entries"],
  "additionalProperties": false,
  "properties": {
    "id":        { "type": "string" },
    "title":     { "type": "string" },
    "status":    { "type": "string" },
    "createdAt": { "type": "string", "format": "date-time" },
    "updatedAt": { "type": "string", "format": "date-time" },
    "viewer":    { "$ref": "#/$defs/Viewer" },
    "parties":   { "type": "array", "items": { "$ref": "#/$defs/Party" } },
    "entries":   { "type": "array", "items": { "$ref": "#/$defs/Entry" } }
  },
  "$defs": {
    "Viewer": {
      "type": "object",
      "required": ["userId", "partyId"],
      "additionalProperties": true,
      "properties": {
        "userId":  { "type": "string" },
        "partyId": { "type": "string" }
      }
    },
    "Party": {
      "type": "object",
      "required": ["id"],
      "additionalProperties": true,
      "properties": {
        "id":   { "type": "string" },
        "name": { "type": "string" },
        "kind": { "type": "string" }
      }
    },
    "Entry": {
      "type": "object",
      "required": ["id"],
      "additionalProperties": true,
      "properties": {
        "id":        { "type": "string" },
        "parentId":  { "type": ["string", "null"] },
        "kind":      { "type": "string" },
        "title":     { "type": "string" },
        "content":   { "type": "string" },
        "createdAt": { "type": "string", "format": "date-time" },
        "author":    { "$ref": "#/$defs/Author" },
        "readBy":    { "type": "array", "items": { "$ref": "#/$defs/EntryRead" } }
      }
    },
    "Author": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "userId":  { "type": "string" },
        "partyId": { "type": "string" },
        "display": { "type": "string" },
        "email":   { "type": "string", "format": "email" }
      }
    },
    "EntryRead": {
      "type": "object",
      "additionalProperties": true,
      "properties": {
        "userId":  { "type": "string" },
        "display": { "type": "string" },
        "email":   { "type": "string", "format": "email" },
        "readAt":  { "type": "string", "format": "date-time" }
      }
    }
  }
}
```

> **`viewer` is the most important field.** Without it the widget treats every entry as a counterparty message and renders the visitor's own messages on the wrong side. Always set `viewer`.

The widget does not accept aliases: no `body`/`text` for `content`, no `created_at`/`timestamp` for `createdAt`, no `messages[]`/`data[]` for `entries[]`, no snake-case `user_id`/`party_id` on `author`.

### Top-level fields

#### `id` — required

Must equal the `MessagingItem.id` your matching `get_items` row carried.

#### `title`, `status`, `createdAt`, `updatedAt`

Surface in the `MessagingView` header. `title` is the conversation topic; `status` renders as a small badge; `updatedAt` (falling back to `createdAt`) is the relative time shown alongside.

#### `viewer` — required

The end-user fetching the conversation. Drives self-vs-other and teammate-vs-counterparty bubble placement.

| Field     | Notes                                                                                                   |
| --------- | ------------------------------------------------------------------------------------------------------- |
| `userId`  | Visitor's id inside your store. Must equal `endUserId` from the request — the `sub` claim of their JWT. |
| `partyId` | Which `parties[].id` the visitor belongs to.                                                            |

The widget uses `viewer.userId` for self vs other (when `entries[i].author.userId === viewer.userId`, the bubble is right-aligned) and `viewer.partyId` for teammate vs counterparty among the left-aligned bubbles.

#### `parties` — required

The distinct principals in the conversation. Typically two; sometimes three (visitor + supplier + operator).

| Field  | Notes                                                                           |
| ------ | ------------------------------------------------------------------------------- |
| `id`   | Required. Stable party id, referenced by `viewer.partyId` and `author.partyId`. |
| `name` | Display name (`"Acme Corp"`).                                                   |
| `kind` | Opaque to the UI. Common: `"organization"`, `"user"`, `"system"`.               |

The UI does **not** use party data for bubble colour. Colours are derived from the `viewer ↔ author` relationship, not from `parties`.

#### `entries` — required

The messages. Return them in render order — sort ascending by `createdAt` on your side (oldest first).

### `Entry` sub-schema

#### `id` — required

Stable id within the conversation. Used as the React key.

#### `parentId`

Optional. Previous entry in a reply chain — references another entry's `id` inside the same `entries[]` array. Not the conversation's own `id`. Preserved but not visualised; the UI renders flat.

#### `kind`

Opaque. Common: `"message"`, `"note"`, `"event"`. All render identically.

#### `title`

Optional. Renders bold above the bubble — useful for subject lines on email-relayed messages.

#### `content`

The body. Plaintext, newlines preserved (`whitespace-pre-wrap`). **Markdown is not rendered as HTML** here — markdown only renders on the default-category detail screen.

#### `createdAt`

ISO date-time. Per-bubble timestamp and ordering key.

#### `author` (sub-schema)

| Field     | Meaning                                                         |
| --------- | --------------------------------------------------------------- |
| `userId`  | Compared against `viewer.userId` for self-vs-other.             |
| `partyId` | Compared against `viewer.partyId` for teammate-vs-counterparty. |
| `display` | Display name shown next to the bubble.                          |
| `email`   | Optional.                                                       |

For system events (status change, routing message), omit `userId` and the bubble renders as a left-aligned counterparty message attributed to `display`.

#### `readBy` (sub-schema)

Per-entry read receipts. Renders "Read" / "Read by X" beneath the message. The visitor's own entry is added optimistically on open; update real `readBy` on your next write so the optimistic state is replaced by truth.

| Field     | Meaning                             |
| --------- | ----------------------------------- |
| `userId`  | Stable user id of the reader.       |
| `display` | Name shown in the "Read by X" line. |
| `email`   | Optional.                           |
| `readAt`  | ISO date-time of the read.          |

### Bubble placement, in plain English

| Author relationship to viewer      | Alignment | Colour                                       |
| ---------------------------------- | --------- | -------------------------------------------- |
| `author.userId === viewer.userId`  | Right     | Visitor accent — the agent's primary colour. |
| Different `userId`, same `partyId` | Left      | Teammate — neutral with a subtle tint.       |
| Different `partyId`                | Left      | Counterparty — a more saturated neutral.     |
| `author` missing or no `userId`    | Left      | Treated as counterparty.                     |

Colours are design tokens. The backend cannot override them.

### Examples

**Minimum response:**

```json
{
  "id": "conv-771",
  "viewer": { "userId": "u-99", "partyId": "p-guests" },
  "parties": [
    { "id": "p-guests" },
    { "id": "p-staff" }
  ],
  "entries": []
}
```

**Typical response:**

```json
{
  "id":        "conv-771",
  "title":     "Booking 4571",
  "status":    "open",
  "createdAt": "2026-05-18T11:02:00Z",
  "updatedAt": "2026-05-20T08:21:11Z",
  "viewer":    { "userId": "u-99", "partyId": "p-guests" },
  "parties": [
    { "id": "p-guests", "name": "Guests",       "kind": "organization" },
    { "id": "p-staff",  "name": "Garden staff", "kind": "organization" }
  ],
  "entries": [
    {
      "id":        "e-1",
      "kind":      "message",
      "content":   "Could you confirm parking is available?",
      "createdAt": "2026-05-20T08:14:00Z",
      "author": {
        "userId":  "u-99",
        "partyId": "p-guests",
        "display": "You",
        "email":   "guest@example.com"
      }
    },
    {
      "id":        "e-2",
      "kind":      "message",
      "content":   "Yes — gate code 8842, valid until checkout.",
      "createdAt": "2026-05-20T08:21:11Z",
      "author": {
        "userId":  "u-4",
        "partyId": "p-staff",
        "display": "Reception",
        "email":   "reception@example.com"
      },
      "readBy": [
        { "userId": "u-99", "display": "You", "readAt": "2026-05-20T08:21:30Z" }
      ]
    }
  ]
}
```

***

## `create_item` — send a reply

Fired by the composer at the bottom of the open thread. There is no other path that fires `create_item` — it's exclusively the messaging composer.

### Request

`POST <your-url>?…` with the standard server-proxied query parameters plus `conversationId` (always present, since `create_item` only fires inside an open thread) and `firedAt`.

Body — always:

```json
{
  "kind":      "message",
  "content":   "<text the visitor typed>",
  "createdAt": "<iso timestamp — matches the optimistic message in the widget>",
  "author": {
    "userId":  "<jwt sub claim — same as endUserId on the URL>",
    "partyId": "<from viewer.partyId in the conversation envelope>",
    "display": "<derived from email local-part>",
    "email":   "<jwt email claim>"
  }
}
```

### Response

No required shape. The widget reads only the HTTP status. Return `204 No Content`, or echo the persisted entry — same `Entry` shape as `item_clicked` returns inside `entries[]`. The widget does not insert your response into the visible thread; the thread refreshes on the next [`realtime-publish`](/oaura/tabs/realtime-updates.md) broadcast or when the visitor reopens it.

### Recommended handling

1. Look up `conversationId` in your store.
2. Insert a new entry with the body fields above.
3. POST to [`realtime-publish`](/oaura/tabs/realtime-updates.md) with `{ endUserId, conversationId }` so any subscribed widget refetches.
4. Return `204` or the inserted row.

The optimistic bubble appears in the visible thread before your webhook responds. On non-2xx the widget removes it and toasts "Failed to send".

### Example

```
POST https://workflows.example.com/webhook/create-item
  ?action=create_item
  &agentId=01H...
  &tabId=01H...
  &conversationId=conv-771
  &sessionId=sess-abc
  &endUserId=u-99
  &endUserEmail=guest%40example.com
  &firedAt=2026-05-20T09%3A01%3A20Z

Content-Type: application/json

{
  "kind":      "message",
  "content":   "Thanks, that's perfect.",
  "createdAt": "2026-05-20T09:01:20Z",
  "author": {
    "userId":  "u-99",
    "partyId": "p-guests",
    "display": "guest",
    "email":   "guest@example.com"
  }
}
```

***

## `update_item` — mark-read & per-message edits

Fires from two distinct surfaces with different bodies. Your webhook needs to accept both.

### Surface 1: server-proxied auto mark-read

When the visitor opens a conversation, `MessagingView` automatically fires `update_item` to write a read-receipt. Fires once on initial load and after each refresh.

`POST <your-url>?…` with the standard server-proxied query parameters plus `conversationId` and `firedAt`.

Body — always:

```json
{
  "type":   "mark_read",
  "seenAt": "<iso timestamp when the visitor opened the thread>"
}
```

Idempotent — fires multiple times per session. Treat as upsert on `(conversationId, endUserId)`.

### Surface 2: direct from per-row menu

When the visitor picks "Update" from a row's three-dot menu on the conversation list (not from inside an open thread). Fires straight to your webhook with no edge function — no identity forwarding, no credential header, **CORS required**.

`POST <your-url>`, body:

```json
{
  "action":   "update_item",
  "tabId":    "<uuid>",
  "tabName":  "<string>",
  "sessionId": "<widget session id>",
  "item":     { /* the full MessagingItem from get_items */ },
  "firedAt":  "<iso timestamp>"
}
```

No form behind the button — the click itself is the entire input. Useful for one-shot toggles (mute, pin, snooze). For anything that needs user input, you'd need a custom UI which the widget doesn't have.

### Response

Any JSON. Widget reads HTTP status. On 2xx toasts "Done" and refetches; non-2xx toasts "Action failed".

***

## `delete_item` — remove a message or conversation

Same dual-surface pattern as `update_item`.

### Surface 1: server-proxied per-message delete

Fires when the visitor deletes a single entry inside `MessagingView`.

`POST <your-url>?…` with server-proxied query parameters plus `conversationId` and `firedAt`.

Body — always:

```json
{ "id": "<entry-uuid>" }
```

`id` is the entry id (the message), not the conversation id (`conversationId` is on the URL).

### Surface 2: direct from per-row menu

When the visitor picks "Delete" from a conversation list row's menu.

`POST <your-url>`, body:

```json
{
  "action":   "delete_item",
  "tabId":    "<uuid>",
  "tabName":  "<string>",
  "sessionId": "<widget session id>",
  "item":     { /* the full MessagingItem */ },
  "firedAt":  "<iso timestamp>",
  "id":       "<conversation id, when available>"
}
```

CORS required, no identity forwarding.

### Recommended handling

On server-proxied per-message delete: verify `entries.author.userId === endUserId` before deleting (visitors should only delete their own messages), then call `realtime-publish` so subscribed widgets refetch.

The widget removes the row optimistically. On non-2xx it restores and toasts "Action failed". No confirmation dialog — the row disappears immediately on click.

***

## Realtime updates

Messaging tabs subscribe to two channels:

* **User channel** (`agent-<agentId>-user-<endUserId>-<hmac>`) — triggers a `get_items` refetch. Subscribed when the tab loads; persists across tab switches.
* **Conversation channel** (`conv-<conversationId>-<hmac>`) — triggers an `item_clicked` refetch on the open thread. Subscribed when the visitor opens a conversation; torn down on close.

To fire either, POST to the [`realtime-publish`](/oaura/tabs/realtime-updates.md) edge function from your backend whenever you write a new entry. One call notifies both channels:

```
POST https://<supabase-ref>.supabase.co/functions/v1/realtime-publish
x-api-key: <agent api key>
Content-Type: application/json

{
  "endUserId":      "u-99",
  "conversationId": "conv-771"
}
```

See [Realtime updates](/oaura/tabs/realtime-updates.md) for the full reference.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.tillforty.com/oaura/tabs/messaging.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
