> ## 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.
</Note>

## 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`              | `any`    | null    | Type              |
| `language`          | `any`    | null    | Language          |
| `tone`              | `any`    | null    | Tone              |
| `hook`              | `any`    | null    | Hook              |
| `guideline`         | `any`    | null    | Guideline         |
| `signature`         | `any`    | null    | Signature         |
| `writing_style_urn` | `any`    | 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.

| 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.

Text content only — media (images/videos) must be added from the
MagicPost web UI for v1. 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` | `any`    | null    | Post Id     |

***

## 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.

| Input                | Type      | Default | Description        |
| -------------------- | --------- | ------- | ------------------ |
| `post_id`            | `string`  | —       | Post Id            |
| `at_iso`             | `string`  | —       | At Iso             |
| `timezone`           | `any`     | 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.

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

***

## get\_metrics\_summary

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

Returns top-line totals (impressions, likes, comments, reposts) and the
top N posts by impressions. 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       |

***

## refresh\_my\_posts

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

Use this when the user explicitly asks to refresh their analytics. The
tool polls the job status every 2 seconds until it completes or
max\_wait\_seconds elapses. Default 90s is enough for typical refreshes.

| 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`        | `any`     | null          | Query        |
| `creator_name` | `any`     | null          | Creator Name |
| `poster_urn`   | `any`     | null          | Poster Urn   |
| `languages`    | `any`     | null          | Languages    |
| `formats`      | `any`     | null          | Formats      |
| `post_types`   | `any`     | null          | Post Types   |
| `days_range`   | `any`     | null          | Days Range   |
| `date_from`    | `any`     | null          | Date From    |
| `date_to`      | `any`     | null          | Date To      |
| `min_likes`    | `any`     | null          | Min Likes    |
| `max_likes`    | `any`     | null          | Max Likes    |
| `min_words`    | `any`     | null          | Min Words    |
| `max_words`    | `any`     | 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/D + icebreaker).
  Get valid ids from `list_icps`.
* level: qualification level filter (A is the best fit, D the worst).
  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`         | `any`     | null    | Icp Id         |
| `level`          | `any`     | null    | Level          |
| `contact_status` | `any`     | null    | Contact Status |
| `search`         | `any`     | null    | Search         |
| `favorite`       | `any`     | 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/D against it.

*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.

⚠️ CONSUMES LEAD CREDITS — 1 credit per scored prospect. 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`  | `any`     | 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` | `any`    | null    | Contact Status |
| `tags`           | `any`    | null    | Tags           |
| `comment`        | `any`    | null    | Comment        |
| `is_favorite`    | `any`    | 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, 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.

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` | `any`     | 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` | `any`     | 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        |

***

## 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`           | `any`     | null    | Timezone           |
| `confirm_same_day`   | `boolean` | `true`  | Confirm Same Day   |
| `confirm_exact_slot` | `boolean` | `true`  | Confirm Exact Slot |
