---
name: linbox-enrichment
description: >-
  Enrich LinkedIn profiles and company pages through the Linbox MCP server using
  linbox_scrap_profile and linbox_scrap_company, and track the queued work with
  linbox_get_task and linbox_get_batch. Use when a request needs profile data
  (connection degree, is_friend, experiences, education, skills, activities,
  comments, reactions, current_company) or company page data, when choosing
  profile scrape sections, when reading a cached profile with its freshness
  window, or when polling scrap task/batch status through Linbox.
contract_version: "3.0"
---

# Linbox Enrichment

Topic skill for enrichment tools on the Linbox LinkedIn MCP server. Neither tool ever runs
Playwright inline, but they differ in what they return:

- `linbox_scrap_profile` is **stale-while-revalidate (SWR)**: it returns the cached profile
  inline from the DB, and only queues a refresh task when the cache is stale or missing.
- `linbox_scrap_company` is **queue-first**: it always enqueues and returns task/batch
  metadata only. Poll `linbox_get_task` / `linbox_get_batch` for the result.

Read the root [Linbox](../SKILL.md) skill first for the shared auth model, identifiers,
`meta` freshness contract, and cursor/pagination rules. This skill covers only enrichment.

## When to use

Use this skill when the request is to:

- read, check, or refresh a person's profile — `is_friend`, `connection_degree`,
  experiences, education, skills, activities, comments, reactions;
- enrich a LinkedIn company page (`companies`);
- pick which profile sections to scrape (full vs topcard-only vs a subset);
- poll the status/result of a queued `scrap_profile` / `scrap_company` task or batch.

For sending messages/replies use the Messaging skill; for invites use the Connections
skill; for tagging use the Tags skill.

## Prerequisites

- A Linbox MCP token scoped to one `account_id` (see root skill for the auth contract).
  Never pass an account id — the token already scopes it.
- An active LinkedIn session is required for the worker to eventually run a scrape, but it
  is **not** required to enqueue or to read cached data — the worker picks the task up when
  the session and limits allow.

## Tools

| Tool | Purpose |
|---|---|
| `linbox_scrap_profile` | Return cached profile inline; queue a refresh only when stale/missing (optional `sections` / `full`) |
| `linbox_scrap_company` | Queue company page scrape for one or more URLs |
| `linbox_get_task` | Read one task status/result by `task_id` |
| `linbox_get_batch` | Read batch status/counts by `batch_id` |

### `linbox_scrap_profile(profile_url=None, persona_id=None, sections=None, full=False, activities_limit=None, activities_since_days=None)`

Reads a profile (`persona_accounts.is_friend`, `connection_degree`, experiences,
activities, etc.) with **stale-while-revalidate** semantics, and enqueues a `scrap_profile`
refresh only when needed. Accepts a single `profile_url` or `persona_id`, or a **list** of
either for batch.

Return shape:

- **single target** → `McpScrapProfileResponse`: `persona_id`, `profile_url`, `profile`
  (cached snapshot or `null`), `current_companies[]`, `task` (`McpEnqueuedTask | null`),
  `guidance`, `meta`. `meta.data_source` is always `cache`; `meta.as_of` /
  `meta.as_of_ago` date the cached snapshot and `meta.reason` says why (see below).
- **2+ targets** → `McpBatchResponse` with per-target SWR in `items[]`. A cache hit has
  `task_status="cache_hit"`, empty `task_id`, and the inline `profile` /
  `current_companies`. `batch_id` is set only when **2+ items actually queued** a refresh.
- **dedup**: a pending (`todo`/`in_progress`) `scrap_profile` for the same `profile_url`
  is reused — `meta.reason="pending_task"`, no duplicate is created.

Read `profile` first and answer from it. Only poll `linbox_get_task(task.task_id)` when
`task` is present **and** you need data newer than `meta.as_of`; you can also just call
`linbox_scrap_profile` again after the task finishes to read the refreshed cache.

After the worker runs, it writes `personas`, `experiences`, and
`activities` / `activity_actions` (see the companies/experiences data docs), plus
`companies` when the `current_company` section is enabled.

#### Freshness windows and `meta.reason`

Freshness comes from the account's limit config, with these defaults:

| Entity | Default window | Applies to |
|---|---|---|
| `persona` | 3 days | the profile snapshot itself |
| `persona_activities` | 1 day | requests enabling `activities` / `comments` / `reactions` / `full` |

| `meta.reason` | `task` | Meaning |
|---|---|---|
| `within_freshness_window` | none | Pure cache hit — nothing queued, no limit spend |
| `profile_stale` | queued | Profile older than the `persona` window |
| `activities_stale` | queued | Activity sections requested but older than the `persona_activities` window |
| `sections_missing` | queued | Cache is fresh but lacks a requested section |
| `stub_refresh` | queued | Cached persona is a connections-list stub, never really scraped |
| `no_cache` | queued | Nothing cached — `profile` is `null` |
| `pending_task` | existing | A `scrap_profile` for this target is already queued |

A limit/work-hours deferral replaces `meta.reason` with the gate reason (e.g. `per_hour`,
`outside_work_hours`) — the refresh is still queued.

#### `sections` and `full` parameters

Controls which profile sections the scrape includes. Allowed section names:
`experiences`, `education`, `skills`, `activities`, `comments`, `reactions`,
`current_company`.

| `sections` / `full` value | Behavior |
|---|---|
| omitted (`None`) or `full=True` | **Full** scrape — all seven sections, including `current_company` |
| `[]` (empty list) | **Topcard only** (same depth as a thread scrape) — no sections |
| `["experiences", "skills"]` | Only the listed sections; any unknown name → `Error: Invalid section ...` |

`full=True` enables every section regardless of `sections`.

`current_company` scrapes the person's current employer company page(s) **inside the same
profile task** — it implies `experiences` (company URLs come from the experience section)
and returns the snapshots in `output.current_companies`. When you need a person plus their
current employer, use `current_company` (or `full=True`) instead of a separate
`linbox_scrap_company` call. `linbox_scrap_company` stays the tool for standalone company
URLs.

#### Activity limits (`activities_limit`, `activities_since_days`)

These apply **only** when at least one activity section is enabled (`activities`,
`comments`, or `reactions`):

- `activities_limit` — 1–200; **default 10** per enabled activity type when omitted.
- `activities_since_days` — 1–365; **no default** (unbounded window when omitted).

Activity sections (posts, comments, reactions) are the most expensive part of a scrape.
Prefer a narrow request: enable only the sections you need, cap `activities_limit`, and
set `activities_since_days` to a recent window. For a cheap existence/`is_friend` check,
pass `sections=[]` (topcard only).

### `linbox_scrap_company(company_url=None)`

Queues a `scrap_company` task that enriches a LinkedIn company page
(`fetch_company_snapshot` → `companies`). Accepts a single URL or a **list**.

- **Always enqueues.** Dedup is only against a pending (`todo`/`in_progress`) task for the
  same `company_id` or URL segment. There is no "current job only" filter, unlike the
  automatic company enqueue that happens from `scrap_profile` experiences.
- **single URL** → `McpTaskResponse`: `task_key="scrap_company"`,
  `task_payload.company_url`, and `task_payload.company_id` when a company stub already
  exists.
- **2+ URLs** → `McpBatchResponse` with a shared `batch_id`.
- Limit gate → `queued` or `queued_deferred`; `meta.reason` is set when deferred.

### `linbox_get_task(task_id)`

Read-only task view from the DB, account-scoped. Fields (`McpTaskResponse`):

- `task_id`, `task_key`, `task_status` (`todo | in_progress | done | failed`);
- `scheduled_for` / `scheduled_for_ago`, `started_at` / `started_at_ago`,
  `finished_at` / `finished_at_ago`;
- related ids: `thread_id`, `thread_source_id`, `persona_id`, `profile_url`, `batch_id`;
- `task_payload` — original JSON from `tasks.task`;
- `output` — JSON from `tasks.output`: the executor result, or `deferred_reason` /
  error fields.

`persona_id` is resolved from the payload/output, or from `profile_url` via
`personas.source_id`.

### `linbox_get_batch(batch_id)`

Read-only batch view: all tasks sharing `batch_id` under the token's account. Fields
(`McpBatchResponse`): `batch_id`, `total`, `todo`, `in_progress`, `done`, `failed`,
`overall_status`, and per-task `items[]` (each like `linbox_get_task`, without
`task_payload`).

`overall_status` aggregation: `in_progress` if any task is running or there is a
`done`+`failed`/`todo` mix; else `todo` if work is still queued; else `failed` (all
failed) or `done` (all done). An empty/unknown batch resolves to `not_found`.

## Workflow

1. **Pick the target(s).** Profile: pass `profile_url` and/or `persona_id` (string for
   one, list for batch). Company: pass `company_url` (string or list).
2. **Scope profile depth.** Choose `sections` and, if activity sections are on, set
   `activities_limit` / `activities_since_days` to keep cost down.
3. **Call** `linbox_scrap_profile` / `linbox_scrap_company`.
4. **Read the response**:
   - profile: use the inline `profile` / `current_companies` and `meta.as_of`. Stop here
     when `task` is `null` (cache hit) or the cached data is good enough.
   - company: capture `task_id` (single) or `batch_id` (2+ URLs).
   - batch profile: per item, `task_status="cache_hit"` means inline data, no polling.
5. **Poll to a terminal state** — only when a `task` / `task_id` was returned and you need
   the fresh scrape:
   - single: `linbox_get_task(task_id)`;
   - batch: `linbox_get_batch(batch_id)` (drill into `items[]` for a stuck member).
   - Wait until after `scheduled_for` / `scheduled_for_ago` before re-polling; a future
     `scheduled_for` means a limit or work-hours gate, not a failure.
6. **Interpret status** (see below), then read `output` for the enriched data or the
   failure reason.

### Interpreting `task_status` and `scheduled_for`

| `task_status` | Meaning | Next step |
|---|---|---|
| `todo` | Queued; not yet started | Poll again after `scheduled_for`; if `scheduled_for` is in the future, a limit/work-hours gate is active — wait |
| `in_progress` | Worker is running it | Poll again shortly |
| `done` | Completed | Read `output` for results; DB (`personas`/`companies`/…) is updated |
| `failed` | Executor failed | Read `output.error` / `output.error_type`; decide whether to retry |

`scheduled_for` is the earliest time the worker may pick the task up. `queued_deferred`
(company) and a future `scheduled_for` both signal the limit gate; `meta.reason` (e.g.
`per_hour`, `per_day`, `outside_work_hours`) explains why.

### Error handling and retries

- **`failed`**: inspect `output.error` and `output.error_type` before retrying. Retry by
  calling `linbox_scrap_profile` / `linbox_scrap_company` again — there is no in-place
  retry, and dedup only reuses **pending** tasks. A failed profile scrape leaves the cache
  stale, so the next `linbox_scrap_profile` call queues a fresh refresh.
- **Deferred (`todo` with future `scheduled_for`, or `queued_deferred`)**: do **not**
  re-enqueue — the task is already queued and will run. Re-enqueuing a profile hits dedup
  and returns the same `pending` task anyway; wait and re-poll.
- **`Error: ...` string** (e.g. invalid `sections`, no target): a validation error is a
  plain string, not JSON — fix the input and call again.
- **Partial batch** (`overall_status=partial_failed`/`in_progress` with some `failed`): read each
  `items[].task_status` and `items[].output`; retry only the failed targets.

## Rules

- `linbox_scrap_profile` returns cached data inline (`data_source="cache"`); a refresh
  `task` appears only when the cache is stale, missing, or lacks a requested section. Never
  tell the user you can't see a profile just because no task was queued.
- Don't re-call `linbox_scrap_profile` in a loop hoping for fresher data — a pure cache hit
  spends no limits but also scrapes nothing until the freshness window (persona 3 days,
  activities 1 day by default) expires.
- `linbox_scrap_company` is queue-first: expect `task_id` / `batch_id`, never inline results.
- `batch_id` appears only when 2+ targets in one call actually queued a refresh; a single
  target never returns one, and an all-cache-hit profile batch has an empty `batch_id`.
- Profile dedup reuses a pending `scrap_profile` for the same `profile_url`
  (`meta.reason="pending_task"`); company dedup reuses a pending task for the same
  `company_id` / URL segment. Only pending tasks are reused — `done`/`failed` tasks do not
  block a new one.
- `sections`: omit (or `full=True`) for all seven sections, `[]` for topcard only, or a
  subset of the allowed names. Unknown names raise `Error: Invalid section ...`.
- `current_company` implies `experiences` and scrapes the current employer page in the same
  profile task — returned as `current_companies` on the scrap_profile response (and
  `output.current_companies` on the task). No separate `linbox_scrap_company` needed.
- `activities_limit` / `activities_since_days` are ignored unless an activity section
  (`activities` / `comments` / `reactions`) is enabled.
- Always poll after `scheduled_for`; treat a future `scheduled_for` as a limit/work-hours
  gate, not an error.
- Never pass an account id — the token scopes the account.

## Examples

**Cheap existence / `is_friend` check (topcard only)**

```
linbox_scrap_profile({"profile_url": "https://www.linkedin.com/in/janedoe/", "sections": []})
  # cache hit  -> profile inline, task=null, meta.reason=within_freshness_window: done
  # stale/miss -> profile (maybe null) + task; poll only if you need fresher data
linbox_get_task("<task_id>")
```

**Targeted profile scrape with a bounded activity window**

```
linbox_scrap_profile({
  "persona_id": "<uuid>",
  "sections": ["experiences", "activities"],
  "activities_limit": 20,
  "activities_since_days": 90
})
  # activities have a shorter window (1 day default) -> meta.reason=activities_stale
  # queues a refresh even when the profile itself is still fresh
linbox_get_task("<task_id>")
```

**Profile plus current employer**

```
linbox_scrap_profile({"profile_url": "https://www.linkedin.com/in/janedoe/",
                      "sections": ["current_company"]})
  # implies experiences; cached employer snapshots come back in current_companies
```

**Batch company scrape, then check aggregate status**

```
linbox_scrap_company({"company_url": ["https://www.linkedin.com/company/acme/",
                                       "https://www.linkedin.com/company/globex/"]})
  # 2+ URLs → batch_id + items[]
linbox_get_batch("<batch_id>")   # overall_status + per-item task_status/output
```

**Retry a failed scrape**

```
linbox_get_task("<task_id>")     # task_status=failed → read output.error / output.error_type
linbox_scrap_profile({"profile_url": "..."})  # cache still stale → queues a fresh task
```

## Related skills

- Root contract: [Linbox](../SKILL.md) — auth, identifiers, `meta`, task polling.
- [Messaging](../messaging/SKILL.md) — inbox, threads, search, send.
- [Connections](../connections/SKILL.md) — send/accept/withdraw invites.
- [Tags](../tags/SKILL.md) — list/create tags, attach/detach on personas, threads & posts.
- [Posts](../posts/SKILL.md) — feed/post scrapes, post search, like & comment.
- End-to-end recipes: [`playbooks/SKILL.md`](../../playbooks/SKILL.md).
