Skip to main content
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.

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

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

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.

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.

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.

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.

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.

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

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.

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.

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.

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.

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

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.

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.

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.

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.

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.

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.

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.