---
name: linbox
description: >-
  Operate the Linbox LinkedIn MCP server — check what's new, read inbox/threads, search
  threads, personas and posts, manage tags, send messages, send/accept/withdraw connection
  requests, enrich profiles/companies, scrape the feed, like/comment on posts, run
  multi-step outreach campaigns, and poll tasks/batches. Use whenever a request involves
  LinkedIn messaging, inbox, DMs, connections/invites, LinkedIn tags, posts/feed,
  outreach campaigns/sequences, or profile/company scraping through Linbox, or when
  calling any linbox_* MCP tool. This root skill holds the global
  contract (auth, identifiers, DB-first vs no-cache, meta/task/guidance, cursor
  pagination, task polling) and routes to topic skills.
contract_version: "3.0"
---

# Linbox

Thin router and global contract for the Linbox LinkedIn MCP server (`/mcp`, FastMCP).
All `linbox_*` tools share one auth model, one identifier vocabulary, and one response
contract. Read this first, then jump to the topic skill for the task.

## When to use

Use this skill when a request touches LinkedIn through Linbox, including:

- asking what's new / what needs attention across messages, feed posts, connections,
  campaign replies, and campaign leads waiting on the agent
- reading the inbox or a specific conversation/thread
- searching threads or people (personas), including 1st-degree connections
- listing, creating, or attaching/removing tags
- sending a message or reply
- sending, accepting, or withdrawing a connection request/invite
- enriching a profile or company page
- searching cached posts, scraping the feed or a post, liking or commenting on a post
- setting up or running a multi-step outreach campaign / sequence, enrolling personas
- polling the status of a queued task or batch

If the task is narrow, go straight to the matching topic skill under
[Related skills](#related-skills). Always apply the global rules below regardless of tool.

## Prerequisites

- A Linbox MCP token scoped to exactly one LinkedIn `account_id`. The token binds the
  account, so no separate account parameter is ever passed.
- The MCP client is connected to `<api-host>/mcp` with auth configured (see below).

### Auth contract

Authenticate with **one** of:

- **Bearer header (preferred):** `Authorization: Bearer <mcp_token>`
- **Query param fallback (clients without custom headers):** `<api-host>/mcp?token=<mcp_token>`

If both are present, the header wins. Prefer the header — `?token=` can leak into access
logs, browser history, and referrers.

### Response format

Optional header `X-Linbox-Response-Format: markdown | json` (default `markdown`). Set
`json` for programmatic parsing; every field name below matches the JSON shape.

## Tools

| Tool | Group | Purpose |
|---|---|---|
| `linbox_whats_new` | core | Cache-only digest: unread messages, new feed posts, new connections, campaign replies |
| `linbox_get_inbox` | messaging | List cached threads; enqueues a background refresh |
| `linbox_get_thread` | messaging | Cached thread + messages; marks read; enqueues a refresh |
| `linbox_search_thread` | messaging | Search threads in DB (cursor paginated) |
| `linbox_search_persona` | messaging | Search personas / list friends; `campaign_id` scopes to a campaign's enrollees, `post_url` or `activity_id` lists a post's actors with `roles` (cursor paginated) |
| `linbox_send_message` | messaging | Queue a reply, or a follow-up by target (poll task) |
| `linbox_list_tags` | tags | List account tags with description/color/count |
| `linbox_upsert_tag` | tags | Create tag or update metadata (no attachment) |
| `linbox_add_persona_tags` / `linbox_remove_persona_tags` | tags | Attach/detach tags on a persona |
| `linbox_add_thread_tags` / `linbox_remove_thread_tags` | tags | Attach/detach tags on a thread |
| `linbox_add_post_tags` / `linbox_remove_post_tags` | tags | Attach/detach tags on a cached post |
| `linbox_send_connection_request` | connections | Queue invite (`send`), single or batch |
| `linbox_accept_connection_request` | connections | Queue accept, single or batch |
| `linbox_withdraw_connection_request` | connections | Queue withdraw, single or batch |
| `linbox_scrap_profile` | enrichment | Cached profile inline; queues a refresh only when stale (optional `sections`) |
| `linbox_scrap_company` | enrichment | Queue company page scrape |
| `linbox_search_post` | posts | Search cached posts (cursor paginated) |
| `linbox_scrap_feed` | posts | Queue a home-feed scrape |
| `linbox_scrap_post` | posts | Queue one post scrape (optional `sections`: comments, reactions) |
| `linbox_like_post` / `linbox_comment_on_post` | posts | Queue a like / comment on a post |
| `linbox_list_campaigns` | campaigns | List campaigns (`status`, `steps_count`, invite cap) |
| `linbox_upsert_campaign` | campaigns | Create/update a campaign and its `steps` (`message`, `scrap_profile`, `like_recent`, `wait_agent`) |
| `linbox_set_campaign_status` | campaigns | `draft` / `active` / `paused` / `done` |
| `linbox_enroll_campaign` | campaigns | Enroll personas by `profile_urls` / `persona_ids` |
| `linbox_list_campaign_personas` | campaigns | Inspect enrollment state; `waiting_agent` / `step_ids` filters (cursor paginated) |
| `linbox_update_campaign_persona` | campaigns | Only per-enrollment write: merge `texts` / `vars`, complete a `wait_agent` step, stop/skip/reactivate |
| `linbox_get_task` | core | Read one task status/result by `task_id` |
| `linbox_get_batch` | core | Read batch status by `batch_id` |

### Identifiers

| Concept | Identifiers | Notes |
|---|---|---|
| Thread | `thread_id`, `linkedin_thread_ref`, `profile_url` | `thread_id` = Linbox UUID (`threads.id`); `linkedin_thread_ref` = thread URL or `thread_source_id`. If both are passed, `thread_id` wins and the ref is validated. `linbox_get_thread` also accepts `profile_url` to open a person's thread (cache miss → `thread=null` + `meta.reason="thread_not_in_cache"`, not an error). Responses also expose `thread_source_id`. |
| Person | `persona_id`, `profile_url`, `linkedin_url` | `persona_id` = Linbox UUID; `profile_url`/`linkedin_url` = LinkedIn URL or `public_id`. Responses also expose `partner_public_id`. |
| Post | `post_url`, `activity_id` | `post_url` = `linkedin.com/posts/...` or `linkedin.com/feed/update/urn:li:{activity\|ugcPost\|share}:{id}` (normalized before dedup); `activity_id` = Linbox UUID (`activities.id`) from `linbox_search_post`. Post tools require the post to be cached for the account. |
| Async work | `task_id`, `batch_id` | `task_id` = one queued task (`tasks.id`); `batch_id` = shared id for a 2+ target call. |

## Workflow

1. **Start open-ended requests with `linbox_whats_new`** ("what's new", "anything to do",
   start of a work session) to see where the work is before picking a group.
2. **Classify the request** into a group: messaging, tags, connections, enrichment,
   posts, or campaigns.
3. **Open the topic skill** for that group ([Related skills](#related-skills)) for
   tool-specific arguments, edge cases, and decision flows.
4. **Read before write.** For messaging, call `linbox_get_inbox` /
   `linbox_get_thread` (or `linbox_search_*`) to get the right identifier before
   `linbox_send_message`.
5. **Check freshness** on every read via `meta` (see below); don't assume data is live.
6. **Compare skill contract version** before relying on this file (see Skill version sync).
7. **After any async call, capture** `task_id` (and `batch_id` if batched) and poll
   until terminal (see Task polling).

### Skill version sync

Every MCP response includes `meta.contract_version` (`MAJOR.MINOR`). Compare it to the
`contract_version` in this skill's frontmatter (and any installed topic/playbook skills):

- **Remote MAJOR is higher** — stop and re-download skills from the hub (`/skills` or
  `linbox-skills.zip`) before continuing; behavior or tool contracts may have changed.
- **Only remote MINOR is higher** — re-download is recommended; your local copy may miss
  new guidance but should still work.
- **Local matches or is newer on MAJOR** — proceed with the installed skills.

### DB-first reads vs no-cache writes

- **Reads** (`linbox_get_inbox`, `linbox_get_thread`, `linbox_search_thread`,
  `linbox_search_persona`, `linbox_search_post`, `linbox_list_tags`, `linbox_scrap_profile`)
  always answer from the Linbox DB — `meta.data_source` is `cache` on these paths. LinkedIn
  is never scraped inline: `linbox_get_inbox` / `linbox_get_thread` instead **enqueue a
  background refresh** task (subject to limits and an active session) and return it as a
  pollable `task`.
- `linbox_scrap_profile` is **stale-while-revalidate**: it returns the cached `profile`
  inline and only attaches a refresh `task` when the cache is stale or missing
  (`meta.reason` says which). A pure cache hit queues nothing and spends no limits. See the
  [Enrichment](enrichment/SKILL.md) skill for the freshness windows and reason values.
- **Writes** never fall back to cache and never run Playwright inline. `linbox_send_message`
  (reply mode) is **enqueue-only**: it returns `sent=false` + a `task_id` to poll, or
  `status="blocked"` + `meta.reason` when the session is inactive. Connection tools are
  likewise queue-only and always return a `task_id`.

### What's new digest

`linbox_whats_new(ack=False, limit_per_section=10)` is the session entrypoint. It is
**cache-only** and enqueues nothing — it never scrapes and never queues a refresh.

Returns `summary` (one line), `guidance`, and five sections — `messages`, `posts`
(feed posts within a 7-day `horizon_days`), `connections`, `campaigns` (replies that
stopped a campaign enrollment), `campaign_waiting` (leads parked by a `wait_agent`
campaign step) — each with `count`, `capped`, a preview `items[]` (`limit_per_section`
clamp 1–10), and `next` naming the tool to widen with. Use `next`, not the preview,
when you need the full set.

- **`ack=true` clears all matching posts, connections, and campaign replies**, not just
  the previewed items.
- **Messages are never acked here.** An unread thread clears only when you open it with
  `linbox_get_thread`.
- **`campaign_waiting` is live and never acked.** Its items are aggregates
  (`campaign_id`, `campaign_name`, `count`); the count drops only once you resolve the
  enrollments via `linbox_update_campaign_persona` (see
  [Campaigns](campaigns/SKILL.md)).
- `campaigns` items carry `campaign_persona_id`, `campaign_id`, `campaign_name`,
  `persona_id`, `profile_url`, `replied_at` — open the conversation before acking.
- To pull fresh data, call the refresh tools yourself: `linbox_get_inbox`,
  `linbox_scrap_feed`, `linbox_search_persona(is_friend=true)`.

The same one-line summary appears as `meta.whats_new_hint` on poll and read tools
(`linbox_get_task`, `linbox_get_batch`, `linbox_get_inbox`, `linbox_get_thread`,
`linbox_search_thread`, `linbox_search_post`, `linbox_search_persona`). It is omitted /
`null` when all counts are zero — treat it as a nudge to call `linbox_whats_new`,
not as data to act on directly.

### meta / task / guidance

Every response carries `meta`:

- `data_source`: `live | cache`
- `contract_version`: `MAJOR.MINOR` of the live MCP contract (see Skill version sync)
- `fetched_at`, `fetched_ago`, `as_of`, `as_of_ago`
- `reason`: why live was skipped, e.g. `per_hour`, `per_day`, `outside_work_hours`,
  `account_session_not_active`, `browser_service_unavailable`
- `whats_new_hint`: optional one-line digest summary (see What's new digest)

`linbox_get_inbox`, `linbox_get_thread`, and `linbox_scrap_profile` may also return:

- `task` (`McpEnqueuedTask | null`): `task_id`, `task_key`, `task_status`,
  `scheduled_for`, `scheduled_in`, `estimated_done_for`, `estimated_done_in`, `reason` —
  present when a refresh was enqueued.
- `guidance`: one human-readable string describing the next step; when `task` is set it
  names the `task_id` to poll and states the expected-done ETA.

Rule: don't tell the user "can't"; read `guidance` and `task` for the concrete next step.

### Cursor pagination

`linbox_search_thread`, `linbox_search_persona`, and `linbox_search_post` use an opaque
**keyset cursor** (not offset):

- **Input:** `cursor` — omit on the first call; pass the previous `page.next_cursor` after.
  An invalid token returns `Error: ...`.
- **Output block `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_list_campaign_personas` paginates the same way but returns `has_more`,
`next_cursor`, and `returned` at the top level instead of inside a `page` block.

### Task polling

Async tools return `task_id` (single) or `batch_id` (2+ targets in one call).

- `linbox_get_task(task_id)` → `task_status` (`todo | in_progress | done | failed`),
  `scheduled_for`/`scheduled_for_ago`, `started_at`, `finished_at`, related ids
  (`thread_id`, `persona_id`, `profile_url`, `batch_id`), `task_payload`, `output`.
- `linbox_get_batch(batch_id)` → counts (`total`, `todo`, `in_progress`, `done`,
  `failed`), `overall_status`, and per-task `items[]`.
- `overall_status`: `in_progress` (any running or a done/failed mix) → `todo` (queued) →
  `failed` (all failed) → `done` (all done); empty batch → `not_found`.
- Poll after `scheduled_in` / `scheduled_for`; on `failed` read `output.error` /
  `output.error_type` / `output.reason`; a future `scheduled_for` means a limit or
  work-hours gate.
- `output.reason="linkedin_session_logged_out"` means the LinkedIn session hit a
  login/checkpoint/authwall page and the account was deactivated — the user must
  reconnect before any further work.

## Rules

- Before invoking tools, compare local `contract_version` (frontmatter) with
  `meta.contract_version`; re-download when remote MAJOR is higher (required) or only
  MINOR is higher (recommended).
- Prefer the Bearer header over `?token=`.
- Never pass an account id; the token already scopes the account.
- A validation error is a plain string `"Error: ..."`, not JSON — handle both shapes.
- HTTP `403 Account is inactive` means the account is deactivated (e.g. its LinkedIn
  session was lost) and **every** tool will fail until the user reconnects it via the
  Linbox extension/dashboard. Don't retry or route around it — report that reconnect is
  needed; check a recent failed task's `output.reason` to confirm a logout episode.
- Treat cache as valid data; surface `meta.as_of` / `meta.reason` when freshness matters.
- **The account working window is work days + work hours** (default Mon–Fri in the
  account timezone). Every queued LinkedIn task is gated by it: work enqueued outside the
  window defers with `outside_work_hours` and a `scheduled_for` on the next work day —
  expected, not a failure. Never promise a weekend send unless the account's work days
  include `sat`/`sun`.
- **Messages and invites share one write budget.** `linbox_send_message` and the
  `linbox_*_connection_request` tools are paced together (a combined minimum interval with
  jitter, plus hourly/daily caps) on top of each tool's own limits, so a DM and an invite
  cannot go out back-to-back — the second one is deferred to a later slot.
  `status="queued_deferred"` with a distant `scheduled_for` is the normal outcome here, not
  a failure: poll `linbox_get_task` / `linbox_get_batch`, then re-read. Never retry the
  write in a loop — a repeat call only adds another queued task. Likes, comments, and
  thread-read marks are outside this bucket.
- Only pass `ack=true` to `linbox_whats_new` after actually reviewing
  posts/connections/campaign replies — it clears every matching item, not just the
  preview, and cannot be undone.
- Campaign tools are config-only: they write campaigns/enrollments in the DB and return
  no `task_id`. Sending happens later via a server-side tick under the limit gate, and
  only while the campaign is `active`.
- Batch semantics: `batch_id` appears only for 2+ targets in one call; a single target
  returns just `task_id`.
- Connection tools return `already_connected` (no task) when the persona is already a
  1st-degree connection, and reuse the existing `pending` task for a duplicate target+action.
- Post queue tools (`linbox_scrap_feed`, `linbox_scrap_post`, `linbox_like_post`,
  `linbox_comment_on_post`) refuse to enqueue without an active session
  (`task_status="blocked"`), and report the enqueue outcome in `output.status`
  (`queued | queued_deferred | pending`).
- Keep this root concise; consult the topic skill before invoking a group's tools.

## Examples

**Open a work session**

```
linbox_whats_new()                         # messages/posts/connections/campaigns counts
  # drill into a section with its `next`, e.g. linbox_search_thread(reply_status="unread")
linbox_whats_new({"ack": true})            # only after reviewing non-message sections
```

**Read a conversation by tag**

```
linbox_list_tags()                         # pick a real tag name
linbox_search_thread({"tags": ["lead"], "reply_status": "unread"})
  # take threads[].id; if page.has_more, repeat with cursor=page.next_cursor
linbox_get_thread({"thread_id": "<uuid>"}) # full messages[]
```

**Send, then confirm**

```
linbox_send_message({"thread_id": "<uuid>", "message_text": "..."})
  # reply mode: enqueue-only -> sent=false + task_id (or blocked + meta.reason)
# target mode also returns a task_id; then:
linbox_get_task("<task_id>")               # poll to done/failed for delivery
```

## Related skills

Topic skills (one level deep, same tree):

- [Messaging](messaging/SKILL.md) — inbox, threads, search, `linbox_send_message`.
- [Tags](tags/SKILL.md) — `linbox_list_tags`, `linbox_upsert_tag`, add/remove persona,
  thread & post tags, tag-based search (OR semantics, persona→thread inheritance).
- [Connections](connections/SKILL.md) — send/accept/withdraw invites, single & batch,
  `already_connected`, pending reuse.
- [Enrichment](enrichment/SKILL.md) — `linbox_scrap_profile` (cached profile + freshness
  windows, `sections`) and `linbox_scrap_company`.
- [Posts](posts/SKILL.md) — `linbox_search_post`, feed/post scrapes, like & comment,
  post tags, and `linbox_search_persona(post_url=...)` for a post's actors.
- [Campaigns](campaigns/SKILL.md) — multi-step outreach sequences: campaign steps,
  enrollment, per-persona texts, `wait_agent` handoffs, and how replies stop an
  enrollment.

For end-to-end recipes, see the playbooks index at [`playbooks/SKILL.md`](../playbooks/SKILL.md).
