> ## Documentation Index
> Fetch the complete documentation index at: https://dev.magicpost.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Tools catalog

> The MCP tools — inputs, outputs, examples.

> [!NOTE]
>
> This page is auto-generated from the MCP server's `tools/list`. Run
> `scripts/sync-tools.py` after backend changes to refresh it.

## generate\_post

Generate a new LinkedIn post and persist it as a draft.

## Inputs

* subject: required. What the post should be about. Free text.
* type: optional. High-level intent:
  'actionable'    → tips, methods, operational lessons
  'aspirational'  → success stories, positive messaging
  'introspective' → reflections, takes, vented frustration
  'promotional'   → launches, lead magnets, valuesales
  When omitted, the type is auto-detected from the subject.
* language: optional. Defaults to the user's UI preference.
* tone: optional. Same vocabulary as the UI ('standard', 'friendly', etc).
* hook: optional. A custom opening line. Inserted verbatim.
* guideline: optional. Extra instructions appended to the prompt.
* signature: optional. Custom signature.
* writing\_style\_urn: optional.

## Writing style resolution

* DEFAULT (writing\_style\_urn empty) — uses the user's configured style:
  if their UI has `profilePostsActive=true` with a profile URN attached,
  that profile's recent posts feed the prompt as few-shot examples.
  If `profilePostsActive=false`, no style is injected.
* "Generate in MY style" → leave writing\_style\_urn empty.
* "Generate in the style of `<other person>`" — multi-step flow:
  1. Call list\_my\_writing\_styles() to check if that person is already
     imported on the user's account.
  2. If not, call `find_writing_style_profile("<name or URL>")` to
     locate a candidate URN with metadata. Profiles with
     `ready_to_use=true` can be attached immediately.
  3. Call add\_writing\_style\_profile(profile\_url) to attach the
     profile to the user's account. If the response is
     `status: 'scraping_in_progress'` or `'import_started'`, tell
     the user to wait 1-3 minutes and re-call add\_writing\_style\_profile
     (idempotent — it flips to 'ready' once enough posts are scraped).
  4. Once `status: 'ready'` or `'already_imported'`, call
     `generate_post(..., writing_style_urn=<the returned urn>)`.
     This overrides `profilePostsActive` for THIS call only.

## Rate limits

Capped at 3 generations per minute, 20 per day, and 50 per week.
A 429 response indicates which window was hit (rate\_limited\_generation)
— surface the limit reset hint to the user verbatim.

## Errors that need user action

* credits\_insufficient (402) → user needs to upgrade.
* writing\_style\_not\_imported (400) → the URN wasn't on the user's
  account; route through the import flow first.
* rate\_limited\_generation (429) → cap hit; surface counts + hint.

| Input | Type | Default | Description |
| - | - | - | - |
| `subject` | `string` | — | Subject |
| `type` | `string \| null` | null | Type |
| `language` | `string \| null` | null | Language |
| `tone` | `string \| null` | null | Tone |
| `hook` | `string \| null` | null | Hook |
| `guideline` | `string \| null` | null | Guideline |
| `signature` | `string \| null` | null | Signature |
| `writing_style_urn` | `string \| null` | null | Writing Style Urn |

***

## list\_my\_writing\_styles

List the LinkedIn profiles the user has imported as writing-style
candidates, including which one is currently active in the UI.

Use this before find\_writing\_style\_profile when the user asks to
generate "in the style of `<person>`" — if the person is already in
their imported list, skip search/import and pass the URN directly
to generate\_post.

## Returns

`{success, count, writing_styles: [WritingStyleEntry, ...]}`

Each WritingStyleEntry has:

* urn: opaque LinkedIn URN (use it as `writing_style_urn`)
* display\_name: human-readable name
* profile\_url: full LinkedIn URL (may be null on legacy entries)
* is\_active: True for the currently active style (the UI default)
* valid\_posts: how many of this profile's posts we have ingested
* ready\_to\_use: True iff valid\_posts >= 30 (below that the
  generated style will be weak)

*No inputs.*

***

## find\_writing\_style\_profile

Search the global LinkedIn profile catalog by name or LinkedIn URL.

Use this AFTER list\_my\_writing\_styles, when the person the user wants
to mimic is not already imported on their account. The returned
results indicate `ready_to_use` (true iff the profile already has

> \= 30 valid posts ingested in our DB).

* query: a person's full name (e.g. "Gary Vaynerchuk") OR a LinkedIn
  URL. Both shapes are handled.
* limit: up to 25, default 10.

## Returns

`{success, query, count, results: [...]}`

Each result has:
urn, full\_name, headline, profile\_url, followers, valid\_posts,
ready\_to\_use.

After picking a candidate, call add\_writing\_style\_profile(profile\_url)
to attach it to the user's account. If the candidate's `ready_to_use`
is false, the import will still succeed but the import endpoint will
return `status: 'scraping_in_progress'` until enough posts are
ingested (\~2 minutes).

| Input | Type | Default | Description |
| - | - | - | - |
| `query` | `string` | — | Query |
| `limit` | `integer` | `10` | Limit |

***

## add\_writing\_style\_profile

Attach a LinkedIn profile to the user's account as a writing-style
candidate, scraping its posts if needed.

Idempotent — re-calling with the same URL is safe. Use this exact
re-call pattern when the response indicates scraping is in progress.

## Inputs

* profile\_url: full LinkedIn URL ([https://www.linkedin.com/in/](https://www.linkedin.com/in/)...).

## Returns

One of these `status` values (HTTP status varies):

* 'already\_imported' (200) — URN was already on the user's account.
  Proceed straight to generate\_post.
* 'ready' (200) — profile had >= 30 valid posts. Just attached.
  Proceed straight to generate\_post.
* 'scraping\_in\_progress' (202) — profile is known but understocked.
  A scrape was queued; the URN is already attached. Tell the user
  to wait \~2 minutes and re-call this same tool — it will flip
  to 'ready' once enough posts are scraped.
* 'import\_started' (202) — profile was unknown to our DB; an
  initial scrape was queued. Tell the user to wait \~3 minutes
  and re-call this tool.
* 'quota\_exceeded' (402) — user's plan limit reached. Surface
  the hint to the user verbatim.
* 'trial\_not\_eligible' (403) — trial accounts cannot import new
  writing styles. The user must upgrade. Generation in the user's
  own style is still available.
* 'invalid\_url' (400) — URL doesn't resolve to a LinkedIn profile.
* 'import\_failed' (4xx) — could not fetch the profile. Verify the
  URL is correct and the profile is public.

Each response shape (when present) includes urn, display\_name,
profile\_url, valid\_posts. Use the returned `urn` as
`writing_style_urn` in generate\_post once status is `ready` or
`already_imported`.

| Input | Type | Default | Description |
| - | - | - | - |
| `profile_url` | `string` | — | Profile Url |

***

## list\_my\_posts

List the user's posts filtered by lifecycle status.

* 'draft':     created but not scheduled and not yet published
* 'scheduled': planified for future publication (sorted by schedule date asc)
* 'published': already on LinkedIn (sorted by created\_at desc)

Default is 'scheduled' because the most common LLM-driven question is
"what's coming up?". When the user asks about drafts or published
posts explicitly, pass the matching status — do NOT rely on the
default.

| Input | Type | Default | Description |
| - | - | - | - |
| `status` | `string` | `'scheduled'` | Status |
| `limit` | `integer` | `20` | Limit |
| `offset` | `integer` | `0` | Offset |

***

## get\_post

Fetch the full record for a single MagicPost owned by the user.

OWN POSTS ONLY. This tool returns `error_type='post_not_found'` (404)
for any post you don't own — including posts of your org teammates,
even if you are an org admin. To read a TEAMMATE's post, use
`get_org_member_post(member_user_id=..., post_id=...)` instead. So if
the post id came from `list_org_member_posts` or relates to another
member, do NOT call this tool.

`post_id` MUST be the MagicPost UUID (e.g.
"16a63f9a-0e13-43eb-b502-..."). It is NOT the LinkedIn share URN
exposed as `linkedin_share_urn` by `get_metrics_summary`. Passing a
URN returns `error_type='invalid_post_id_format'` (400).

To resolve a `linkedin_share_urn` into a MagicPost UUID:

1. Call `list_my_posts(status="published")`.
2. Find the row whose `linked_in_url_post` contains the URN.
3. Use that row's `id` here.

Posts published outside MagicPost are not in this table and cannot
be fetched with this tool.

Besides the raw `post` record, the response carries:

* `publication`: `{state, scheduled_at, published_at, linkedin_url,
  error}`. `state` is one of draft, scheduled, publishing, retrying,
  published, failed. On failed or retrying, `error` gives `message`
  and, when LinkedIn refused the post, `linkedin_status` (e.g. 422)
  and `linkedin_body` (LinkedIn's raw response). Check it right after
  a scheduled time to fix and republish without waiting for the email.
* `scheduled_comments`: the comments armed on the post.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |

***

## create\_or\_update\_post

Create a new draft post (no post\_id) or update an existing one's text.

This tool only creates or updates text. To add an image, video, or PDF,
use attach\_media\_to\_post for a chat-provided file, or the
prepare\_post\_media\_upload / complete\_post\_media\_upload flow for a local
file in a code or CLI client. On success returns
`{success: True, post_id, created: bool}` — use `post_id` (NOT `id`)
when chaining into schedule\_post or publish\_post\_now.

| Input | Type | Default | Description |
| - | - | - | - |
| `content` | `string` | — | Content |
| `post_id` | `string \| null` | null | Post Id |

***

## attach\_media\_to\_post

Attach one chat-provided image, video, or PDF to a draft.

Call this after create\_or\_update\_post and before schedule\_post or
publish\_post\_now. The post must be an unscheduled, unpublished draft,
must not contain a poll. Set replace\_existing=True to replace ALL
existing attachments with this file after validation succeeds.
The post ID and scheduled comments are preserved. alt\_text is limited
to 1000 characters; the separate title is limited to 400 characters.

For a draft created by `create_member_post`, pass the same
`member_user_id` here, then use `schedule_member_post`. Only an org
super\_admin can attach media to a member-owned draft.

`media_file` is a temporary file reference supplied by the chat host.
Never invent or reuse its `download_url`: it is consumed during this
tool call and copied into permanent MagicPost storage.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `media_file` | `object` | — | Media File |
| `alt_text` | `string \| null` | null | Alt Text |
| `member_user_id` | `string \| null` | null | Member User Id |
| `replace_existing` | `boolean` | `false` | Replace Existing |
| `title` | `string \| null` | null | Title |

***

## prepare\_post\_media\_upload

Prepare a short-lived upload for a local image, video, or PDF.

Use this when the MCP client can upload local bytes over HTTPS, such as
Claude Code. This remote server cannot read a local file path. Upload
the bytes according to `upload_request`, then call
complete\_post\_media\_upload with the returned `upload_id`.

For a member-owned draft, pass the `member_user_id` returned by
`list_org_members` and pass it again when completing the upload.
To replace all existing attachments, pass replace\_existing=True here
AND when completing. Existing media stays until completion succeeds.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `file_name` | `string` | — | File Name |
| `mime_type` | `string \| null` | null | Mime Type |
| `member_user_id` | `string \| null` | null | Member User Id |
| `replace_existing` | `boolean` | `false` | Replace Existing |

***

## complete\_post\_media\_upload

Validate an uploaded local file and attach it to its draft.

Call this after prepare\_post\_media\_upload and the HTTPS upload. For a
member-owned draft, pass the same `member_user_id` used to prepare it.
Re-calling a successfully completed upload is safe.
Use replace\_existing=True to replace all attachments, keeping the post
and its comments. alt\_text: max 1000 characters; title: max 400.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `upload_id` | `string` | — | Upload Id |
| `alt_text` | `string \| null` | null | Alt Text |
| `member_user_id` | `string \| null` | null | Member User Id |
| `replace_existing` | `boolean` | `false` | Replace Existing |
| `title` | `string \| null` | null | Title |

***

## update\_post\_media

Edit an existing draft attachment without losing its scheduled comments.

Read media\_url from get\_post's file\_post; never invent it. Cancel any
schedule first. Supply alt\_text (max 1000 characters) and/or title
(max 400); omitted fields stay unchanged, an empty string clears one.
This does not replace the file. Use the upload tools with
replace\_existing=True for replacement. Published posts cannot change.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `media_url` | `string` | — | Media Url |
| `alt_text` | `string \| null` | null | Alt Text |
| `title` | `string \| null` | null | Title |
| `member_user_id` | `string \| null` | null | Member User Id |

***

## schedule\_post\_comment

Arm a comment that MagicPost publishes on LinkedIn under the user's
post, `minutes_after` minutes after the post goes live (0 = right
after publication). Typical use: the "first comment" with the link.

The post must be one of the user's own posts that is NOT published
yet: a draft, or a post already scheduled with schedule\_post. Arm the
comment before or after scheduling, it follows the post's real
publication time. A post can carry several comments; call once per
comment.

* text: max 1250 characters (LinkedIn's limit).
* minutes\_after: 0 to 10080 (one week). Default 0.

Returns `{success, post_id, comment_id, text, minutes_after, ...}`.
Use `comment_id` with delete\_post\_comment to remove it; to change a
comment, delete it and schedule a new one. get\_post lists the post's
comments under `scheduled_comments`.

Errors: `post_already_published` (409) when the post is live or being
published; `failed_tag` when a mentioned member can't be tagged;
`scheduled_comments_forbidden` when the user's plan doesn't include
scheduled comments (do not retry). The call is not retried
automatically: on a network error, check get\_post before calling
again, or the comment may be armed twice.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `text` | `string` | — | Text |
| `minutes_after` | `integer` | `0` | Minutes After |

***

## delete\_post\_comment

Remove a comment armed with schedule\_post\_comment, before it is
published on LinkedIn. A comment already published (or being
published) cannot be removed here: `comment_already_published` (409).

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `comment_id` | `string` | — | Comment Id |

***

## list\_received\_comments

List the comments people left on one of the user's published
LinkedIn posts: text, author (name, headline, profile URL), date, and
whether it is a reply (with its parent comment).

Pass exactly one of `post_id` (MagicPost UUID of a post published
through MagicPost) or `linkedin_activity_id` (from
list\_linkedin\_post\_metrics, works for any post of the user). Only the
user's own posts are allowed.

This reads what MagicPost has already collected, it costs nothing.
Comments are collected by a third-party provider, not by LinkedIn's
official API, and only when a sync runs: compare `comments_stored`
with `post.comments_on_linkedin` and look at `last_sync`. If comments
are missing or stale, call sync\_received\_comments, wait about a
minute, then call this tool again.

`synced: false` means the post is not imported yet (just published):
retry later.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string \| null` | null | Post Id |
| `linkedin_activity_id` | `string \| null` | null | Linkedin Activity Id |

***

## sync\_received\_comments

Fetch the latest comments of one of the user's published posts from
LinkedIn (through MagicPost's data provider), in the background.
Same references as list\_received\_comments.

Uses one unit of the user's daily comment sync quota (returned in
`sync_quota`); a sync already running or started less than a few
minutes ago returns `already_running` and costs nothing. On
`comment_sync_quota_reached`, do not retry: read the stored comments.

Returns `status: running` right away. Wait about a minute (longer for
posts with hundreds of comments), then call list\_received\_comments:
`last_sync.state` tells whether the sync is done.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string \| null` | null | Post Id |
| `linkedin_activity_id` | `string \| null` | null | Linkedin Activity Id |

***

## score\_post

Run MagicPost's Post AI score on a LinkedIn post: the same scan as
the editor's "Score IA". Pass plain `text` to check a post before
creating a draft, or the `post_id` of one of the user's posts (its
current text is scored, unless `text` is also given).

Returns `{success, grade, score, fluency_score, language, flagged,
post_id, scanner_version}`:

* grade: A (doesn't sound AI) to E (sounds too AI), the grade shown
  in the editor.
* score: 0 to 100, the higher the more AI-sounding.
* flagged: each flagged passage with `excerpt` (the exact words),
  `category` (the AI tell family, e.g. contrast\_formula, here\_is),
  `severity` (`strong` tells lower the grade, `weak` ones are
  informative) and `explanation`. Rewrite strong ones first.
  There is no suggested rewrite: propose your own fix for each passage,
  keeping the author's meaning and voice.

Each scan uses the user's daily AI score quota (a post\_id whose text
did not change since its last scan is free). On
`ai_score_quota_reached`, do not retry before `reset_at`.

| Input | Type | Default | Description |
| - | - | - | - |
| `text` | `string \| null` | null | Text |
| `post_id` | `string \| null` | null | Post Id |

***

## add\_idea\_to\_notebook

Save a post idea to the user's MagicPost Idea notebook, to write
later. The idea appears at the top of the notebook's backlog column,
like an idea typed in the app.

Use this when the user asks things like:

* "Save this idea in my MagicPost notebook"
* "Add these 3 topics to my ideas"
  For several ideas, call the tool once per idea.

## Parameters

* idea: the idea itself, short (a topic, an angle, a hook). Max 2000
  characters.
* notes: optional longer context kept with the idea (sources, angle,
  personal anecdote, outline). Max 20000 characters.

This does NOT write a post. To draft a post now, use generate\_post or
create\_or\_update\_post.

## Returns

`{success, idea_id, idea, notes, created_at}`. The call is not
retried: on a network error, check with the user before calling again,
or the idea may be saved twice.

| Input | Type | Default | Description |
| - | - | - | - |
| `idea` | `string` | — | Idea |
| `notes` | `string \| null` | null | Notes |

***

## schedule\_post

Schedule a post for future publication on LinkedIn.

* at\_iso: ISO 8601 datetime. If naive (no offset), it is interpreted
  in the user's timezone. If it has an offset, it is converted.
* timezone: optional IANA tz name override (e.g. 'Europe/Paris').
  Falls back to the user's stored timezone, then UTC.
* confirm\_same\_day: when True (default), the call returns 200 with
  `success: false, warning: 'same_day_posts'` if the user already has
  other posts scheduled on the same day. Show the warning to the user,
  and retry with confirm\_same\_day=false only after they confirm.
* confirm\_exact\_slot: same behavior for posts scheduled within ±5
  minutes (a stronger signal of a likely mistake). This check is
  evaluated FIRST and short-circuits the same-day check, so an
  exact-slot warning (`warning: 'exact_slot_conflict'`) will be
  returned alone even if there are other same-day posts.

When LinkedIn token is expired, returns `actionable: true` with a
hint — surface it to the user verbatim, do not retry blindly.

Returns `error_type='daily_post_limit_reached'` (429) with `limit` and
`retry_at` when this slot would exceed the number of posts the
LinkedIn profile may publish per 24 hours (published and scheduled
posts both count). Tell the user and pick a later slot: do not retry.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |
| `at_iso` | `string` | — | At Iso |
| `timezone` | `string \| null` | null | Timezone |
| `confirm_same_day` | `boolean` | `true` | Confirm Same Day |
| `confirm_exact_slot` | `boolean` | `true` | Confirm Exact Slot |

***

## cancel\_scheduled\_post

Cancel a scheduled post. Idempotent: returns success even if the
post is no longer scheduled. Returns 409 if the post is already
published (cannot un-publish).

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |

***

## publish\_post\_now

Publish a post to LinkedIn immediately (no schedule).

On LinkedIn token expired, returns `actionable: true` with a
reconnect hint. Returns 409 if the post is already published.
Returns `error_type='daily_post_limit_reached'` (429) with `limit` and
`retry_at` when the LinkedIn profile already reached its number of
posts per 24 hours: tell the user, do not retry.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_id` | `string` | — | Post Id |

***

## list\_linkedin\_post\_metrics

List per-post LinkedIn metrics for an inclusive date range.

Includes posts published natively on LinkedIn as well as posts
published through MagicPost, provided they have been imported into
MagicPost analytics. Use YYYY-MM-DD for `date_from` and `date_to`.

Results are ordered newest first and paginated. When `has_more` is
true, call this tool again with `cursor=next_cursor` and the same date
range. Continue until `has_more` is false.

`linkedin_activity_id` and `linkedin_post_id` are not MagicPost UUIDs
and cannot be passed to editing, scheduling, or publishing tools.
Metrics that were not collected are null, not 0.

Beyond `impressions`, `reactions`, `comments` and `reposts`, each post
carries LinkedIn's extended analytics: `reach` (unique members who saw
the post), `saves`, `link_clicks`, `premium_cta_clicks`,
`profile_views` (profile visits generated by the post), `sends` (times
the post was sent in LinkedIn messages) and `followers_gained` (followers
LinkedIn attributes to the post). They are null
until LinkedIn has been asked for them, which `advanced_metrics_available`
tells you (`advanced_metrics_updated_at` gives the date). A 0 means
LinkedIn answered zero, so never read a null as a zero.
When the connected identity is a company page, LinkedIn does not measure
`saves`, `premium_cta_clicks`, `profile_views`, `sends` or
`followers_gained` at all: they come back
null and are listed in `advanced_metrics_unavailable_for_profile_type`.
Say the metric does not exist for pages rather than reporting zero.

| Input | Type | Default | Description |
| - | - | - | - |
| `date_from` | `string` | — | Date From |
| `date_to` | `string` | — | Date To |
| `limit` | `integer` | `50` | Limit |
| `cursor` | `string \| null` | null | Cursor |

***

## get\_metrics\_summary

Get a compact summary of the user's LinkedIn analytics.

Returns top-line totals, follower growth for the selected period, and
the top N posts by impressions.
`totals` and `averages_per_post` cover impressions, likes, comments and
reposts, plus LinkedIn's extended analytics: `reach` (unique members
reached), `saves`, `link_clicks`, `premium_cta_clicks`,
`profile_views`, `sends` and `followers_gained` (followers LinkedIn
attributes to the posts). These are null when no post in the window carries
them, and their average is computed over the posts that do, not over
every post in the window. `advanced_metrics_coverage.posts_with_data`
gives that count per metric, so compare it to `totals.posts_count`
before reading an extended figure as a period total. On a company page,
LinkedIn does not measure `saves`, `premium_cta_clicks`,
`profile_views`, `sends` or `followers_gained`: they are null and listed in
`advanced_metrics_coverage.unavailable_for_profile_type`, so say the
metric does not exist for pages rather than reporting zero.
`followers.gained` is null when
LinkedIn did not provide enough snapshots to cover the whole period.
Check `followers.coverage`; `available_range_gained` is explicitly
limited to the returned coverage dates.
`followers.daily` returns compact `{date, gained}` points, so use one
multi-day call instead of one request per day when building a roll-up.
These are profile-level daily gains, not followers attributed to a
specific post.
Use `period` to scope the window. For most questions like "how did I
perform recently?" use '30d'.

Identifiers — IMPORTANT:
`top_posts[].linkedin_share_urn` is the LinkedIn share URN, NOT the
MagicPost UUID expected by `get_post`, `schedule_post`,
`cancel_scheduled_post`, `publish_post_now`, or
`create_or_update_post`. Passing it to those tools returns
`error_type='invalid_post_id_format'`.

To act on a top post, resolve the URN to a MagicPost UUID:

1. Call `list_my_posts(status="published")`.
2. Find the row whose `linked_in_url_post` contains the URN.
3. Use that row's `id` (UUID) with `get_post` / `schedule_post` / etc.

Posts published outside MagicPost have no MagicPost UUID and cannot
be acted on via the MCP tools.

| Input | Type | Default | Description |
| - | - | - | - |
| `period` | `string` | `'30d'` | Period |
| `top_n` | `integer` | `5` | Top N |

***

## get\_metrics\_freshness

Check when LinkedIn post metrics were last updated.

Call this before `refresh_my_posts`. When `should_refresh` is false,
reuse the stored metrics instead of starting another refresh. When no
metrics timestamp exists, `data_available` is false and
`should_refresh` is true.

| Input | Type | Default | Description |
| - | - | - | - |
| `max_age_minutes` | `integer` | `60` | Max Age Minutes |

***

## refresh\_my\_posts

Trigger a fresh pull of the user's LinkedIn posts and wait until done.

Check `get_metrics_freshness` first. If `should_refresh` is false, do
not start another refresh unless the user explicitly requires a forced
refresh. It is a slow, expensive call: the backend scrapes LinkedIn
synchronously and only responds once the refresh is finished, which can
take up to a minute or two. Call it once and wait for the result — do
NOT call it again back-to-back if it errors, as each call re-triggers
the full scrape.

`max_wait_seconds` is only an upper bound on how long this tool waits;
with the current backend the call returns as soon as the refresh is
done, so the default is fine. If the result ever comes back with status
'timeout', the refresh is still running in the background — tell the
user to check back shortly instead of calling again.

| Input | Type | Default | Description |
| - | - | - | - |
| `max_wait_seconds` | `integer` | `90` | Max Wait Seconds |

***

## search\_inspiration\_posts

Search the global LinkedIn inspiration catalogue (NOT the user's
own posts) for high-performing examples to draw inspiration from.

Use this when the user asks things like:

* "Find me top posts about \<topic>"
* "Show me viral French posts on remote work this month"
* "What's \<creator> posting that gets traction?"

## Filters

* query: free-text. Matches against post text + theme (PostgreSQL FTS).
* creator\_name: partial match on poster's display name.
  ⚠️ ILIKE scan — slower than `poster_urn`. Prefer `poster_urn` when
  you already have it (e.g. from a previous result).
* poster\_urn: exact LinkedIn URN — indexed, fast.
* languages: e.g. \["French", "English"]. Use the pycountry English
  name (not ISO codes, not native names).
* formats: subset of \{text, image, video, carousel, article}.
* post\_types: subset of \{actionable, aspirational, introspective,
  promotional}. Case-insensitive on input; backend matches Capitalize.
* days\_range: shortcut "posts from the last N days". Ignored when
  `date_from`/`date_to` are set.
* date\_from / date\_to: ISO date strings (YYYY-MM-DD). Inclusive.
* min\_likes / max\_likes / min\_words / max\_words: numeric bounds.
  Default minimums (20 likes, 80 words, 5 comments) are applied by
  the backend to keep the catalogue noise-free even when filters
  are sparse.

## Sorting

* 'recent'       → posted\_at DESC
* 'top\_likes'    → num\_likes DESC (default — best for inspiration)
* 'top\_comments' → num\_comments DESC (proxy for discussion-driving)

## Pagination

* page: 1-indexed. Start at 1, increment to walk through results.
* limit: up to 12 per call. Default 10. The response includes
  `has_more` so you know when to stop.

## Returns

`{success, page, per_page, total, has_more, count, posts, filters_applied}`

Each post has: id, text (full), theme, type, format, language,
posted\_at (ISO), num\_likes, num\_comments, num\_reposts, words,
poster\_name, poster\_urn, poster\_headline, profile\_followers\_count,
post\_url, image\_url, video\_url, document\_cover\_urls.

## Common workflows

* "Get me 5 great hooks on \<topic>": query=\<topic>, limit=5,
  sort\_by='top\_likes'.
* "Latest carousels by \<creator>": creator\_name=\<name>,
  formats=\['carousel'], sort\_by='recent'.
* "Inspiration close to what I'm writing": pass the user's draft
  theme as `query`, leave other filters open.

| Input | Type | Default | Description |
| - | - | - | - |
| `query` | `string \| null` | null | Query |
| `creator_name` | `string \| null` | null | Creator Name |
| `poster_urn` | `string \| null` | null | Poster Urn |
| `languages` | `array \| null` | null | Languages |
| `formats` | `array \| null` | null | Formats |
| `post_types` | `array \| null` | null | Post Types |
| `days_range` | `integer \| null` | null | Days Range |
| `date_from` | `string \| null` | null | Date From |
| `date_to` | `string \| null` | null | Date To |
| `min_likes` | `integer \| null` | null | Min Likes |
| `max_likes` | `integer \| null` | null | Max Likes |
| `min_words` | `integer \| null` | null | Min Words |
| `max_words` | `integer \| null` | null | Max Words |
| `sort_by` | `string` | `'top_likes'` | Sort By |
| `page` | `integer` | `1` | Page |
| `limit` | `integer` | `10` | Limit |

***

## list\_leads

List the user's LinkedIn leads (prospects detected from the
engagement on their posts and qualified against their ICPs).

## Filters

* icp\_id: scope the list to one ICP. When set, each lead embeds its
  qualification against that ICP (level A/B/C + icebreaker).
  Get valid ids from `list_icps`.
* level: qualification level filter (A is the best fit, C the
  weakest listed — D-scored profiles are not leads: never billed,
  never listed). Only effective when `icp_id` is also provided.
* contact\_status: pipeline status slug. Defaults are 'not\_contacted',
  'contacted', 'in\_discussion', 'to\_follow\_up', 'won', 'lost',
  'not\_relevant' (users can customize them).
  ⚠️ A lead that was never touched has contact\_status null, and this
  filter only matches explicitly-set statuses — so filtering on
  'not\_contacted' does NOT return untouched leads. To find leads the
  user hasn't contacted yet, list WITHOUT this filter and read each
  item's contact\_status (null or 'not\_contacted' = not contacted).
* search: free-text match on the lead's name/headline.
* favorite: True to show only favorited leads.

## Pagination

* limit: up to 25 per call (default 20).
* offset: 0-indexed. Use `offset` + `has_more` from the response to
  paginate.

Returns `{items, count, limit, offset, has_more}`.

| Input | Type | Default | Description |
| - | - | - | - |
| `icp_id` | `string \| null` | null | Icp Id |
| `level` | `string \| null` | null | Level |
| `contact_status` | `string \| null` | null | Contact Status |
| `search` | `string \| null` | null | Search |
| `favorite` | `boolean \| null` | null | Favorite |
| `limit` | `integer` | `20` | Limit |
| `offset` | `integer` | `0` | Offset |

***

## get\_lead

Full detail of one lead: profile, interactions (likes/comments on
the user's posts), qualification reasoning, icebreaker, and CRM sync
state (`deliveries` to connected external CRMs).

Get `prospect_id` values from `list_leads`.

| Input | Type | Default | Description |
| - | - | - | - |
| `prospect_id` | `string` | — | Prospect Id |

***

## list\_icps

List the user's ICPs (Ideal Customer Profiles).

Needed to get the `icp_id` values used by `list_leads` (scoping) and
`detect_leads` (targeting). Each ICP describes the kind of prospect
the user wants to detect; leads are scored A/B/C against it (a
D-scored profile is not a lead: free, and never listed).

*No inputs.*

***

## get\_leads\_summary

Leads quota status: trial state (is\_trial, trial\_days\_remaining,
trial\_ends\_at), monthly quota (leads\_monthly\_included,
leads\_consumed\_in\_period, leads\_remaining), top-ups
(topup\_leads\_remaining, topups\_detail), and renewal date (period\_end).

Call this before `detect_leads` if the user asks how many credits
they have left.

*No inputs.*

***

## get\_detection\_status

Poll the status of running lead detections (they are async and
typically take 2-15 minutes).

Returns `{in_progress, scoring_in_progress, all_terminal}`.
`all_terminal: true` means every detection is done — the new leads
are available via `list_leads`.

*No inputs.*

***

## detect\_leads

Detect new leads from the engagement (likes + comments) on one of
the user's LinkedIn posts.

post\_urn accepts a numeric activity ID, urn:li:activity:..., or the
share\_urn returned by get\_post (urn:li:share:... / urn:li:ugcPost:...).
The post must be synced to the connected LinkedIn profile; direct
reposts are excluded. A post\_not\_found error requires correcting the
reference or connected profile, not retrying the same call.

⚠️ CONSUMES LEAD CREDITS — 1 credit per prospect scored with at
least a Medium ICP fit (level A/B/C; weak D fits are free and
discarded). The exact cost is NOT known upfront: every engager
matching an ICP gets scored (potentially dozens on a popular post),
so never promise a number.

⚠️ OTHER SIDE EFFECTS — a completed detection also:

* pushes the qualified leads to the user's connected external CRMs
  (Pipedrive/HubSpot/Lemlist/Zapier) — an external write that is
  hard to undo;
* sends a recap email if the user enabled it.
  The confirm-step response tells you which apply (`crm_destinations`,
  `email_recap_enabled`) — relay them to the user before confirming.

Two-step confirmation flow:

1. First call with confirm=true (default): returns `success: false,
   warning: 'credits_consumption'` with the targeted ICPs, the
   remaining credits, `crm_destinations` (enabled CRM push rules;
   empty list = no CRM push) and `email_recap_enabled`. Show all of
   this to the user and ask them to confirm.
2. After the user confirms, retry with confirm=false to actually
   start the detection (returns `success: true` with a queued
   payload).

* icp\_ids: optional list of ICP ids to score against (from
  `list_icps`). Defaults to all the user's active ICPs.
* Detection runs in the background — poll `get_detection_status`
  afterwards. Returns `error_type: 'leads_quota_exhausted'` (402)
  when no credits remain.
* Re-detecting an already-processed post is a NO-OP until its
  engagement has grown: the response comes back with `success:
  false` and `queued: 0` (`already_synced` in results\[]). Tell the
  user nothing was launched in that case — do not claim a detection
  started.
* `results[]` can MIX queued and rejected ICPs (e.g. `queued: 2`
  with one `already_synced` entry). Report both to the user: which
  ICPs were launched and which were skipped, and why.

| Input | Type | Default | Description |
| - | - | - | - |
| `post_urn` | `string` | — | Post Urn |
| `icp_ids` | `array \| null` | null | Icp Ids |
| `confirm` | `boolean` | `true` | Confirm |

***

## update\_lead

Update a lead's pipeline fields. At least one field is required.

* contact\_status: pipeline status slug (see `list_leads` for the
  default values; users can customize them).
* tags: full replacement of the lead's tag list.
* comment: free-text note on the lead.
* is\_favorite: star/unstar the lead.

Only the provided fields are modified. Returns the updated lead.

| Input | Type | Default | Description |
| - | - | - | - |
| `prospect_id` | `string` | — | Prospect Id |
| `contact_status` | `string \| null` | null | Contact Status |
| `tags` | `array \| null` | null | Tags |
| `comment` | `string \| null` | null | Comment |
| `is_favorite` | `boolean \| null` | null | Is Favorite |

***

## list\_org\_members

List the active members of your organisation.

Only callable by org super\_admins. Returns name, email, status,
whether each member is themselves an org\_admin, and whether their
LinkedIn account is currently connected to MagicPost.

Use this first when the user asks anything team-scoped, so you know
the set of valid `member_user_id` values to pass to the other org
tools.

*No inputs.*

***

## get\_org\_metrics\_summary

Compact analytics for the org or one specific member.

* When `member_user_id` is omitted: returns one compact summary per
  team member (name, totals, follower growth, top posts). Use this for
  "how is the team doing?" questions.
* When `member_user_id` is set: returns ONE detailed summary for
  that member, same shape as `get_metrics_summary` for the caller's
  own account.

`top_n` defaults to 3 in team view (response stays token-efficient
for an LLM); set higher when drilling into a single member.

`summary.followers.gained` is null when LinkedIn did not provide
enough snapshots to cover the whole period. Check follower `coverage`;
`available_range_gained` only covers the returned dates.
`summary.followers.daily` contains profile-level daily gains.

Identifiers — IMPORTANT:
`top_posts[].linkedin_share_urn` is the LinkedIn share URN, NOT the
MagicPost UUID expected by `get_post`, `get_org_member_post`,
`schedule_post`, `cancel_scheduled_post`, `publish_post_now`,
`schedule_member_post`, or `create_or_update_post`. Passing it to
those tools returns `error_type='invalid_post_id_format'`.

To act on a top post, resolve the URN to a MagicPost UUID via
`list_org_member_posts(member_user_id=..., status="published")` and
match the URN against the row's `linked_in_url_post` field. Then read
its full record with `get_org_member_post`. Posts published outside
MagicPost have no MagicPost UUID and cannot be acted on.

| Input | Type | Default | Description |
| - | - | - | - |
| `period` | `string` | `'30d'` | Period |
| `member_user_id` | `string \| null` | null | Member User Id |
| `top_n` | `integer` | `3` | Top N |

***

## list\_org\_member\_posts

List posts across the org, optionally scoped to one member.

* When `member_user_id` is set: only that member's posts. Useful for
  "show me Alice's drafts" or "what is Bob scheduling next?".
* When omitted: posts grouped by member (each with `limit` posts
  starting at `offset`). Use for "what's the team posting this
  week?".

`status` is the lifecycle filter (`draft` / `scheduled` / `published`),
same semantics as `list_my_posts`. Members and members' posts are
returned even if the LLM client doesn't have a direct relationship
with them — org-admin permission is sufficient.

Each returned post row exposes its MagicPost UUID as `id`. To read
ONE post's full record (full text, media, stats…), pass that `id` to
`get_org_member_post(member_user_id=..., post_id=...)` — NOT to
`get_post`, which only reads YOUR own posts and returns
`post_not_found` for a teammate's post.

| Input | Type | Default | Description |
| - | - | - | - |
| `member_user_id` | `string \| null` | null | Member User Id |
| `status` | `string` | `'scheduled'` | Status |
| `limit` | `integer` | `20` | Limit |
| `offset` | `integer` | `0` | Offset |

***

## get\_org\_member\_post

Fetch the full record of ONE post owned by a member of your org.

Org-admin read counterpart to `get_post` (which only reads YOUR own
posts). Use this when you already have a member's post UUID — typically
from `list_org_member_posts` — and need its full content/metadata
(full text, schedule date, LinkedIn URL, stats, media, etc.).

Only callable by org super\_admins. Both arguments are REQUIRED:

* `member_user_id`: the post owner's user id, exactly as returned by
  `list_org_members` (`user_record_id`) or `list_org_member_posts`.
  It must be a member of YOUR org.
* `post_id`: the MagicPost UUID of the post (e.g.
  "df1cbd56-a7c0-41f4-b929-5d242bed12eb"), as returned in the `id`
  field by `list_org_member_posts`. This is NOT the
  `linkedin_share_urn` from `get_org_metrics_summary` — passing a URN
  returns `error_type='invalid_post_id_format'` (400).

Typical chain:

1. `list_org_member_posts(member_user_id=..., status=...)` → read the
   target row's `id`.
2. `get_org_member_post(member_user_id=..., post_id=<that id>)`.

Error shapes:

* `error_type='org_admin_required'` (403): you are not an org admin.
* `error_type='member_not_in_org'` (404): `member_user_id` is not in
  your org.
* `error_type='post_not_found'` (404): the post does not exist, is
  deleted, or does NOT belong to `member_user_id` (IDOR guard — you
  cannot read another member's post by passing the wrong owner).

Do NOT use `get_post` for a teammate's post — it is scoped to your own
posts and will return `post_not_found` for anything you don't own.

| Input | Type | Default | Description |
| - | - | - | - |
| `member_user_id` | `string` | — | Member User Id |
| `post_id` | `string` | — | Post Id |

***

## create\_member\_post

Create a draft owned by a specific organisation member.

Only callable by org super\_admins. Get `member_user_id` from
`list_org_members`. The returned `post_id` belongs to that member and
can be passed to `schedule_member_post`.

To attach media first, call `attach_media_to_post` with both the
returned `post_id` and the same `member_user_id`.

| Input | Type | Default | Description |
| - | - | - | - |
| `member_user_id` | `string` | — | Member User Id |
| `content` | `string` | — | Content |

***

## schedule\_member\_post

Schedule a post on behalf of an org member.

Only callable by org super\_admins. The post must belong to
`member_user_id` (you can't move someone else's post by accident).

Defaults:

* `timezone` falls back to the member's stored userTimezone (NOT
  yours). Set it explicitly if you want to schedule in your own
  timezone instead.
* `at_iso` is interpreted in that timezone unless the string
  already has an offset.

Same warning flow as `schedule_post` (same\_day\_posts /
exact\_slot\_conflict) — computed against the MEMBER's existing
scheduled posts. Show the warning to the user, then retry with
`confirm_*=false` to force through.

If the member's LinkedIn token has expired, the response has
`actionable: true` with a `hint` telling you to ASK THE MEMBER to
reconnect — only they can, not you. Surface that hint verbatim.

| Input | Type | Default | Description |
| - | - | - | - |
| `member_user_id` | `string` | — | Member User Id |
| `post_id` | `string` | — | Post Id |
| `at_iso` | `string` | — | At Iso |
| `timezone` | `string \| null` | null | Timezone |
| `confirm_same_day` | `boolean` | `true` | Confirm Same Day |
| `confirm_exact_slot` | `boolean` | `true` | Confirm Exact Slot |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.