---
name: linbox-posts
description: >-
  Work with LinkedIn posts through the Linbox MCP server — search cached posts with
  linbox_search_post, queue a home-feed or single-post scrape with linbox_scrap_feed /
  linbox_scrap_post (sections: comments, reactions), engage via linbox_like_post and
  linbox_comment_on_post, tag posts with linbox_add_post_tags / linbox_remove_post_tags,
  and list a post's actors with their roles via
  linbox_search_persona(post_url=... | activity_id=...). Use when a request involves the LinkedIn feed,
  a post URL, post content search, liking or commenting on a post, post tags, or finding
  who authored, liked, or commented on a post.
contract_version: "3.0"
---

# Linbox posts

Post and feed flows for the Linbox LinkedIn MCP server: search posts already in the
account cache, queue feed/post scrapes, like or comment, and tag posts. Reads are
cache-only; every write and scrape is **queue-first** (enqueue a task, poll it).

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

## When to use

- finding posts already cached for the account (by tag, text, or recency)
- pulling fresh posts from the home feed, or one post (optionally its comments/reactions)
- 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 scrapes 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 `linbox_scrap_post` (or
  `linbox_scrap_feed`) first and poll to `done`.
- Queue tools additionally need an **active LinkedIn session** — unlike the enrichment
  scrapes, they refuse to enqueue without one (see `blocked` below).

### `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 | Kind | Purpose |
|---|---|---|
| `linbox_search_post` | read (DB only) | Search cached posts, cursor paginated |
| `linbox_scrap_feed` | queue | Scrape the home feed into the cache |
| `linbox_scrap_post` | queue | Scrape one post (optionally its comments / reactions) |
| `linbox_like_post` | queue | Like a post |
| `linbox_comment_on_post` | queue | Comment on a post |
| `linbox_add_post_tags` / `linbox_remove_post_tags` | tags | Attach/detach tags on a post |
| `linbox_search_persona(post_url=... \| activity_id=...)` | read (DB only) | People who engaged with a post, with `roles` and `headline` |

### `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 (`search_posts_for_account`). Always
`meta.data_source="cache"` — it never scrapes inline and never enqueues a refresh.

- **All filters are optional.** With none, it returns the account's cached posts by
  recency (`last_seen_at` desc).
- `tags` — **OR** semantics inside the array; combines with the other filters by AND.
- `content_contains` — substring match on post content.
- `posted_since_days` — clamped 1–3650; keeps posts published within that window.
- `actions` — **our own** engagement on the post; allowlist `liked`, `commented`.
  **OR** inside the array (like `tags`), AND with the other filters. `None`/`[]` = no
  filter; 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.

Each `posts[]` item: `activity_id` (the id used by the post tag tools), `source_id`,
`urn_type`, `post_url`, `content`, `posted_at(_ago)`, `last_seen_at(_ago)`,
`author_name`, `author_url`, `tags`, `is_liked`, `actions`, `comment`. The response
echoes the `actions` filter it applied.

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

`actions` is what we already did to the post — a subset of `liked`, `commented` (that
order), empty when neither. It is the broader signal: `liked` appears from a recorded like
action **or** `is_liked=true`. `comment` carries our own comment text when `commented` is
present, otherwise `null`.

### Queue tools: `scrap_feed`, `scrap_post`, `like_post`, `comment_on_post`

All four behave the same way: they enqueue a task and return `McpTaskResponse` —
never an inline result.

| Tool | Arguments |
|---|---|
| `linbox_scrap_feed(limit=30)` | `limit` clamp 1–200 |
| `linbox_scrap_post(post_url, sections=None, comments_limit=None, comments_sort="relevant", reactions_limit=None)` | `sections` allowlist: `comments`, `reactions`; `None` or `[]` = post body only. Unknown name → `Error: Invalid section ...`. `comments_limit` 1–200 and `comments_sort` (`relevant` \| `recent`) apply only with `comments`; `reactions_limit` 1–500 only with `reactions` |
| `linbox_like_post(post_url)` | — |
| `linbox_comment_on_post(post_url, comment_text)` | `comment_text` required, non-empty |

Both scrapes also sync the viewer's liked-state: a `done` scrape writes or clears the
account 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 calling
`linbox_like_post`. A finished `scrap_post` task exposes it directly as
`output.is_liked`.

Read **two** fields on the response:

- `task_status` — `todo` when queued, or **`blocked`** when the LinkedIn session is
  inactive (then `task_id` is empty, `meta.reason="account_session_not_active"`, and
  nothing was queued — reconnect the account and retry).
- `output.status` — the enqueue outcome:

| `output.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; `meta.reason` says why, `scheduled_for` is the earliest run | Wait past `scheduled_for`, then poll — do not re-enqueue |
| `pending` | An identical pending task already exists; the existing `task_id` is returned | Poll that task — no duplicate was created |

Dedup scope: `scrap_feed` allows **one pending task per account** (any `limit`);
`scrap_post`, `like_post`, and `comment_on_post` dedup per normalized `post_url` **per
action**. Only `todo`/`in_progress` tasks are reused — a `done`/`failed` one does not
block a new task.

`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` (the
`activity_id` from `linbox_search_post`); with neither →
`Error: Provide post_url or activity_id`.

- The post must already be cached for this account, 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 are a
  separate entity type — they 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 — its author, likers, and
commenters — restricted to personas known to this account.

- Pass **at most one** of `post_url` / `activity_id` → otherwise `Error: Provide at most
  one of post_url or activity_id`. The post must be cached (same resolution as the post
  tag tools).
- Each `personas[]` 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 (cached profile headline, or the headline captured
  with the post's engagement data). Use it to triage likers and commenters by title
  instead of calling `linbox_scrap_profile` once per person; it may be absent, so treat a
  missing `headline` as "unknown", not "no title".
- The response echoes the resolved `post_url` and `activity_id`, so an `activity_id`-only
  call still tells you which post it matched.
- Actors are cursor paginated like any other search: while `page.has_more`, repeat with the
  same post filter and `cursor=page.next_cursor` (`limit_personas` clamps 1–100).
- The post filter alone returns the actor set; combining it with `name`, `linkedin_url`,
  `tags`, or `is_friend` narrows that set by AND.
- Coverage depends on what has been scraped: run `linbox_scrap_post` with
  `sections=["comments"]` before expecting commenters, and `sections=["reactions"]` before
  expecting likers. The account owner appears among the likers once any scrape (or
  `linbox_like_post`) detected the reaction.

## Workflow

1. **Look in the cache first.** `linbox_search_post` is free and instant; use it before
   queueing any scrape.
2. **Fill the cache when needed.** No relevant posts → `linbox_scrap_feed` for a broad
   sweep, or `linbox_scrap_post` for one known URL (add `sections=["comments"]` /
   `["reactions"]` only when you need those actors — body-only is the cheap default).
3. **Poll to terminal.** Capture `task_id`, wait past `scheduled_for`, then
   `linbox_get_task(task_id)` until `done`/`failed`. Post tools are single-target — they
   never return a `batch_id`.
4. **Re-read the cache.** After a `done` scrape, call `linbox_search_post` (or
   `linbox_search_persona(post_url=... | activity_id=...)`) to get the persisted data
   and each actor's `roles`.
5. **Engage.** `linbox_like_post` / `linbox_comment_on_post`, then poll the task to
   confirm the action actually landed.
6. **Tag what matters** with `linbox_add_post_tags` so the post is retrievable later by
   `linbox_search_post(tags=[...])`.

## Rules

- Reads are cache-only; scrapes and engagement are queue-first. Never expect a post
  scrape, like, or comment to complete inside the tool call.
- `task_status="blocked"` means nothing was queued (inactive session) — reconnect, don't
  poll and don't retry in a loop.
- Check `actions` from `linbox_search_post` before engaging: `liked` present → skip
  `linbox_like_post`, `commented` present → read `comment` to see what we already wrote
  instead of commenting twice. Likes and comments are tightly limited, so re-engaging is
  wasted quota.
- `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`.
- Tagging a post and filtering personas by post both require the post to be
  cached — scrape before tagging.
- `linbox_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; page actors
  with `cursor` while `page.has_more` (`limit_personas` clamps 1–100).
- 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

**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>"})
```

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

```
linbox_scrap_feed({"limit": 30})
  # output.status: queued | queued_deferred | pending; task_status "blocked" = inactive session
linbox_get_task("<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_scrap_post({"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>")            # poll to done
linbox_search_persona({"activity_id": "<uuid>", "limit_personas": 100})
  # personas[].roles: author | commenter | liker; personas[].headline when known (triage before scraping)
  # while page.has_more, cursor=page.next_cursor
```

**List the posts we already engaged with**

```
linbox_search_post({"actions": ["commented"]})   # posts[].comment holds our own comment text
linbox_search_post({"actions": ["liked"], "posted_since_days": 30})
```

**Engage with a post**

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

## Related skills

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