---
name: linbox-posts
description: >-
  Work with LinkedIn posts through the Linbox MCP server — read one cached post with
  linbox_get_post, search cached posts with linbox_search_post, queue a home-feed scrape,
  a live LinkedIn post search, a single-post scrape, and engagement via linbox_create_task
  (types scrap_feed, search, scrap_post, like_post, comment_on_post), tag posts with
  linbox_add_post_tags / linbox_remove_post_tags, and list a post's actors with their roles
  and comment text via linbox_search_persona(post_url=... | activity_id=...). Use when a
  request involves the LinkedIn feed, a post URL, finding LinkedIn posts by keyword, post
  content search, liking or commenting on a post, post tags, or finding who authored,
  liked, or commented on a post and what they wrote.
contract_version: "5.2"
---

# Linbox posts

Post and feed flows for the Linbox LinkedIn MCP server: read one post or search the
account's cached posts, queue feed/post scrapes and engagement, and tag posts. Reads never
scrape inline (`linbox_get_post` queues a body-only scrape on a `post_url` miss); every other
scrape and engagement is queued through `linbox_create_task(type, params)` and polled with
`linbox_get_task`.

Read the root [Linbox](../SKILL.md) skill first for auth, `meta`, cursor pagination, and
the create_task/get_task contract. This skill covers only the posts group.

## When to use

- reading one known post (by URL or `activity_id`)
- finding posts already cached for the account (by tag, text, recency, or our engagement)
- pulling fresh posts from the home feed, or one post with its comments/reactions
- finding new LinkedIn posts by keyword (live LinkedIn search, not just the cache)
- liking or commenting on a post
- tagging a post so it can be found later
- listing the people who authored, liked, or commented on a post

For DMs use [Messaging](../messaging/SKILL.md); for invites use
[Connections](../connections/SKILL.md); for profile/company data use
[Enrichment](../enrichment/SKILL.md).

To check whether unreviewed feed posts are waiting (7-day horizon, cache-only), call
`linbox_whats_new` before searching; `ack=true` marks every matching post seen, not just
the previewed ones.

## Prerequisites

- A Linbox MCP token scoped to one LinkedIn `account_id`; never pass an account id.
- **A post must be in the account cache** before it can be tagged or used as a `post_url`
  persona filter. If it isn't, queue `scrap_post` (or `scrap_feed`) first and poll to `done`.
- Queueing post work additionally needs an **active LinkedIn session** — these types refuse
  to enqueue without one (`status="blocked"`).

### `post_url` format

`post_url` must be a LinkedIn post URL in one of these forms (anything else →
`Error: post_url must point to ...`):

- `https://www.linkedin.com/posts/<slug>`
- `https://www.linkedin.com/feed/update/urn:li:{activity|ugcPost|share}:{id}`

URLs are normalized before dedup, so the two forms of the same post collide correctly.

## Tools

| Tool / type | Kind | Purpose |
|---|---|---|
| `linbox_get_post(post_url \| activity_id)` | read | One post of any age; a `post_url` miss queues a body-only scrape |
| `linbox_search_post` | read (DB only) | Search cached posts, cursor paginated |
| `linbox_search_persona(post_url=... \| activity_id=...)` | read (DB only) | People who engaged with a post: `roles`, `headline`, each commenter's `comment` |
| `linbox_create_task(type="scrap_feed", params)` | queue | Scrape the home feed into the cache |
| `linbox_create_task(type="search", params)` | queue | Search LinkedIn posts by keyword into the cache |
| `linbox_create_task(type="scrap_post", params)` | queue | Scrape one post (optionally comments / reactions) |
| `linbox_create_task(type="like_post", params)` | queue | Like a post |
| `linbox_create_task(type="comment_on_post", params)` | queue | Comment on a post |
| `linbox_add_post_tags` / `linbox_remove_post_tags` | tags | Attach/detach tags on a post |
| `linbox_get_task(task_id)` | poll | What the task did; `next` names the read tool |

### `linbox_get_post(post_url=None, activity_id=None)`

One post for this account, no `task_id` required. Returns `post`
(`text`/`content`, `author_name`, `author_url`, `post_url`, `activity_id`, `source_id`,
`urn_type`, `posted_at(_ago)`, `last_seen_at(_ago)`, `is_liked`, `actions`, `comment`,
`tags`) plus `task`, `guidance`, `meta`.

- **Cached → any age, nothing queued** (`meta.reason="cached"`). Age is `meta.as_of_ago`.
- **`post_url` miss → a body-only `scrap_post` is queued** and returned on `task` (or the
  unfinished one, `meta.reason="pending_task"`). Poll it, then call `linbox_get_post` again.
- **`activity_id` miss → nothing can be queued**: `post=null`, `task=null`,
  `meta.reason="no_scrape_url"`. Call again with `post_url`.
- Comments/reactions, or a fresher body, are `linbox_create_task(type="scrap_post")`.
- The **people** on a post stay on `linbox_search_persona(post_url=...)`; this tool returns
  the post and our own engagement, not the actor list.

### `linbox_search_post(tags=None, content_contains=None, posted_since_days=None, actions=None, cursor=None, limit_posts=30)`

Searches the account's cached posts via Postgres RPC. Always `meta.data_source="cache"` —
it never scrapes and never enqueues. The cache holds posts from `scrap_feed`, `scrap_post`,
and `search` alike. To find posts LinkedIn has that the cache doesn't, queue
`type="search"`.

- **All filters are optional.** With none, returns cached posts by recency
  (`last_seen_at` desc).
- `tags` — **OR** semantics inside the array; combines with other filters by AND.
- `content_contains` — substring match on post content.
- `posted_since_days` — clamped 1–3650.
- `actions` — **our own** engagement; allowlist `liked`, `commented`, OR inside the array.
  Anything else → `Error: unsupported post search action: ...`.
- `limit_posts` — clamp 1–100 (default 30). Keyset `cursor` over `last_seen_at` +
  `activity_id`, same loop as the other search tools.

Items have the same shape as `linbox_get_post`'s `post`. Use `linbox_get_post` for one
known post, `linbox_search_post` to find a set.

`is_liked` is the **viewing account's** own reaction state as of the last scrape: `true`
already liked, `false` not liked, `null` not detected.

`actions` is what we already did — a subset of `liked`, `commented`. `comment` carries
**our own** comment text when `commented` is present; for other people's comments use
`linbox_search_persona` with a post filter.

### Queue types: `scrap_feed`, `search`, `scrap_post`, `like_post`, `comment_on_post`

| `type` | `params` |
|---|---|
| `scrap_feed` | optional `limit`, integer 1–200, default 30 |
| `search` | `query` (non-empty string, trimmed, max 200 chars); optional `limit` (posts to collect in this run, 1–20; omit = account cap), `entities` (default `["posts"]`) |
| `scrap_post` | `post_url`; optional `sections` (allowlist `comments`, `reactions`; `null` or `[]` = post body only), `comments_limit` 1–500, `comments_sort` (`relevant` \| `recent`), `reactions_limit` 1–500 |
| `like_post` | `post_url` |
| `comment_on_post` | `post_url`, `text` |

`comments_limit` / `comments_sort` matter only with `comments`; `reactions_limit` only with
`reactions`. An unknown section name or an extra `params` key is an error.

Read `status` on the response:

| `status` | Meaning | Next step |
|---|---|---|
| `queued` | Task created, worker may pick it up now | Poll `linbox_get_task(task_id)` |
| `queued_deferred` | Created but limit-gated; `reason` says why, `scheduled_for` is the earliest run | Wait past `scheduled_for`, then poll — do not re-enqueue |
| `pending` | An identical pending task exists; its `task_id` is returned | Poll that task — no duplicate was created |
| `blocked` | LinkedIn session inactive; **nothing was queued** and `task_id` is empty | Reconnect the account, then retry |

Dedup scope: `scrap_feed` allows **one pending task per account** (any `limit`); `search`
dedups on `query` + `entities` (a different `query` is a new task); `scrap_post`,
`like_post`, and `comment_on_post` dedup per normalized `post_url` **per action**. Only
`todo`/`in_progress` tasks are reused.

**`search` — posts only.** `entities` accepts only `"posts"`. `"companies"` and
`"profiles"` return `Error: ... not implemented yet` and nothing is queued; any other name is
also an error. Search runs only when you ask — there is no auto-schedule.

**`search` — `limit` is posts per run, capped per account.** Omit `limit` to collect the
account cap (10 on low / on_demand, 20 on high); pass it only to collect fewer. A `limit`
above the cap is an error (`search limit N exceeds max_posts M for this account`) and nothing
is queued — drop `limit` or lower it, don't resend the same number. This is separate from the
daily search-run limit.

A `done` search task's `output` lists what was found: `activity_ids` and slim `posts[]` rows (`activity_id`,
`post_url`, `author_name`, `author_url`, `content`, `posted_at`, counts). `activity_id` is
the Linbox cache UUID, not a LinkedIn id — pass it to `linbox_get_post(activity_id=...)` or
`linbox_add_post_tags`. `next` is `linbox_search_post`.

Both scrapes also sync the viewer's liked-state: a `done` scrape writes or clears the owner's
`liked` action for each post, so `is_liked` and the owner's presence in
`linbox_search_persona(post_url=...)` stay accurate without ever liking anything. A finished
`scrap_post` task exposes it as `output.is_liked`.

`like_post` and especially `comment_on_post` are the tightest-limited post actions, so
`queued_deferred` is the normal path for bursts, not an error.

### `linbox_add_post_tags(tags, post_url=None, activity_id=None)` / `linbox_remove_post_tags(...)`

Tag a post (`entity_type='activity'`). Pass `post_url` **or** `activity_id`; with neither →
`Error: Provide post_url or activity_id`.

- The post must already be cached, otherwise `Error: Post is not in cache for this account`
  — scrape it first.
- Names are normalized (`trim + lowercase`) and missing tags are auto-created on add,
  exactly like persona/thread tags (see [Tags](../tags/SKILL.md)).
- Response: `post_url`, `activity_id`, plus `applied` (add) or `removed` / `not_found`
  (remove, idempotent), and `remaining`.
- These tags are what `linbox_search_post(tags=[...])` filters on. Post tags do not appear
  in thread or persona search.

### `linbox_search_persona(post_url=... | activity_id=...)`

Lists the personas linked to a post's recorded actions — author, likers, commenters —
restricted to personas known to this account.

- Pass **at most one** of `post_url` / `activity_id`. The post must be cached.
- Each item carries `roles`: any of `author`, `commenter`, `liker` (a person can hold
  several). Without a post filter `roles` is empty.
- `headline` is returned when available. Use it to triage likers and commenters by title
  instead of scraping each profile; treat a missing `headline` as "unknown".
- `comment` is that person's **latest** comment text on the filtered post — the only way to
  read other people's comments. Absent for likers and non-commenting authors.
- The response echoes the resolved `post_url` and `activity_id`.
- Cursor paginated: while `page.has_more`, repeat with the same filter and
  `cursor=page.next_cursor` (`limit_personas` clamps 1–100).
- Combining the post filter with `name`, `linkedin_url`, `tags`, or `is_friend` narrows the
  actor set by AND.
- Coverage depends on what was scraped: request `sections: ["comments"]` before expecting
  commenters, `["reactions"]` before expecting likers.

## Workflow

1. **Ask for the data.** `linbox_get_post(post_url)` for one known post (a miss queues the
   body scrape for you), `linbox_search_post` to find cached candidates.
2. **Fill the cache when needed.** `type="scrap_feed"` for a broad sweep, `type="search"`
   for posts on a topic, `type="scrap_post"` with `sections` when you need
   comments/reactions.
3. **Poll to terminal.** Capture `task_id`, wait past `scheduled_for`, then
   `linbox_get_task(task_id)` until `done`/`failed`. Post types are single-target — they
   never return a `batch_id`.
4. **Read the entity.** After a `done` scrape: `linbox_get_post` for the post itself,
   `linbox_search_post` for a feed sweep or search, and `linbox_search_persona(post_url=...)` for the
   actors' `roles`, `headline`, and `comment`. `next` on the task names it.
5. **Engage.** `type="like_post"` / `type="comment_on_post"`, then poll the task and re-read
   `linbox_get_post` to confirm the action landed.
6. **Tag what matters** with `linbox_add_post_tags` so the post is retrievable later by
   `linbox_search_post(tags=[...])`.

## Rules

- **Entity via get/search, task via get_task.** A `linbox_create_task` or `linbox_get_task`
  response never contains the full post — `linbox_get_post` / `linbox_search_post` do. A
  `search` task's slim `output.posts[]` is a pointer list, not the post shape.
- `post=null` means "not in cache", not a failure. Follow `task` if set; on
  `no_scrape_url` call again with `post_url`.
- Reads never scrape inline; scrapes and engagement are queue-first. Never expect a scrape,
  like, or comment to complete inside the tool call.
- `status="blocked"` means nothing was queued (inactive session) — reconnect; don't poll and
  don't retry in a loop.
- Check `actions` before engaging: `liked` present → skip `like_post`; `commented` present →
  read `comment` instead of commenting twice. Likes and comments are tightly limited.
- `is_liked` stays the raw scrape signal: only `false` means "not liked yet"; `null` is
  unknown, so scrape the post first if it matters.
- `queued_deferred` and a future `scheduled_for` are limit gates, not failures. Wait;
  re-enqueuing just returns `pending` with the same `task_id`. There is no flag to bypass
  limits.
- Tagging a post and filtering personas by post both require the post to be cached.
- `scrap_post` scrapes the body only unless you list `sections`; comments and reactions are
  the expensive parts, so enable just what you need and cap `comments_limit` /
  `reactions_limit`.
- `linbox_search_persona` takes `post_url` **or** `activity_id`, never both.
- Post tags are `entity_type='activity'` — distinct from persona and thread tags, and only
  searchable via `linbox_search_post`.
- Verify a comment before sending: `comment_on_post` is public and cannot be undone through
  Linbox.
- A validation error is the plain string `"Error: ..."`, not JSON.

## Examples

**Read one post (a miss queues the scrape)**

```
linbox_get_post({"post_url": "https://www.linkedin.com/posts/janedoe_hiring-activity-123"})
  # post present -> use it; note meta.as_of_ago
  # post == null, task set -> body-only scrape queued (or pending_task reused)
linbox_get_task({"task_id": "<task.task_id>"})   # poll to done
linbox_get_post({"post_url": "https://www.linkedin.com/posts/janedoe_hiring-activity-123"})
```

**Find cached posts about a topic, then tag one**

```
linbox_search_post({"content_contains": "hiring", "posted_since_days": 30})
  # posts[].activity_id / post_url; while page.has_more, repeat with cursor=page.next_cursor
linbox_add_post_tags({"tags": ["icp-signal"], "activity_id": "<uuid>"})
```

**Search LinkedIn for posts on a topic**

```
linbox_create_task({"type": "search", "params": {"query": "sales automation"}})  # omit limit = account cap
  # status: queued | queued_deferred | pending | blocked
linbox_get_task({"task_id": "<task_id>"})   # poll to done; output.activity_ids; next = linbox_search_post
linbox_search_post({"content_contains": "sales automation"})
linbox_get_post({"activity_id": "<uuid from output>"})
```

**Refresh the feed, then read what landed**

```
linbox_create_task({"type": "scrap_feed", "params": {"limit": 30}})
  # status: queued | queued_deferred | pending | blocked
linbox_get_task({"task_id": "<task_id>"})   # poll after scheduled_for until done
linbox_search_post({"posted_since_days": 7})
```

**Scrape one post with comments and reactions, then see who engaged**

```
linbox_create_task({"type": "scrap_post",
                    "params": {"post_url": "https://www.linkedin.com/posts/janedoe_hiring-activity-123",
                               "sections": ["comments", "reactions"],
                               "comments_limit": 50, "comments_sort": "recent",
                               "reactions_limit": 100}})
linbox_get_task({"task_id": "<task_id>"})   # poll to done
linbox_search_persona({"activity_id": "<uuid>", "limit_personas": 100})
  # personas[].roles: author | commenter | liker; headline + comment when known
  # while page.has_more, cursor=page.next_cursor
```

**Engage with a post**

```
linbox_search_post({"content_contains": "limits"})
  # skip posts whose actions[] already contains "liked" / "commented"
linbox_create_task({"type": "like_post",
                    "params": {"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:123"}})
linbox_create_task({"type": "comment_on_post",
                    "params": {"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:123",
                               "text": "Great breakdown — the limits point matches what we see."}})
linbox_get_task({"task_id": "<task_id>"})   # poll each task to done/failed
```

## Related skills

- [Linbox](../SKILL.md) — root contract: auth, identifiers, create_task types, `meta`,
  cursor pagination, task polling.
- [Tags](../tags/SKILL.md) — tag normalization, auto-create, and the other entity types.
- [Enrichment](../enrichment/SKILL.md) — `linbox_get_profile` for a post actor's profile.
- [Messaging](../messaging/SKILL.md) — reach out to someone found via a post.
- End-to-end recipes: [`playbooks/SKILL.md`](../../playbooks/SKILL.md).
