---
name: linbox-messaging
description: >-
  Read the Linbox LinkedIn inbox and threads, search threads in the local DB, and
  send messages or queue follow-ups via linbox_get_inbox, linbox_get_thread,
  linbox_search_thread, and linbox_send_message. Use when a request involves reading
  LinkedIn conversations, finding a thread by name/tag/message text, opening one by
  profile URL, or sending a reply or first message through Linbox, or when calling any
  of those linbox_* messaging tools.
contract_version: "3.0"
---

# Linbox messaging

Messaging flows for the Linbox LinkedIn MCP server: read the inbox, open a full thread,
search threads in the local DB, and send a message. This skill assumes the global
contract from the root [Linbox](../SKILL.md) skill (auth, identifiers, `meta`, cursor
pagination, task polling) and only covers the four messaging tools.

## When to use

Use this skill when the request is about LinkedIn conversations through Linbox:

- listing recent inbox threads
- opening one conversation by thread id, thread ref, or partner profile URL
- finding threads by partner name, tag, message content, or reply status
- sending a reply into a known thread, or a first message to a person by profile/persona

For tags, connections, or profile/company enrichment, use the sibling topic skills in
[Related skills](#related-skills).

To see how many threads are waiting before digging in, call `linbox_whats_new` — it counts
unread threads but never clears them; a thread clears only when opened with
`linbox_get_thread`.

## Prerequisites

- A Linbox MCP token scoped to one LinkedIn `account_id` (no account param is passed).
- The MCP client connected to `<api-host>/mcp` with `Authorization: Bearer <token>`.
- Set `X-Linbox-Response-Format: json` for programmatic parsing; field names below match
  the JSON shape.

## Tools

| Tool | Direction | Purpose |
|---|---|---|
| `linbox_get_inbox` | read | Cached threads; enqueues a background refresh via `task` |
| `linbox_get_thread` | read | Cached thread + messages; marks read; enqueues refresh via `task` |
| `linbox_search_thread` | read (DB only) | Filter threads in the local DB with cursor pagination |
| `linbox_send_message` | write | Queue a reply (enqueue-only), or a follow-up by target |

### `linbox_get_inbox(limit_threads=30)`

Returns `threads[]` (clamp 1–100, default 30) plus `meta`, `task`, `guidance`. Always
serves cache (`meta.data_source="cache"`) — it never scrapes LinkedIn inline. When the
session is active it **enqueues a background refresh** (subject to the limit gate) and
returns a pollable `task`; when the session is inactive there is no task
(`meta.reason="account_session_not_active"`).

That `scrap_inbox` task **enriches up to 5 threads inline** in the same messaging session
and queues the overflow as separate `scrap_thread` batches. Poll the parent `task_id` —
child ids are never returned. Once it is `done`, those threads' messages are in the DB
(call `linbox_get_thread` to read them) and `output.scraped_threads[]` reports the
per-thread result (`status`, `thread_source_id`); overflow threads arrive in later sessions.

Key thread fields: `id` (Linbox `thread_id`), `thread_source_id`, `thread_url`,
`persona_id`, `partner_name`, `partner_linkedin_url`, `partner_public_id`, `last_message`,
`last_message_author` (`"Me"` or partner name), `last_message_at(_ago)`, `is_read`,
`read_state.linkedin_is_read`, `pending_remote_update`, `tags`, `persona_tags`.

### `linbox_get_thread(thread_id=None, linkedin_thread_ref=None, profile_url=None)`

At least one identifier is required. `thread_id` = `threads.id` UUID; `linkedin_thread_ref`
= thread URL or `thread_source_id`; `profile_url` = a partner's LinkedIn URL or `public_id`,
which resolves to that person's thread. If both `thread_id` and a ref are passed,
`thread_id` wins and the ref is validated against it.

Returns `thread` (list-preview fields + `read_state` timestamps), `participants[]`
(owner first, then partner: `name`, `linkedin_url`), `messages[]` (`text`, `sent_at`,
`sent_ago`, `author`), and `meta` / `task` / `guidance`.

- `author` in `messages[]` is a LinkedIn public id, not a display name: outgoing →
  owner `accounts.linkedin_public_id`; incoming → partner `partner_public_id`. Match to
  `participants[].linkedin_url` via the `/in/<public_id>/` slug.
- `read_state.agent_read_at` is upserted on every `linbox_get_thread` call; a deferred
  refresh may enqueue `scrap_thread` and (for unread threads) `mark_thread_read`.
- **`profile_url` cache miss is a structured non-error**, not an `Error:` string: when no
  thread for that person is cached, it returns `thread=null`, `participants=[]`,
  `messages=[]`, and `meta.reason="thread_not_in_cache"`. If the LinkedIn session is
  active, it queues (or reuses) a `scrap_thread` task and sets `task` + `guidance` to poll;
  otherwise `meta.reason="account_session_not_active"`. Branch on `thread==null`, then poll
  `task.task_id` — do not treat it as a failure.
- **One `scrap_thread` task carries up to 5 thread targets.** A new request is appended to
  an existing unlocked pending batch when possible, so repeated or nearby calls can return
  the **same `task_id`** with its already-scheduled slot. That is reuse, not an error — do
  not expect a fresh task per thread.
- **`meta.reason="no_messaging_conversation"` is terminal.** When a recent `scrap_thread`
  already confirmed LinkedIn has no DM thread with that person, no new scrap is queued for
  ~24h: `thread=null` and `task` points at the finished task (`task_status="done"`). Stop
  here — start a conversation first (connection request, or `linbox_send_message` in target
  mode); re-calling `linbox_get_thread` returns the same result.

### `linbox_search_thread(name, tags, message_contains, reply_status, cursor, limit_threads=30)`

Searches the local DB via Postgres RPC (`search_threads_for_account`). **No live
LinkedIn** — `meta.data_source` is always `cache`. Returns threads in inbox-preview
format (each with `unread_incoming_count`, int) plus a `page` block, and echoes active
filters.

There is **no `linkedin_url` filter** here. To open a specific person's thread by URL, use
`linbox_get_thread(profile_url=...)` instead.

**At least one filter is required:** `name`, `tags`, `message_contains`, or `reply_status`.

| Filter | Meaning |
|---|---|
| `name` | Substring of partner name (`pg_trgm` + `ILIKE`) |
| `tags` | `string[]`, **OR** semantics; matches thread tags and inherited persona tags |
| `message_contains` | Substring in any message body (`messages.text ILIKE`) |
| `reply_status` | `unread` \| `replied` \| `no_reply` (omit = no filter) |

`reply_status` semantics (incoming = a message with `messages.persona_id` set; outgoing
messages never make a thread unread):

- `unread` — has an incoming message with `sent_at` after the latest `thread_reads.read_at`
  across all viewers (i.e. `unread_incoming_count > 0`).
- `replied` — has incoming messages but none unread (all opened by us).
- `no_reply` — no incoming messages at all (outgoing-only or empty).

Different filter dimensions combine with **AND**; values inside `tags` combine with OR.

**Cursor pagination** (keyset, not offset):

- Input `cursor`: omit on the first call; pass the previous `page.next_cursor` afterward.
  An invalid token returns `Error: ...`.
- Output `page`: `limit` (clamp 1–100), `returned`, `has_more`, `next_cursor`
  (`null` when `has_more=false`).
- Loop: while `page.has_more`, repeat with the **same filters** and `cursor=page.next_cursor`.

### `linbox_send_message(message_text, thread_id=None, linkedin_thread_ref=None, profile_url=None, persona_id=None)`

Write tool with two modes. Never falls back to cache. `message_text` is required.

**Reply mode** — a `thread_id` or `linkedin_thread_ref` is provided. **Enqueue-only**:
the message is never sent inline, so `sent` is always `false`.

- Session active → enqueues a `send_message` task: `status="queued"` (or `queued_deferred`
  when the limit gate defers it), with `task_id`, `task_status`, `scheduled_for(_ago)`,
  `estimated_done_for`, `estimated_done_in`, and `guidance` naming the `task_id` to poll.
  Confirm delivery by polling `linbox_get_task(task_id)` to `done`/`failed`.
- Session inactive → `status="blocked"`, `sent=false`,
  `meta.reason="account_session_not_active"`, no task. No cache fallback.

**Target mode** — no thread ref, but `profile_url` and/or `persona_id`
(`decide_send_target` picks the next step and enqueues it):

| Situation | `status` | Enqueued task |
|---|---|---|
| Persona unknown | `checking_profile` | `scrap_profile` |
| Persona known, not a friend | `queued_connection_first` | `connection_request(send)` |
| Friend + thread in DB | `queued` (or `queued_deferred`) | `send_message` (thread payload) |
| Friend, no thread in DB | `queued` (or `queued_deferred`) | `send_message` (profile-first payload) |

Either friend row requires an **active LinkedIn session**: without one it returns
`status="blocked"`, `meta.reason="account_session_not_active"` and no task.
Profile-first sends carry `profile_url`/`persona_id` instead of a thread — the worker opens
the conversation itself, so no `linbox_get_inbox` / `linbox_scrap_profile` round-trip is
needed first.

Response (`McpSendMessageResponse`): `status`, `sent`, `message_text`, `blocked_reason`,
`thread`, `persona_id`, `new_messages` (same shape as `messages[]`), `task_id`,
`task_status`, `scheduled_for(_ago)`, `estimated_done_for`, `estimated_done_in`,
`guidance`, `meta.reason`. It does **not** dead-end — reply and target modes both enqueue
a task and return `task_id` + `guidance` to poll.

## Workflow

1. **Find the thread first.** Read before write: get an identifier from
   `linbox_get_inbox`, `linbox_get_thread`, or `linbox_search_thread` before sending.
2. **Search vs list.** Use `linbox_search_thread` (DB only) to filter by name / tag /
   message text / reply status; use `linbox_get_inbox` for the latest threads, and
   `linbox_get_thread(profile_url=...)` to open one person's thread. Loop
   `page.next_cursor` while `page.has_more`.
3. **Load context before sending.** Call `linbox_get_thread` to read `messages[]` and
   confirm the right conversation before `linbox_send_message` — never reply blind.
4. **Send.**
   - Have a thread id → reply mode. Enqueue-only: capture `task_id` and poll for delivery;
     `status="blocked"` + `meta.reason` (inactive session) = reconnect and retry.
   - Only have a person → target mode. Capture the returned `task_id`.
5. **Poll async results.** For any `queued*` / `checking_profile` /
   `queued_connection_first` send, and for deferred read `task`s, poll
   `linbox_get_task(task_id)` after `scheduled_in` until `done`/`failed`; read
   `output.deferred_reason` / `output.error` when it isn't done. A `scrap_thread` that ends
   `done` with `output.status="thread_not_found"` (`output.reason="no_messaging_conversation"`)
   is a terminal "no conversation exists" answer, not a retry signal.
6. **A `scrap_thread` task_id may cover several threads.** Its `task_payload.targets[]` and
   `output.results[]` hold one entry per thread — find yours by `thread_source_id` /
   `partner_profile_url` (per-target `status`: `ok | thread_not_found | error`). Then call
   `linbox_get_thread` again to read that thread's refreshed cache; the task output is a
   status report, not the conversation.

### Freshness and deferral

- Check `meta.data_source` (`live | cache`), `meta.as_of`, `meta.reason` on every read.
- Inbox/thread reads always serve cache (`meta.data_source="cache"`) and, when the session
  is active, enqueue a refresh as a top-level `task` (`task_id`, `task_key`, `task_status`,
  `scheduled_for`, `scheduled_in`, `estimated_done_for`, `estimated_done_in`, `reason`)
  plus a `guidance` string naming the `task_id` to poll and its expected-done ETA. Use
  `task` + `guidance`, not just `meta.reason`.
- A messaging scrape session (`scrap_inbox` or `scrap_thread`) takes **~6–8 minutes**;
  `scrap_thread` runs **~2 sessions/hour, ≤6/day, ≤5 threads per session**, at least 30
  minutes apart, and both tools draw on the same session budget. Poll on that timescale;
  re-calling the read tool does not make the refresh run sooner.

## Rules

- **Search is cache/DB only.** `linbox_search_thread` never triggers a live LinkedIn
  fetch; `meta.data_source` is always `cache`. It also requires at least one filter.
- **Get the thread before sending.** Load `linbox_get_thread` for context so the reply
  fits the conversation and uses the correct `thread_id`.
- **Reply mode is enqueue-only.** It never sends inline: `sent` is always `false`. Capture
  `task_id` and poll `linbox_get_task` for delivery; `status="blocked"` + `meta.reason`
  only on an inactive session. It never serves stale data.
- **Send is never a dead end.** In target mode, a missing thread or persona yields an
  enqueued task (`checking_profile`, `queued_connection_first`, `queued`) with `guidance` —
  including friend-without-thread, which queues a profile-first `send_message`. The only
  `blocked` case is an inactive session (`meta.reason="account_session_not_active"`).
- **Sends share a write budget with invites.** `linbox_send_message` is paced together with
  connection requests (a combined interval plus hourly/daily caps, on top of the per-tool
  limit), so `status="queued_deferred"` with a later `scheduled_for` is expected right after
  any other DM or invite. Poll `linbox_get_task`; never re-send to get an earlier slot.
- **Send statuses:** `queued`, `queued_deferred`, `queued_connection_first`,
  `checking_profile`, `blocked` (inactive-session reply also reports `blocked`). Reply mode
  no longer returns `sent` — delivery is confirmed by polling the task.
- **`author` is a public id**, not a name — resolve via `participants[].linkedin_url`.
- **Paginate with the cursor**, not an offset; keep filters identical across pages.
- A validation error is a plain string `"Error: ..."`, not JSON — handle both shapes.

## Examples

**Search by tag, then read the conversation**

```
linbox_search_thread({"tags": ["lead"], "reply_status": "unread", "limit_threads": 20})
  # take threads[].id; while page.has_more, repeat with cursor=page.next_cursor
linbox_get_thread({"thread_id": "<uuid>"})   # full messages[] for context
```

**Open a person's thread by profile URL (handle cache miss)**

```
linbox_get_thread({"profile_url": "https://www.linkedin.com/in/johndoe/"})
  # thread present -> messages[] as usual
  # thread == null + meta.reason == "thread_not_in_cache":
  #   if task != null, poll linbox_get_task(task.task_id) after task.scheduled_in
  #     (batched task: find your target in output.results[])
  #     -> task_status="done": call linbox_get_thread again to read the cached thread
  #     -> done + output.status="thread_not_found": no conversation, stop
  # thread == null + meta.reason == "no_messaging_conversation": terminal, do not retry
```

**Reply into a known thread**

```
linbox_get_thread({"thread_id": "<uuid>"})   # load context first
linbox_send_message({"thread_id": "<uuid>", "message_text": "Sounds good — Tuesday works."})
  # enqueue-only: sent=false, status="queued" (+ task_id), OR status="blocked" (inactive session)
linbox_get_task("<task_id>")                 # poll to done/failed to confirm delivery
```

**Message a person with no known thread (target mode)**

```
linbox_send_message({"profile_url": "https://www.linkedin.com/in/johndoe/",
                     "message_text": "Hi John, following up..."})
  # status one of: checking_profile | queued_connection_first | queued | blocked
linbox_get_task("<task_id>")                 # poll after scheduled_in to done/failed
```

**Handle a deferred inbox refresh**

```
linbox_get_inbox({"limit_threads": 30})
  # meta.data_source may be "cache"; if task != null, read guidance and:
linbox_get_task(task.task_id)                # poll after task.scheduled_in
```

## Related skills

- [Linbox](../SKILL.md) — root contract: auth, identifiers, DB-first vs no-cache,
  `meta`/`task`/`guidance`, cursor pagination, task polling.
- [Tags](../tags/SKILL.md) — list/upsert tags and attach them; feeds `tags` filters here.
- [Connections](../connections/SKILL.md) — send/accept/withdraw invites; target-mode send
  may enqueue a `connection_request` first.
- [Enrichment](../enrichment/SKILL.md) — `linbox_scrap_profile` / `linbox_scrap_company`
  to resolve a persona or thread before sending.
- [Posts](../posts/SKILL.md) — post search and `linbox_search_persona(post_url=...)` to
  source people to message.
