---
name: linbox-connections
description: >-
  Queues LinkedIn connection invites through the Linbox MCP server with
  linbox_create_task — types connect, accept_connection, and withdraw_connection for
  single or batch targets — and finds 1st-degree connections via linbox_search_persona
  (is_friend=true). Use when a request involves LinkedIn connections, invites, connection
  requests, accepting or withdrawing invites, or checking who is already connected.
contract_version: "5.2"
---

# Linbox Connections

Queue-only connection actions on the Linbox LinkedIn MCP server. All three go through the
single enqueue tool:

- `linbox_create_task(type="connect", params)` — send an invite
- `linbox_create_task(type="accept_connection", params)` — accept an incoming invite
- `linbox_create_task(type="withdraw_connection", params)` — withdraw a pending invite

They never run Playwright inline: each enqueues a `connection_request` task and returns a
`task_id` per target (plus `batch_id` for 2+). Poll with `linbox_get_task`, then read the
person with `linbox_search_persona`.

Read the root [Linbox skill](../SKILL.md) first for the shared auth, identifier, and
create_task/get_task contract. This skill covers only the connection group.

## When to use

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

- sending a connection invite
- accepting an incoming invite
- withdrawing a pending outgoing invite
- checking who is already a 1st-degree connection before inviting
  (`linbox_search_persona` with `is_friend=true`)
- polling the outcome of a queued connection task or batch

To check for new 1st-degree connections you haven't reviewed yet, call `linbox_whats_new`
first; `ack=true` marks every matching connection seen, not just the previewed ones.

## Prerequisites

- A Linbox MCP token scoped to one LinkedIn `account_id` (see root skill). Never pass an
  account id; the token scopes it.
- An **active LinkedIn session** for the account. Without it every connection type returns
  `status="blocked"`, `reason="account_session_not_active"` and enqueues nothing.

## Tools

| Tool / type | Action | Targets |
|---|---|---|
| `linbox_create_task(type="connect", params)` | `send` | single or batch |
| `linbox_create_task(type="accept_connection", params)` | `accept` | single or batch |
| `linbox_create_task(type="withdraw_connection", params)` | `withdraw` | single or batch |
| `linbox_search_persona(name?, linkedin_url?, tags?, is_friend?, campaign_id?, cursor?, limit_personas=30)` | check friends | cursor paginated |
| `linbox_get_task(task_id \| batch_id)` | poll one task or a batch | — |

### `params`: targeting

All three types take the same targets — `profile_url` and/or `persona_id`, each either a
single string or a **list**:

- **`profile_url`** — a LinkedIn URL or `public_id`. Use when you only have the link.
- **`persona_id`** — the Linbox UUID (`personas.id`), e.g. from `linbox_search_persona` or
  thread/inbox reads. Prefer it when available.
- At least one target is required, or the call errors
  (`Error: type=connect requires one of persona_id, profile_url`).
- **Batch** = 2+ resolved targets in one call (lists, or mixing `profile_url` and
  `persona_id`). Duplicate targets resolving to the same profile are collapsed.
- `text` is the **invite note** and belongs to `connect` only. `accept_connection` and
  `withdraw_connection` accept no `text` — passing it is an error, not an ignored key.

## Workflow

1. **Check first (recommended for `connect`).** Call `linbox_search_persona` with
   `is_friend=true` (optionally `name` / `linkedin_url` / `tags`) to see whether the target
   is already a 1st-degree connection. This lists connections imported by `sync_connections`
   from the DB — no live LinkedIn fetch. Paginate with `cursor` while `page.has_more=true`.
2. **Queue the action** with `linbox_create_task`. Pass a list for a batch.
3. **Read `status` and each `tasks[]` item** (see below). `already_connected` means no task
   was created; `pending` reuses an existing task; `partial` means the batch mixed outcomes,
   so check every item.
4. **Capture ids.** Single target → `tasks[0].task_id`. Batch (2+) → `batch_id` plus one
   `task_id` per item.
5. **Poll to terminal** — `linbox_get_task(task_id=...)` for one target,
   `linbox_get_task(batch_id=...)` for the batch. Poll after `scheduled_for`.
6. **Read the person** with `linbox_search_persona` (the task's `next`) — the connection
   state lives there, not in the task response.

### Status outcomes

Top-level `status`, and `task_status` on each `tasks[]` item:

| Status | Meaning | Task created? |
|---|---|---|
| `queued` | Enqueued; worker may run now | yes |
| `queued_deferred` | Enqueued with a future `scheduled_for` (limit / work-hours gate, incl. the write pace shared with `send_message`) | yes |
| `pending` | A `todo`/`in_progress` `connection_request` already exists for the same target + action; its `task_id` is returned | reused |
| `already_connected` | `persona_accounts.is_friend=true`; nothing to do | no |
| `blocked` | No active session (`reason="account_session_not_active"`) | no |
| `partial` | Batch only: targets ended in different states — read each item | mixed |

### Single vs batch

- **Single target** → `tasks[]` with one item (`task_id`, `task_key="connection_request"`,
  `task_status`, `scheduled_for(_ago)`, `reason`, `profile_url`, `persona_id`). No `batch_id`.
- **2+ targets** → the same `tasks[]` with one item per target, plus a shared `batch_id`.
  `linbox_get_task(batch_id=...)` aggregates them: `total`, `todo`, `in_progress`, `done`,
  `failed`, `overall_status`, `items[]`.
- `overall_status`: `in_progress` (any running, or a done+failed mix) → `todo` (still
  queued) → `failed` (all failed) → `done` (all done); an empty/unknown batch → `not_found`.
- On a failed item, read its `output.error` / `output.error_type`.

### Invite outcome (`connect`, once the task is `done`)

A `done` task is not proof an invite went out — read `output`:

| `output.status` | Meaning |
|---|---|
| `sent` | Connect was clicked and **Pending is visible** on the profile (`verified_pending=true`) |
| `already_pending` | Pending was already visible before clicking — an earlier invite is live (`verified_pending=true`). Not a reliable duplicate detector: on profiles that hide Pending in the overflow menu, a repeat send returns `sent` instead |
| `already_connected` | Profile shows a 1st-degree badge |
| `not_sent` | Nothing was sent — read `output.blocked_reason` (below) |

`not_sent` reasons, grouped by what to do next:

- **LinkedIn refused — back off for this account.** `invite_weekly_limit`: the account hit
  LinkedIn's *own* weekly invitation limit. Retrying sooner fails the same way; stop sending
  invites for this account until LinkedIn's weekly window resets.
- **Terminal for this profile — do not retry.** `invitations_not_accepted` (the member is not
  accepting invitations), `email_verification_required` (LinkedIn demands the target's email
  first), `connect_button_not_found` (no Connect, no overflow entry, no invite URL).
- **Retry only after fixing the cause.** `note_too_long` (shorten the note),
  `connect_click_failed`, `invite_modal_failed`, `pending_not_verified`.

## Rules

- Connections are **queue-only**: `linbox_create_task` enqueues (or reuses/skips) and never
  scrapes inline or falls back to cache.
- **Entity via search, task via get_task.** Whether someone is now connected is read with
  `linbox_search_persona` (or `linbox_get_profile`), never from the task response.
- An active session is always required; otherwise `blocked` +
  `account_session_not_active`, no task.
- **Invites share a write budget with DMs.** Invites and `type="send_message"` are paced
  together (a combined interval plus hourly/daily caps, on top of the per-type limit), so an
  invite queued right after a DM comes back `queued_deferred` with a later `scheduled_for`.
  That is normal — poll `linbox_get_task`; never re-queue to get an earlier slot, and there
  is no flag to bypass limits.
- **Withdraws have their own budget** (`connection_withdraw`), separate from the invite / DM
  daily caps, so a withdraw can be `queued_deferred` on its own pacing. Expected — poll,
  don't re-queue.
- **Campaigns withdraw their own stale invites.** A campaign auto-withdraws any invite still
  pending after its `withdraw_after_days` (default 30) and marks the enrollment
  `invite_withdrawn`. Do not withdraw an invite a campaign is waiting on unless you
  deliberately want to withdraw early — see [Campaigns](../campaigns/SKILL.md).
- `already_connected` short-circuits `connect` — no task is created for existing 1st-degree
  connections. A `linbox_search_persona(is_friend=true)` check first avoids the round trip.
- Duplicate target + action returns the existing `pending` task; reuse that `task_id` rather
  than expecting a new one.
- A `done` `connect` task is not a sent invite. Only `output.verified_pending=true` proves a
  live Pending invite; treat `not_sent` as "nothing went out" and retry only after fixing the
  cause.
- `blocked_reason=invite_weekly_limit` is **LinkedIn's** restriction, not a Linbox deferral —
  Linbox will keep queueing invites, so it is on the agent to stop for this account.
- `batch_id` appears only for 2+ targets; a single target returns just `task_id`.
- `text` is only valid on `connect`; an extra key in `params` is an error.
- A validation error is a plain string `"Error: ..."`, not JSON.

## Examples

**Check, then invite one person**

```
linbox_search_persona({"linkedin_url": "https://www.linkedin.com/in/johndoe/", "is_friend": true})
  # if personas[] returns the target → already connected, skip the invite
linbox_create_task({"type": "connect",
                    "params": {"profile_url": "https://www.linkedin.com/in/johndoe/",
                               "text": "Great to connect!"}})
  # status=queued|queued_deferred → capture tasks[0].task_id
linbox_get_task({"task_id": "<task_id>"})
  # done -> check output.status + output.verified_pending
```

**Batch invite by persona_id**

```
linbox_create_task({"type": "connect",
                    "params": {"persona_id": ["<uuid-a>", "<uuid-b>", "<uuid-c>"]}})
  # 3 targets → batch_id + tasks[]; status may be "partial" → read each item
linbox_get_task({"batch_id": "<batch_id>"})
  # overall_status: in_progress → todo → failed → done
```

**Accept incoming, withdraw a stale invite**

```
linbox_create_task({"type": "accept_connection",
                    "params": {"profile_url": "https://www.linkedin.com/in/janedoe/"}})
linbox_create_task({"type": "withdraw_connection", "params": {"persona_id": "<uuid>"}})
  # each single target → tasks[0].task_id; poll with linbox_get_task
```

**List 1st-degree connections (paginated)**

```
linbox_search_persona({"is_friend": true, "limit_personas": 50})
  # while page.has_more: repeat with cursor=page.next_cursor
```

## Related skills

- [Linbox root](../SKILL.md) — auth, identifiers, create_task types, meta/task/guidance,
  cursor pagination, task polling (global contract).
- [Messaging](../messaging/SKILL.md) — inbox, threads, `type="send_message"` (which can queue
  a connection first via `queued_connection_first`).
- [Campaigns](../campaigns/SKILL.md) — how the invite step and the automatic stale-invite
  withdraw behave inside a campaign.
- [Enrichment](../enrichment/SKILL.md) — `linbox_get_profile` to check `is_friend` /
  `connection_degree` before inviting.
- [Tags](../tags/SKILL.md) — tag personas to organize connection targets.
- [Posts](../posts/SKILL.md) — find invite targets among the actors of a post.
