---
name: linbox-enrichment
description: >-
  Read and enrich LinkedIn profiles and company pages through the Linbox MCP server —
  read cached data with linbox_get_profile and linbox_get_company, queue scrapes with
  linbox_create_task(type="scrap_profile" | "scrap_company"), and track the queued work
  with linbox_get_task. 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 profile or company (cached at any age, or queued on a miss), or when polling
  scrap tasks.
contract_version: "5.2"
---

# Linbox Enrichment

Topic skill for people and company data on the Linbox LinkedIn MCP server. Two halves:

- **Just ask for the data** — `linbox_get_profile`, `linbox_get_company`. Cached data of
  any age comes back as-is; a miss queues a default scrape and returns it on `task`.
- **Ask for newer or deeper data** — `linbox_create_task(type="scrap_profile" |
  "scrap_company", params=...)`, then poll `linbox_get_task` and read the entity again.

Nothing here runs Playwright inline. Read the root [Linbox](../SKILL.md) skill first for
auth, identifiers, the `meta` freshness contract, and the create_task/get_task contract.

## When to use

Use this skill when the request is to:

- read or check a person's profile — `is_friend`, `connection_degree`, experiences,
  education, skills, activities, comments, reactions;
- read or enrich a LinkedIn company page (`companies`);
- decide whether cached data is fresh enough or a scrape is warranted;
- pick which profile sections to scrape (full vs short vs topcard-only vs a subset);
- poll a queued `scrap_profile` / `scrap_company` task.

For sending messages use [Messaging](../messaging/SKILL.md); for invites use
[Connections](../connections/SKILL.md); to find people rather than read one person, use
`linbox_search_persona` — it is a list/filter tool, **not** a substitute for
`linbox_get_profile`.

## Prerequisites

- A Linbox MCP token scoped to one `account_id` (see root skill). Never pass an account id.
- An active LinkedIn session is required for the worker to eventually run a scrape, but it
  is **not** required to read cached data. A get miss without one queues nothing
  (`meta.reason="account_session_not_active"`).

## Tools

| Tool / type | Kind | Purpose |
|---|---|---|
| `linbox_get_profile(profile_url \| persona_id)` | read | One profile of any age + `current_companies`; a miss queues a short scrape |
| `linbox_get_company(company_url)` | read | One company; global, not per account; a miss queues a scrape |
| `linbox_create_task(type="scrap_profile", params)` | queue | Refresh one or more profiles |
| `linbox_create_task(type="scrap_company", params)` | queue | Scrape one or more company pages |
| `linbox_get_task(task_id \| batch_id)` | poll | What the task did; `next` names the read tool |

### `linbox_get_profile(profile_url=None, persona_id=None)`

Pass **exactly one** of `profile_url` or `persona_id`.

Returns `persona_id`, `profile_url`, `profile` (the cached snapshot), `current_companies[]`,
`task`, `guidance`, `meta`. `meta.data_source` is always `cache`; `meta.as_of` /
`meta.as_of_ago` date the snapshot.

- **Cached → any age, nothing queued.** `meta.reason="cached"`, `task=null`, no quota spent.
  Old data is never refreshed automatically — use it and note `meta.as_of_ago`.
- **Miss → a short `scrap_profile` is queued** (150/day budget, never full) and returned on
  `task`; `persona_id` works when that persona has a profile URL. Poll `task.task_id`, then
  call `linbox_get_profile` again. An unfinished scrape for the URL is reused
  (`meta.reason="pending_task"`).
- `profile=null` with `task=null` and `meta.reason="account_session_not_active"` means
  nothing was queued — reconnect the account.

Need a newer or deeper snapshot than the cache? `linbox_create_task(type="scrap_profile")`.

### `linbox_get_company(company_url)`

Returns `company_url`, `name`, `company` (the raw `companies.data` body), `task`,
`guidance`, `meta`. Companies are **global**, shared across accounts. Same contract as
`linbox_get_profile`: a cached page of any age comes back with no task; a miss queues
`scrap_company` (or returns the unfinished one) on `task`. A URL-only stub with no page
data counts as a miss.

### `type="scrap_profile"`

`params`: one of `profile_url` / `persona_id` (string or **list** for batch), plus optional
`sections`, `full`, `short`, `activities_limit`, `activities_since_days`.

- **Fresh cache → `status="cache_hit"`**: nothing queued, no quota spent, `task_id` empty.
  Read the data with `linbox_get_profile`.
- Stale/missing → a `scrap_profile` task per target; 2+ queued targets share a `batch_id`.
- **Dedup:** a pending (`todo`/`in_progress`) `scrap_profile` for the same `profile_url` is
  reused (`status="pending"`, `reason="pending_task"`). A pending **full** also covers a
  later `short` request; a pending `short` does **not** cover a `full` one.
- After the worker runs it writes `personas`, `experiences`, `activities` /
  `activity_actions`, plus `companies` when `current_company` is enabled.

#### Freshness windows and `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` |

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

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

#### `sections`, `full` and `short`

Allowed section names: `experiences`, `education`, `skills`, `activities`, `comments`,
`reactions`, `current_company`.

| Value | Behavior |
|---|---|
| `sections` omitted / `full=true` | **Full** scrape — all seven sections, including `current_company` |
| `short=true` | **Short** scrape — main profile page only: topcard plus up to 3 experiences and the visible education/skills cards |
| `sections: []` | **Topcard only** (same depth as a thread scrape) |
| `sections: ["experiences", "skills"]` | Only the listed sections; an unknown name is an error |

`full=true` enables every section and wins over `short=true`. A cached short profile still
counts as stale for a later `full` (or deeper section) request.

#### Daily budget: `short` and `full` are separate buckets

| Bucket | What counts as this bucket | Per day | Per hour |
|---|---|---|---|
| short | `short=true`, `sections=[]`, or only `experiences` / `education` / `skills` | 150 | 20 |
| full | `full=true`, or any of `activities` / `comments` / `reactions` / `current_company` | 50 | 8 |

The two are metered independently, with a shared minimum interval. Because full is the
scarce budget, use `short` for bulk enrichment and spend `full` only where the deep data
matters. A deferred scrape names its bucket in `output.limit_mode` next to
`output.deferred_reason` — wait for `scheduled_for`, never re-enqueue in a loop.

`current_company` scrapes the person's current employer page(s) **inside the same profile
task** — it implies `experiences` and lands in `output.current_companies` (and on
`linbox_get_profile`'s `current_companies`). Use it instead of a separate `scrap_company`
when you need a person plus their employer; `scrap_company` stays for standalone URLs.

#### Activity limits

These apply **only** when an activity section (`activities`, `comments`, `reactions`) is
enabled:

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

Activity sections are the most expensive part of a scrape: enable only what you need, cap
`activities_limit`, and set a recent `activities_since_days`. For a cheap
existence/`is_friend` check, just call `linbox_get_profile` — a miss queues a short scrape.

### `type="scrap_company"`

`params`: `company_url`, a single URL or a **list**.

- **Always enqueues** — there is no cache-hit short-circuit here; read
  `linbox_get_company` first if cached data may be enough.
- Dedup is only against a pending task for the same `company_id` / URL segment.
- 2+ URLs share a `batch_id`. The limit gate yields `queued` or `queued_deferred`.

### Polling

`linbox_get_task(task_id=...)` returns `task_status` (`todo | in_progress | done | failed`),
`scheduled_for(_ago)`, `started_at`, `finished_at`, `task_payload`, `output`, and `next` —
`linbox_get_profile` for a profile scrape, `linbox_get_company` for a company scrape.
`linbox_get_task(batch_id=...)` gives the batch view: `total`, `todo`, `in_progress`,
`done`, `failed`, `overall_status`, `items[]`.

| `task_status` | Meaning | Next step |
|---|---|---|
| `todo` | Queued | Poll after `scheduled_for`; a future `scheduled_for` is a limit/work-hours gate — wait |
| `in_progress` | Worker is running it | Poll again shortly |
| `done` | Completed | Read the entity with the tool in `next`; `output` is the status report |
| `failed` | Executor failed | Read `output.error` / `output.error_type` before deciding to retry |

## Workflow

1. **Call the get.** `linbox_get_profile` / `linbox_get_company` return cached data of any
   age, or queue a default scrape on a miss — poll its `task`, then call the get again.
2. **Decide if that is enough** from `meta.as_of` and the fields you actually need.
3. **Queue a deeper/newer scrape only if it isn't** — `linbox_create_task(type="scrap_profile" |
   "scrap_company", params=...)`. Scope profile depth with `sections` / `short` / `full`
   and cap activity limits.
4. **Handle the enqueue status.** `cache_hit` → already fresh, stop. `pending` → an
   existing task covers it. `queued_deferred` → a gate, wait. `blocked` → inactive session.
5. **Poll to terminal** with `linbox_get_task` after `scheduled_for`.
6. **Read the entity again** with the tool named in `next` — the task `output` is a status
   report, not the profile or company body.

## Rules

- **Entity via get, task via get_task.** `linbox_create_task` and `linbox_get_task` never
  carry a profile or company body — `linbox_get_profile` / `linbox_get_company` do.
- `profile=null` / `company=null` means "not in cache", not an error. If `task` is set, poll
  it and read again; don't re-read in a loop.
- A cached `linbox_get_profile` / `linbox_get_company` enqueues nothing and spends no quota,
  however old the data — never tell the user you can't see a profile just because it is old.
- Freshness windows below apply only to `linbox_create_task(type="scrap_profile")`, not
  to the gets.
- `short` and `full` draw on **separate** daily budgets (150 / 50 by default) with a shared
  minimum interval — prefer `short` for volume, reserve `full` for depth.
- `sections`: omit (or `full=true`) for all seven sections, `short=true` for the first page
  only, `[]` for topcard only, or a subset of the allowed names. `full` wins over `short`.
- `current_company` implies `experiences` and scrapes the employer page in the same task —
  no separate `scrap_company` needed.
- `activities_limit` / `activities_since_days` are ignored unless an activity section is
  enabled.
- Dedup reuses only **pending** tasks (`status="pending"`); `done`/`failed` ones do not
  block a new task. A pending `short` does not cover a `full` request.
- `batch_id` appears only when 2+ targets actually queued; a single target returns just
  `task_id`. An all-cache-hit profile batch returns no `batch_id`.
- Always poll after `scheduled_for`; a future `scheduled_for` is a limit/work-hours gate,
  not an error, and there is no flag to bypass it.
- An extra or misspelled key in `params` is an error — no key is silently ignored.
- A validation error is a plain string `"Error: ..."`, not JSON.

## Examples

**Just ask for a profile**

```
linbox_get_profile({"profile_url": "https://www.linkedin.com/in/janedoe/"})
  # profile present -> answer from it; note meta.as_of_ago (nothing was queued)
  # profile == null, task set -> short scrape queued (or pending_task reused)
linbox_get_task({"task_id": "<task.task_id>"})   # poll to done
linbox_get_profile({"profile_url": "https://www.linkedin.com/in/janedoe/"})
```

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

```
linbox_create_task({"type": "scrap_profile",
                    "params": {"persona_id": "<uuid>",
                               "sections": ["experiences", "activities"],
                               "activities_limit": 20,
                               "activities_since_days": 90}})
  # activities have the shorter window -> reason=activities_stale, queued even when the
  # profile itself is fresh; status=cache_hit would mean nothing was needed
linbox_get_task({"task_id": "<task_id>"})
linbox_get_profile({"persona_id": "<uuid>"})
```

**Batch company scrape, then read one company**

```
linbox_create_task({"type": "scrap_company",
                    "params": {"company_url": ["https://www.linkedin.com/company/acme/",
                                                "https://www.linkedin.com/company/globex/"]}})
  # 2 URLs -> batch_id + tasks[]
linbox_get_task({"batch_id": "<batch_id>"})   # overall_status + per-item task_status
linbox_get_company({"company_url": "https://www.linkedin.com/company/acme/"})
```

**Profile plus current employer in one task**

```
linbox_create_task({"type": "scrap_profile",
                    "params": {"profile_url": "https://www.linkedin.com/in/janedoe/",
                               "sections": ["current_company"]}})
  # implies experiences; employer snapshots land on linbox_get_profile.current_companies
```

## Related skills

- Root contract: [Linbox](../SKILL.md) — auth, identifiers, create_task types, `meta`,
  task polling.
- [Messaging](../messaging/SKILL.md) — inbox, threads, search, `send_message`.
- [Connections](../connections/SKILL.md) — `connect` / `accept_connection` /
  `withdraw_connection`.
- [Tags](../tags/SKILL.md) — list/create tags, attach/detach on personas, threads & posts.
- [Posts](../posts/SKILL.md) — `linbox_get_post`, post search, feed/post scrapes, engagement.
- End-to-end recipes: [`playbooks/SKILL.md`](../../playbooks/SKILL.md).
