---
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_create_task(type="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.
contract_version: "5.2"
---

# Linbox messaging

Messaging flows for the Linbox LinkedIn MCP server: read the inbox, open a full thread,
search threads in the local DB, and queue a message with
`linbox_create_task(type="send_message", params)`. This skill assumes the global contract
from the root [Linbox](../SKILL.md) skill (auth, identifiers, `meta`, cursor pagination,
create_task/get_task) and only covers the messaging group.

## 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_create_task(type="send_message", params)` | write | Queue a reply, or a first message by target |
| `linbox_get_task(task_id)` | poll | What the send task did; `next` = `linbox_get_thread` |

### `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`, `is_me`), and `meta` / `task` / `guidance`.

- **Use `is_me` for message direction:** `true` = sent by the connected account, `false` =
  sent by the partner. Do not infer direction by comparing `author` to a remembered slug.
- `author` in `messages[]` is a LinkedIn public id, not a display name (owner's id when
  `is_me`, else the partner's). 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 (a connection request, or `type="send_message"` with a
  `profile_url` target); 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_create_task(type="send_message", params)`

`params`: `text` (required), plus **one of** `thread_id`, `linkedin_thread_ref`,
`profile_url`, `persona_id`. Enqueue-only — the message is never sent inline and never falls
back to cache.

**With a thread reference** (`thread_id` / `linkedin_thread_ref`) it queues a `send_message`
task directly: `status="queued"` (or `queued_deferred` under the limit gate) with
`tasks[0].task_id` and `guidance` naming the task to poll. An inactive session gives
`status="blocked"`, `reason="account_session_not_active"`, no task.

**With a person** (`profile_url` / `persona_id`) the server picks the next step in the chain
and queues **that** task — the response describes the task actually queued, not always a
send:

| Situation | `status` | Queued task (`tasks[0].task_key`) |
|---|---|---|
| Persona unknown | `checking_profile` | `scrap_profile` |
| Persona known, not a friend | `queued_connection_first` | `connection_request` (send) |
| Friend + thread in DB | `queued` / `queued_deferred` | `send_message` (thread payload) |
| Friend, no thread in DB | `queued` / `queued_deferred` | `send_message` (profile-first payload) |
| No active session (friend rows) | `blocked` | none |

A profile-first send carries `profile_url`/`persona_id` instead of a thread — the worker
opens the conversation itself, so no inbox or profile round-trip is needed first. When the
chain queued a `scrap_profile` or `connection_request`, that step must finish before the
message exists; poll it, then queue `send_message` again for the next step.

**Response:** `type`, `status`, `tasks[]` (`task_id`, `task_key`, `task_status`,
`scheduled_for(_ago)`, `estimated_done_for`, `estimated_done_in`, `reason`, `thread_id`,
`persona_id`), `guidance`, `meta`. There is **no `sent` flag and no message body** — the
conversation is read with `linbox_get_thread` once the task is `done`. It never dead-ends:
every non-`blocked` outcome carries a `task_id` and `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 queueing a send — never reply blind.
4. **Queue the send** with `linbox_create_task(type="send_message", params)`: a thread
   reference when you have one, otherwise `profile_url` / `persona_id`. Capture
   `tasks[0].task_id`; `status="blocked"` (inactive session) = reconnect and retry.
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`.
- **Sending is enqueue-only.** It never sends inline and returns no message body: capture
  `tasks[0].task_id`, poll `linbox_get_task`, then read the conversation with
  `linbox_get_thread` — the entity is never in the task response.
- **Send is never a dead end.** With a person target, 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 (`reason="account_session_not_active"`).
- **Sends share a write budget with invites.** `type="send_message"` is paced together with
  the connection types (a combined interval plus hourly/daily caps, on top of the per-type
  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, and
  there is no flag to bypass limits.
- **Send statuses:** `queued`, `queued_deferred`, `queued_connection_first`,
  `checking_profile`, `blocked`. A `checking_profile` or `queued_connection_first` response
  means the message was **not** queued yet — that step runs first.
- **Direction = `messages[].is_me`**; `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_create_task({"type": "send_message",
                    "params": {"thread_id": "<uuid>", "text": "Sounds good — Tuesday works."}})
  # enqueue-only: status="queued" (+ tasks[0].task_id) OR "blocked" (inactive session)
linbox_get_task({"task_id": "<task_id>"})    # poll to done/failed
linbox_get_thread({"thread_id": "<uuid>"})   # the sent message lives here
```

**Message a person with no known thread**

```
linbox_create_task({"type": "send_message",
                    "params": {"profile_url": "https://www.linkedin.com/in/johndoe/",
                               "text": "Hi John, following up..."}})
  # status one of: checking_profile | queued_connection_first | queued | blocked
  # the first two queued a profile scrape / invite instead — poll it, then send again
linbox_get_task({"task_id": "<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_id": task.task_id})   # poll after task.scheduled_in
```

## Related skills

- [Linbox](../SKILL.md) — root contract: auth, identifiers, create_task types, cache-first
  reads, `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) — `connect` / `accept_connection` /
  `withdraw_connection`; a person-target send may queue a `connection_request` first.
- [Enrichment](../enrichment/SKILL.md) — `linbox_get_profile` and the `scrap_profile` type
  to resolve a persona before sending.
- [Posts](../posts/SKILL.md) — post search and `linbox_search_persona(post_url=...)` to
  source people to message.
