API v1 · MCP — agents
MCP tools & contracts
The full catalogue of the Scripe MCP server's 132 operations (66
read-only and 66 writes, switch_workspace included), 6 resources, and
5 prompts, and the contracts they share: scopes, two-phase confirmation,
idempotency, inline file content, and progress streaming. For connecting
a host and the workspace/project model, start at MCP.
The planning operations are listed as tools of their own; every other
operation is served as an action of a domain tool — create_idea is
ideas_write({ action: "create_idea", arguments: { … } }) — and is
still callable under its own name; MCP §1.1
explains the catalogue and the MCP tool catalogue
lists which tool carries which operation. This page documents each
operation under that name: its arguments, scopes and behaviour are the
same however it is called.
Tools mirror the REST resources — the MCP tool is a thin adapter over
the same handler the REST endpoint calls, so any contract you've coded
against /v1/* is preserved. The exceptions are the verbs that only
make sense in a conversation (schedule_post, two-phase
publish_post, the destroy verbs, the settings, posting-schedule and
positioning writes, the content-topic tools (list_content_topics,
create_content_topic, assign_content_topic, unassign_content_topic),
generate_post_ideas, the
community profile-list tools, the label, campaign and content-question
families (§2.17), the planning autopilot (§2.19), the Ideal customer
tools (§2.20), the tracked-link pair (§2.21), the anonymised
list_post_comments (§2.22), and
the per-post team engagement pair
get_post_engagements / update_post_engagements), which ship on MCP
only — with one exception: delete_source also has a REST route
(DELETE /v1/sources/:sourceId), two-phase there too (§2.13).
Most tools resolve a missing projectId from the connection's default
project; eleven require it explicitly — the split is in
MCP §2.2.
1. Read tools
All read tools advertise readOnlyHint: true. The required scope must
be on the OAuth grant; <resource>:write grants imply the matching
read. (draft_positioning_from_website and preview_plan also
advertise readOnlyHint: true but are listed with the writes — §2.10
and §2.19 — because each exists to feed a write.)
| Tool | Scope | Returns |
|---|---|---|
get_workspace | workspace:read | The active workspace + plan + principal, including defaultProject: { id, name } | null. |
list_workspaces | workspace:read | Every workspace the user can reach (id, name, isDefault). |
get_workspace_context | workspace:read | One-call overview: plan + features, review-workflow gate, up to 25 projects each with its linkedIn connection block (§1.4). |
list_team | workspace:read | Team members (≤100; truncated + totalCount when more): name, email, role (the dashboard's label, e.g. Admin, Client), per-member project assignments capped at 25 distinct projects and at the caller's own reach. Never returns credentials. |
list_projects | projects:read | Paginated list of projects. Every row carries linkedIn — whether Scripe can publish as it right now (§1.4). |
get_project | projects:read | Single project read, with the same linkedIn block (§1.4). |
list_company_pages | projects:read | Company pages — the delta over list_projects: activation status, STORED admin-token health, follower count, engagement-policy state. |
list_notes | notes:read | List notes for a project (date / cursor + free-text q search, §1.5). Reports pagination.total (§1.6). |
get_note | notes:read | Single note read. |
list_posts | posts:read | List posts for a project — status / statusCategory / statusId / review / delivery filters, q search, creation and publication date windows, content modes. Rows carry the delivery (§1.2) and review (§1.3) verdicts and pagination.total (§1.6). |
get_post | posts:read | Single post read — the whole body, which list_posts only excerpts (§1.7). |
list_post_statuses | posts:read | The pipeline: every kanban column with postCount, plus a counts rollup for the project (§1.8). Each row's id is the statusId both update_post and list_posts take. |
list_calendar | calendar:read | A project's content calendar for a date range (max 92 days): scheduled/planned posts with workflow status + any last publish error, note slots, idea placements, the posting-time template. Days are interpreted in the project's calendar timezone. |
get_next_free_slot | calendar:read | The next free posting slot, spelled out (localTime, date, time, utcOffset beside iso/timezone, plus source: template | fallback) — the same computation the dashboard and chat scheduling use (§1.13). |
list_ideas | ideas:read | Paginated compact idea-board list: brief fields, the DERIVED workflow status (inbox | in_production | review | done), calendar placement, whether the idea is waiting on someone's input, and its campaign ({ id, title, emoji } or null). campaignId (cmp_…) narrows to one campaign's ideas. Responses echo project { id, name, fromDefault }. |
get_idea | ideas:read | One idea in full: creative brief, its campaign, capture evidence, the idea's media (attached images / a carousel cover with img_… ids, or a reference-only style pick — the display URLs additionally need media:read, §2.4), AI readiness with open questions, attached sources, linked post. Same project echo. |
list_sources | sources:read | Paginated source (transcription) list — previews only; fetch a single source for its topics. |
get_source | sources:read | Single source read. Includes topics[], best-first by score, once status: "Success". Pass a topic id to generate_post (§8); hooks[] is a dashboard artefact and is empty here. |
list_knowledge | knowledge:read | Paginated knowledge-document list visible to a project (workspace docs honour assignment visibility; ephemeral chat uploads never appear). |
get_knowledge_doc | knowledge:read | One knowledge document: metadata, status, tags, chunk count, 2,000-char preview — never the full body. |
get_knowledge_ingest | knowledge:read | Whether an add_to_knowledge_base job is in yet, in the knowledge vocabulary: processing, ready with the kb_… documentId, failed with the error, or cancelled — read from the same job row get_job returns, so the two never disagree. A job of any other type reads as not_found. MCP only. |
search_media | media:read | Search the project's reachable media-library images (owned + profile-assigned, READY only): display URLs, tags, provenance. |
get_settings | workspace:read | Curated project settings: generation-context knobs with word labels (§2.8), project tone of voice, org custom-instruction state, calendar settings including the recurring posting-time template (calendar.schedule, §2.7), content pillars. Structured fields only — never the rendered prompt context. |
get_engagement_policy | workspace:read | Engagement defaults (OFF | REQUEST | ON), per-requester overrides, stored connection state, plan-lock state. Answer from effective, not policy — §1.15. |
get_post_engagements | posts:read + projects:read | One POST's team engagement timeline (the editor's team engagement panel): queued likes/comments/reposts with status + approval state, availableActors — the workspace brands/pages the post could engage from, each with its resolved per-action policy and connection state — and the plan lock. The eng_… row ids and actor ids update_post_engagements takes come from here. projects:read is required on top of posts:read because availableActors is workspace brand/company-page data (ids, names, LinkedIn connection state, engagement-policy states), not this post's content. |
get_draft_run | posts:read | A Scripe DRAFT RUN's progress — the posts Scripe is writing for planned ideas (draft_planned_posts' default mode), the visuals it makes once the user confirmed them (add_post_media) and the team engagement it sets up (set_up_team_engagement): per item queued → writing → polishing → engaging → done (a visual: queued → adding_media → done), or failed with a reason; a done item carries the post id and link, the opening line, the other hook options Scripe generated, whether the voice pass ran, the visual and the engagement added. waitSeconds (≤15) waits for progress. Readable only by the user who started the run, for 24 hours. §2.19. |
get_positioning | workspace:read | The WORKSPACE's positioning, shared by every brand in it: the company document (description, mission, vision, core statements, audience line, promised results, synced website), the target-audience document, and the organization-wide tone of voice with its plan-lock state. Distinct from get_settings, which is per-project. |
list_content_topics | workspace:read | The WORKSPACE's content topics (the dashboard's topic groups / Themen, Settings → Organization → Positioning), each with its wct_… id and the brands it is assigned to (profiles: [{ id, name }]). Membership-level, like the dashboard list; a member sees only the brands they can reach named and the rest as otherProfileCount. Optional profileId filters to one brand's topics. §2.14. |
get_personal_dna | workspace:read | The PROFILE's positioning — the per-project person document (knowledge base → profile folder → Positioning; "You" on the Strategy card): who the author is (intro), credibility points, opinions & hot takes, and stories, list sections one entry per line. projectId optional (default-project fallback, project echo on the response). Per-profile, unlike the workspace-wide get_positioning. The wire name predates the current product term and is kept for connected clients. §2.10. |
list_profile_lists | community:read | The brand's community profile lists — the named watchlists of LinkedIn people whose posts Scripe collects, under Community → Profiles. Returns each list's plist_… id, name, description and member count. projectId optional (default-project fallback, project echo on the response). The only way to name a list; call it before get_profile_list or add_profile_to_list. §2.16. |
get_profile_list | community:read | One list with its members: name, headline, LinkedIn profile URL, follower count, the note whoever added them left, whether they are muted, when Scripe last checked them, and how many of their posts Scripe holds. postsCollected: 0 is an ordinary state, not a failure. §2.16. |
list_post_comments | posts:read | The comments under one of the user's OWN LinkedIn posts, replies nested under their comment, ANONYMISED: each comment's text, createdAt and byPostAuthor (written from the user's workspace), never who wrote it. Name the post by postId (post_…) or linkedInPost (its URL or URN, e.g. a list_post_analytics row's linkedin_post_id). Every page re-reads LinkedIn live; a comment that read did not return is counted in notAvailable, never sent from Scripe's stored text. Paid plans (the comments inbox). §2.22. |
list_labels | posts:read | The workspace's labels (Settings → Labels): lbl_… id, title, colour, description and label group (lblg_…). §2.17. |
list_posts_with_label | posts:read | The posts carrying one label, newest first, across every profile the user can access (projectId narrows to one): state (draft | scheduled | published), dates, an opening-line excerpt. hiddenProfileCount counts profiles with matching posts the user cannot see. Paginated with nextCursor. §2.17. |
list_campaigns | calendar:read | The workspace's campaigns: cmp_… id, emoji, title, status, dates, the profiles each is narrowed to (none = workspace-wide), labels, post count. Optional status filter. §2.17. |
get_campaign | calendar:read | One campaign in full: summary and brief (description), labels, each narrowed profile's progress exactly as the home page's campaign tile shows it (posted | scheduled | inProgress | missing) and the campaign's posts (posts in profiles the user cannot access are counted in hiddenPostCount, never shown). §2.17. |
list_content_questions | ideas:read | One idea's content questions (the idea page's Questions rail): cq_… id, text, status (draft | scheduled | sent | answered), who it is for. projectId optional; project echo. §2.17. |
get_content_question | ideas:read | One content question with its recipients (cqr_… ids, status, last reminder) and every answer. Never an email address or an answer-page token. §2.17. |
list_for_you_ideas | ideas:read | The "For you" suggested ideas (Explore → For you) of one profile, or — projectId omitted — of every profile the user can access: fyi_… id, title, hook, angle, lane (REACH | TRUST | CONVERT), format, origin, a one-line whyPicked, the profiles it suits, and the board ideas already saved from it. Filters lane, sourceType, postType, query, saved; paged with cursor. §2.18. |
get_for_you_idea | ideas:read | One For You idea in full: its argument with numbered markers, every source it was built on (URL or kb_… id, excerpt, and the words of the argument each backs), the origin item and why it was picked. §2.18. |
get_goals | calendar:read | The posting goal and progress the dashboard's goal tile shows, per profile (or every reachable profile plus per-lane totals when projectId is omitted): the period (the workspace's planning cycle, in the profile's timezone), weekly goal, lane mix, and per lane target / done / to do / drafts waiting. period: previous | current | next, resolved by the same planning-range rule get_planning_overview and list_calendar_slots use, so the three agree on what "this period" is. §2.18. |
list_calendar_slots | calendar:read | A profile's PLANNED posting slots ahead (default the next 14 days; from / to / days ≤31 days, or instead a goal period — current | next | previous — or weeksAhead 1–13, read from the range's first day that can still take a post): each slot's instant (at), day, time, lane, pillar hint and state (empty | planned | draft | scheduled | published) with the post in it, or the idea reserving it (planned, only while the planning autopilot is on for the caller — §2.19); onlyEmpty: true returns just the slots to fill. §2.18. |
get_planning_overview | calendar:read | The whole workspace calendar for planning, in ONE call: for every profile the user can reach (or the profileIds named), over one range (period — current by default, next, previous — or weeksAhead, or from / to), the goal per lane, the planned slots (the open ones with their at and lane, the ones an idea already reserves, drafts, scheduled, published), the idea stock per lane, LinkedIn health and gaps; plus the goal-period length, whether posts need review, campaigns in range and what waits for the caller. detail: "full" lists every slot. §2.19. |
get_profile_planning_context | calendar:read | The planning bundle for one profile or up to 10 (profileIds) in ONE call, bounded: per profile its positioning ("You"), its target audience, tone of voice, content topics, post history (recent openings, best posts and the lane mix over 90 days; performance numbers are get_planning_signals'), knowledge folders and newest documents (meetings in range first), campaigns in range with their brief, and its idea stock; once for all, the company positioning and the post format keys per lane. projectId optional (default profile); each bundle names the profile it read. §2.19. |
get_planning_signals | calendar:read | What is happening around up to 10 profiles (profileIds; omitted = every profile the user can reach) in ONE bounded call: niche — recent LinkedIn posts by people like each profile that beat their own author's usual engagement (outlierMultiple), each listed once with the profiles it is relevant to; sourceItems — what the profiles' followed sources published since sourcesSince (default 7 days), with each profile's match and the For You idea it became; per profile performance — the one source of performance numbers (90 days, by post format with lane, lane, media format and content topic, each with a lift against the profile's own median) and idea stock (forYou with why each was picked and its source, board); knowledge — knowledge-base documents added in the last knowledgeDays (default 30), each with its opening and the profiles that can use it; knowledgeSummaries — per profile the most relevant document Scripe already summarised (chapters or a meeting's own summary; no model call). Ranks and chooses nothing. §2.19. |
check_plan_ideas | ideas:read | The plan-quality check of preview_plan on a DRAFT plan of up to 200 ideas (projectId, title, optional angle, hook, label): the items whose angle repeats another's — within a profile or across profiles — or that are only a title, each named with the warning the preview would give. For a plan longer than one preview (60 items), checked as a whole before it is previewed in parts. Reads and writes nothing; no model. §2.19. |
search_knowledge | knowledge:read | A profile's knowledge base by MEANING: the passages most relevant to query (≤10), each naming its kb_… document. Narrow with folderId or documentIds. projectId optional (default profile); a folder- or document-narrowed read reaches every profile the user can reach. MCP only. §2.19. |
read_knowledge_document | knowledge:read | One knowledge document's text in full, in order, up to maxCharacters (default 12,000, max 24,000; truncated says when cut) — where get_knowledge_doc stops at a 2,000-character preview. projectId optional. MCP only. §2.19. |
list_waiting_for_me | posts:read | What waits on the CALLER: posts they are the assigned reviewer of (still in review, not on LinkedIn) and content questions sent to them and unanswered, each with a url into the Scripe web app. kind: all | reviews | questions; limit ≤50 per list (default 20). Never approves. §2.19. |
get_icp | workspace:read | The WORKSPACE's Ideal customer (ICP) the people who engage with its profiles are scored against; with projectId, also what that profile is scored against (its own, the workspace's, or none). Signals pilot only — §2.20. |
get_usage | workspace:read | Usage and limits: the weekly AI budget as a percentage, storage, today's API AI-spend cap, and canGenerate — whether the next AI job would be accepted at all. The pre-flight for both 402s (§1.16). |
get_analytics_overview | analytics:read | Current vs previous-period rollup (posts, impressions, engagement rate, follower growth, activity). |
list_post_analytics | analytics:read | Per-post LinkedIn metrics (views, likes, comments, shares, engagement rate) for posts already live on LinkedIn, newest first. Offset-paginated. For one specific post pass postId (§1.10) — never scan a date window. Body is an excerpt by default (content modes). |
search_viral_posts | analytics:read | Semantic search over the inspiration feed for high-performing posts on a topic (excludes your own author). Outliers only by default — posts that beat their own author's median by 2x or more — in the project's language, above minEngagement, inside publishedWithinDays (90 by default), optionally narrowed to a media shape with mediaFormats (§1.11). |
get_linkedin_performance_insights | none | What measurably performs on LinkedIn, from Scripe's LinkedIn Trend Report 2026 (public edition, linkedin-trends-2026.vercel.app): pass the user's ask as question ("make this post go viral", "best hook for a new-job post") or up to three topics, and get back only the relevant sections (findings with their figures, what to do, caveats) plus how to apply them. Every figure compares an author with their own usual post. Static text, no workspace data, no model; any token may call it. |
get_analytics_report | analytics:read | Full report as JSON: executive summary + earned media value, per-author performance, the period's top 20 posts by impressions (postsTotal counts every post; list_post_analytics pages the rest), daily engagement series and the three click figures (§1.12), follower timeline. (REST's GET /v1/analytics/report renders the same figures as PDF/CSV with the period's posts, row-capped — see Analytics.) |
get_cross_workspace_analytics_overview | analytics:read + workspace:read | The same rollup as get_analytics_overview, for EVERY workspace the connection can reach, in one call — the tool for "how are all my clients doing", instead of a switch_workspace loop. Each row names the workspace it belongs to and the grant that reaches it; data.totals is one query over the union, not a sum (§1.17). |
get_cross_workspace_analytics_report | analytics:read + workspace:read | The cross-workspace twin of get_analytics_report: one whole report per reachable workspace. Visits fewer workspaces per call than the overview and keeps fewer top posts each, because it returns a document per workspace (§1.17). |
get_posting_times | analytics:read | The project's own best posting times (hourly + weekday×hour buckets), on the project's calendar clock by default and carrying only the slots it has actually posted in (§1.14). Plan-gated — lower plans get plan_not_eligible with an upgrade pointer. |
list_jobs | jobs:read | List async jobs (paginated). |
get_job | jobs:read | Single job read with progress snapshot. |
list_tracked_links | analytics:read | The workspace's tracked links, newest first: short URL, destination, name, profile, and clicks — every non-bot click attributed to the link, the number Settings → Links shows — with each placement (post, team-engagement comment, profile) and the clicks collected there. Cursor-paginated. Business plan (plan_not_eligible below it). MCP only. §2.21. |
The subsections below are the read contracts an integrator (or an agent) needs to answer a user correctly. Each is shaped around the ask it serves.
1.1 A post's status: two vocabularies, one truth
Every post row carries several status fields, because Scripe has two vocabularies for the same thing:
| Field | Vocabulary | What it is |
|---|---|---|
status | lifecycle enum | waitingProcessing | draft | waitingApproval | approved | rejected | published | scheduled | suggested |
statusCategory | kanban | suggested | draft | inProgress | review | scheduled | published — what list_post_statuses returns |
statusId | kanban | The workspace column the post sits in; update_post moves a post to it and list_posts({ statusId }) filters by it |
statusTitle | kanban | Its human name, e.g. Good to go |
publishedAt | timestamp | When the LinkedIn copy went live — the only field that dates a publish (§1.9) |
The truth is the custom status (statusId), because that is what
the product writes: publishing sets it in one indivisible UPDATE and
the scheduler does the same. status is derived from that status's
category, and only falls back to the stored column for a post that
carries no custom status at all. (Until 2026-08 this field served the
stored column directly, and nothing had written published to it since
2025-12 — so every post published in that window read back draft, and
list_posts({ status: "published" }) answered an empty list.)
Two consequences worth knowing before you answer a user:
draftcovers two columns.draftandinProgressboth render asstatus: "draft", so a workspace with an "In Progress" and a "Good to go" column reports both as drafts. ReadstatusTitlewhen the user asks where a post is.- Filter on the axis you mean.
statusfilters the lifecycle value (anddrafttherefore also returns theinProgresscolumns);statusCategoryfilters the board column and is the only way to ask forinProgressorreview. Both match through the custom status, so a filter and a read can never disagree.
1.2 "Did my post go out?" — read delivery, never infer from status
A post the publisher has given up on keeps its scheduled status
forever. The publish cron picks up a post whose scheduledAt is in the
past and within the last 24 hours; outside that window it is
skipped on every tick from then on, and nothing in the row changes. So
two posts can be byte-identical on status + scheduledAt while only
one of them will ever publish. Every post read therefore carries a
delivery block with the verdict:
get_post({ postId: "post_9f4a…" })
→ { "data": { "status": "scheduled",
"scheduledAt": "2026-08-11T06:00:00.000Z",
"lastPublishError": "LinkedIn access for this brand was revoked, so this post was not published. Reconnect your LinkedIn account to resume publishing.",
"delivery": {
"state": "missed",
"willRetry": false,
"retryUntil": "2026-08-12T06:00:00.000Z",
"detail": "This post never went out. The last attempt failed: … The scheduler stopped retrying at 2026-08-12T06:00:00.000Z (24h after the scheduled time). Fix the cause, then re-schedule it with schedule_post." } } }state | Means | Will anything try again? |
|---|---|---|
not_scheduled | In a non-scheduled column. A scheduledAt here is a leftover intention. | No |
scheduled | Future time, armed. | Yes |
due | Time has passed, inside the retry window — publishing now, or failing and retrying. | Yes |
missed | Past the window (or no time at all), or stopped by a failure no retry can fix (an image LinkedIn refused, media that could not be fetched) until the post is edited or rescheduled. Nothing will pick it up again on its own. | No |
published | It went out. | — |
- Never report a post as "on its way" from
statusalone. On production, 145 posts across 44 projects have been sittingscheduledand unsent for more than a day; 41 of them recorded no error at all, so the row contains no other evidence that anything went wrong. delivery.detailis the sentence to relay. It names the failure and the repair.lastPublishErrorbeside it is the raw message from the last attempt — useful, but not written for a user.retryUntilis the deadline. Whilestateisduethe user can still fix the cause (usually reconnecting LinkedIn) and the post goes out by itself. After it, onlyschedule_postwill.- Find them in one call:
list_posts({ delivery: "missed" }). The five states partition the project, so an empty page really does mean nothing is stuck. - Then ask why. 90 of those 145 stranded posts sit on a project whose LinkedIn authorization was revoked — §1.4.
1.3 "What's waiting for my approval?" — read review, never the board column
Every post read carries where the post stands in the approval workflow,
and list_posts filters by it:
list_posts({ review: "pending", content: "none" })
→ { "data": [ { "id": "post_…", "statusCategory": "review",
"review": {
"state": "pending",
"awaitingDecision": true,
"reviewerUserId": "user_33O0iUlcQyhyVo5FJG3mIcZ0y2d",
"deadline": "2026-07-30T22:00:00.000Z",
"overdue": true,
"decidedAt": null,
"notes": null,
"requiredByWorkspace": false,
"detail": "Waiting for a decision from user_33O0… , who was asked to review it by 2026-07-30T22:00:00.000Z — that deadline has passed. Approvals happen in the Scripe dashboard; nothing on this API can decide it." } } ],
"pagination": { "total": 1, … } }state | Waiting on someone? | Means |
|---|---|---|
not_requested | no | no review is open at the post's current board column |
pending | yes | a reviewer was asked and has not decided |
approved | no | signed off — it does not move on by itself |
rejected | no | sent back; notes carries the reason when one was given |
- A
reviewboard column is a different question. Across production those columns hold 1,018 posts: 231 awaiting a decision, 104 already approved, 11 sent back, and 672 nobody was ever asked to look at. SostatusCategory: "review"answers "what needs my approval?" 22.7% correctly. Filter byreview: "pending"(the two combine). - A review belongs to a STAGE, not to a post. A post keeps the
review rows of the columns it passed through — 2,144 of production's
2,490 are at a stage their post has left, 313 of them still
PENDINGon an already-published post. Only the row for the post's current column is reported here, which is why a published post readsnot_requested. reviewerUserIdis a Clerk user id.list_teamreturns the same id asuserId— resolve it there rather than showing the raw id.- Nothing on this surface can decide a review. Approving and
rejecting happen in the dashboard;
detailsays so on every state that is waiting on a person. requiredByWorkspacecan be ENFORCED on this surface (since 2026-09; it mirrorsget_workspace_context.requireReviewWorkflow). Two things have to be true before it refuses anything, andrequiredByWorkspace: trueon its own is only the first: the workspace must have set the flag AND be on a plan that includes content approvals (Advanced and Business among the plans sold today). Approving and rejecting are themselves plan-gated features, so in a workspace below that plan the flag is inert — nothing on this surface refuses, because the approval a refusal would send the user to get cannot be produced there at all. Do not branch onrequiredByWorkspacealone: treat it as "this workspace may refuse an unreviewed post" and handle the409when it comes, rather than pre-emptively blocking a schedule the API would have accepted. Where the gate does bind,schedule_postandpublish_postrefuse a post without an approved review with409 conflict(details.reason:review_not_requested/review_pending/review_rejected;details.reviewerUserIdis always present — the Clerk id of the reviewer on the post's current-stage review when one exists, pending or rejected,nullotherwise — and the message itself never shows the raw id, so resolve it vialist_team). A post the caller could not schedule anyway answers the ordinarynot_found/forbidden_project, never a review state.create_post_draft/generate_postrefuse a non-nullscheduledForoutright there — a post that does not exist yet cannot have been reviewed, so create the draft first and schedule it after approval — and accept it normally in a workspace the gate does not bind on. A review approved at an earlier board column still counts after the post moves on (the dashboard's approve-then-move flow). Approvals themselves still happen only in the dashboard. The REST write paths (POST /v1/posts,PATCH /v1/posts/:postId) deliberately do NOT enforce it — see Posts § review.
1.4 "Is my LinkedIn still connected?" — the linkedIn block
Every project read (get_project, list_projects,
get_workspace_context) carries the publishing precondition:
get_project({})
→ { "data": { "id": "proj_…", "name": "Luke Matthews",
"linkedIn": {
"state": "revoked",
"canPublish": false,
"connectionStatus": "REVOKED",
"lastValidatedAt": "2026-06-02T08:11:00.000Z",
"accessTokenExpiresAt": null,
"detail": "Scripe's LinkedIn authorization for this project was revoked on 2026-07-30T08:00:00.000Z: … Nothing will publish as this project until someone reconnects LinkedIn in Scripe. Reconnecting cannot be done through the API.",
"reconnectUrl": "https://app.scripe.io/oauth?projectId=…&reconnect=true" } } }state | Publishes? | Means |
|---|---|---|
connected | yes | a token is on file and nothing is wrong |
expiring_soon | yes | the access token lapses within 14 days (or already has) and has not been renewed |
degraded | yes | recent LinkedIn calls failed; three consecutive failures revoke the connection |
disconnected | no | no usable token — never connected, or the token was cleared |
revoked | no | the authorization was withdrawn |
- Branch on
canPublish/state, never onconnectionStatus.connectionStatusis the stored health cache. On production it readsCONNECTEDfor 38 accounts that hold no access token at all, andUNKNOWNfor 15,352 that were simply never connected.stateapplies token presence first. - This read never probes LinkedIn. It reports stored health as of
lastValidatedAt. The live check runs insideschedule_postandpublish_post, where a write is about to happen — the proposals return it (§8). - You cannot fix it. Reconnecting LinkedIn is an OAuth flow a human
performs in the dashboard;
reconnectUrlis where. Relay it rather than retrying — no call on this surface can succeed until they do. - A company page has no connection of its own. It publishes through
the personal account it was linked to, so its
linkedInblock (and itsreconnectUrl) describe that account.nullmeans the page was never linked to one, which is itself the answer.
1.5 "Find my post about X" — use q, never page the project
list_posts and list_notes take a free-text q. Use it for any ask
whose subject is what the content is about.
| Fields searched | posts: body, title and hook. notes: the body. |
| Matching | case- and accent-insensitive substring |
| Multiple words | AND-ed — every word must appear somewhere in the row |
"quoted phrase" | matched whole |
% and _ | matched literally; they are not wildcards to the caller |
| Ordering | unchanged: newest-first. q narrows the page, it does not rank by relevance |
| Limits | 200 characters, 6 terms; a longer query is a 400 invalid_request, never a silent truncation |
- An empty result means the words are absent, not the topic. This
is a literal substring match with no stemming and no synonyms:
pricingdoes not findpriced. Retry with fewer or simpler words before concluding anything. - Paging is not a substitute. A project's posts run into the
thousands; hundreds of production projects hold more than the 200-row
maximum page, so "read them all and look" cannot terminate. If
qreturns nothing, say so.
Not searchable this way: knowledge-base documents (semantic search
only, through the dashboard's assistant), ideas, and the calendar.
search_media searches the image library and search_viral_posts
searches other people's posts — neither looks at the user's own
writing.
1.6 "How many …?" — read pagination.total, never add up pages
list_posts and list_notes report pagination.total: how many rows
the filter matches, across every page.
list_posts({ projectId, status: "draft", limit: 1 })
→ { "data": [ /* 1 row */ ],
"pagination": { "next_cursor": "eyJ…", "has_more": true, "total": 128 } }limit: 1is the right shape for a counting ask — you want the number, not the posts.- The count excludes the cursor, so it is the same number on page 1 and page 4. It is not "rows remaining".
- It counts what you filtered by, including
q. - Never report the page size as the total — paging to find out costs every post body in the project (measured: 3 calls and ~52k tokens on a 128-draft project; the largest production project holds 7,874 posts).
The other list tools do not report a total yet — has_more is still
the only signal there.
1.7 A list row's content is an EXCERPT — get_post has the post
list_posts serves ~280 characters of each post body, cut on a word
boundary, with contentTruncated: true beside it. A production post
averages 1,078 characters, so a default page of 50 whole bodies is
~54,000 characters — roughly 13.5k tokens spent on "show me my drafts".
- Never quote a row whose
contentTruncatedis true back to the user as their post. It is an opening, not the text they wrote. - To read one post, call
get_post— it always returns the whole body and costs one row. content: "full"returns whole bodies for the page; pair it with a smalllimit.content: "none"omits the body entirely, which is the right shape for "what's in my review column".- When you pass
q, the excerpt is centred on the first matching term, so the row shows why it matched. A row whose excerpt does not contain your search word matched on its title or its hook.
list_post_analytics uses the same three modes with the same default.
1.8 "What's in my pipeline?" — one call, then open the column
list_post_statuses is the board: every kanban column the workspace
runs, with postCount — how many posts sit in it right now — plus a
project rollup:
list_post_statuses() // projectId defaults, like every read
→ { "data": [ { "id": "7cae…", "title": "Draft", "category": "draft", "postCount": 126 },
{ "id": "22e0…", "title": "Good to go", "category": "inProgress", "postCount": 1 } ],
"counts": { "projectId": "proj_…", "total": 657,
"byCategory": { "suggested": 493, "draft": 126, "inProgress": 3,
"review": 4, "scheduled": 0, "published": 31 },
"uncategorized": 0 } }- One call answers the ask — not
list_postsonce per category. counts.byCategory[c]is the same numberlist_posts({ statusCategory: c, limit: 1 })reports aspagination.total.counts.totalalways equals the sum ofbyCategoryplusuncategorized(posts whose column was deleted).- Without a
projectId(REST callers only — the MCP tool defaults it) everypostCountisnullandcountsisnull: statuses are workspace-wide, posts are not. - Then open the column the user named:
list_posts({ statusId: "22e0…", content: "none" }). A category is not a substitute — a workspace can run several columns inside one category. An id the workspace does not own is refused with a400 invalid_request, never answered as an empty column. The sameidis whatupdate_post({ statusId })moves a post to.
1.9 "What did I publish last week?" — a different date from dateFrom
A post has two dates, and they are days apart: createdAt is when the
draft was written, publishedAt is when the LinkedIn copy went live.
On production the gap averages 6.3 days, and 51% of the posts
published in a given week were drafted before that week began. So
dateFrom/dateTo — which filter the CREATION date — cannot answer
"what did I publish last week". Use the publication window:
list_posts({ publishedFrom: "2026-08-01", publishedTo: "2026-08-08", content: "none" })publishedAtis the only field that dates a publish.scheduledAtis what somebody asked for and stays set on posts that never went out;updatedAtmoves on any edit.- For a post Scripe published,
publishedAtis set at publish time — the publish commit — and later refined to LinkedIn's own time when the sync lands. It is never guessed from the schedule.publishedAt: nullon a published post means there is no measurement row for it: the publish-time placeholder was not written (e.g. no LinkedIn author to attribute it to), or the post was removed at the source. publishedFrom/publishedToonly ever return published posts, and combine with every other filter.- The page is still ordered newest-DRAFTED first. Sort by
publishedAtyourself before narrating "my last three posts".
Then ask how it did. list_post_analytics measures the LinkedIn
side, and its dateFrom/dateTo are publication dates too, so the
same window lines the two up. Every measured row carries post_id —
see §1.10.
1.10 "How did THIS post do?" — pass postId, never scan a window
list_post_analytics describes posts that are live on LinkedIn.
That is not the same object as the Scripe draft it may have come from,
so a row carries three ids and only one of them is actionable:
| field | is | accepted by |
|---|---|---|
id | the analytics measurement row | nothing |
post_id | the Scripe post, post_…, or null | get_post, update_post, schedule_post, publish_post, delete_post |
linkedin_post_id / permalink | the LinkedIn activity URN and its URL | LinkedIn itself |
post_id is null for any post not published through Scripe —
imported history, posts written directly on LinkedIn. That is most of a
mature account's back catalogue, and it is the honest answer rather
than an error. Use permalink to show the user the post, and content
(with content: "full") as source text for a generate_post rewrite.
When the user asks about one post, pass the id:
list_post_analytics({ postId: "post_0bf5a28fe9155883" })
→ { "data": [ { "post_id": "post_0bf5…", "permalink": "…",
"posted_at": "2026-08-03T06:00:15.000Z",
"metrics": { "views": 1254, "likes": 27, "engagement_rate": 2.95, … } } ],
"pagination": { "total": 1, "has_more": false },
"meta": { "requested_post_ids": ["post_0bf5…"], "unmeasured": [] } }- One call, one row, ~330 tokens — versus ~12k tokens per date-window
page, and a window leaves you guessing for a post published minutes
ago, which is in the window but carries no numbers until LinkedIn's
sync lands. The id says so instead, in
meta.unmeasured. - An array works too, up to 50 ids.
metaappears only whenpostIdwas passed, and it is what makes an empty answer readable:meta.unmeasuredsays which named post has no numbers and why —not_published(never went out) ornot_measured(published, but LinkedIn's sync has not landed; it usually does within the hour).- An id the project does not contain is a
400, not an empty page. sortdoes not rank by rate.impressionsranks by views andengagementby total interactions; if the user meant rate, sort, then comparemetrics.engagement_rateyourself.
1.11 What search_viral_posts searched, and what its numbers mean
"Viral" here means an outlier, not a big number. Every post in
data beat its OWN author's median engagement by at least
meta.min_outlier_multiplier (2 by default), so a large account's
ordinary post is not reported as a hit. outlier_multiplier on each
post is that ratio and is capped at 10 — a 10 means "at least 10x".
The search is bounded before it ranks: only posts published within
meta.published_within_days (90 by default, max 180), in the resolved
meta.language, above meta.min_engagement (100 by default), in any
meta.media_formats that were asked for, excluding the project's own
author. A short or empty data may therefore be those filters at
work rather than a topic nobody writes about.
meta.narrowed_tois present when you narrowed beyond the default and the answer is short, and it names the three numbers to widen. One of those filters may be why: offer to relax one rather than reporting the topic as uncovered, and do not assert the cause — the search cannot tell a thin topic from a filtered one. The recoveries are a lowerminOutlierMultiplier(0 opts out of the outlier floor entirely and serves every post aboveminEngagement, scored or not), a widerpublishedWithinDays, nomediaFormats,language: "all", or a lowerminEngagement.mediaFormatsis how you ask for a shape.carouselis a swipeable deck (a document post),multi_imageseveral photos in one post,imageat most one; thenvideo,text(no media),article(a shared link) andother. A deck is a small share of the corpus, so expect a shorter answer there than for an unfiltered search.- An
outlier_multiplierofnullmeans NOT SCORED, never "average" — the post is too fresh, below the scoring floor, or its author has no usable baseline. It is only reachable withminOutlierMultiplier: 0. Onlyoutlier_baseline: "AUTHOR"supports a sentence about that author's own usual performance;CORPUScompared the post to a corpus median instead. meta.returnedis how many posts came back, capped bylimit(default 12, max 30). There is no paging and no corpus-wide total.metrics.viewsisnull, not0, when unknown. Only a post's own author can read its impressions, so LinkedIn withholds the count for roughly 97% of third-party posts. Report anullas unknown.metrics.reactionsdoes not have to add up tototal_engagement, because LinkedIn reports reaction types it does not break out.- Two of the three ids are links, and the third is not usable.
permalinkopens the post,author.profile_urlopens its writer, andidis the internal feed row — no Scripe tool accepts it. To act on an angle you liked, feed the post'scontenttogenerate_postas context.
1.12 The two engagement rates and the three click figures in get_analytics_report
daily[] is one row per day in the range, zero-filled, and every
figure in a row is that day's own — including engagementRate,
which is that day's engagement over that day's impressions and is
therefore 0 on a day with no impressions.
cumulativeEngagementRate is the separate running figure: the rate
from the start of the range through that day — what the in-app KPI card
plots, deliberately carrying forward across quiet days.
Read engagementRate to compare days, cumulativeEngagementRate for a
trend line. Reading the running rate as a per-day figure is how a month
with four posts appears to have had engagement on all thirty-one days.
The CSV rendering of GET /v1/analytics/report carries both columns;
the PDF's Daily Metrics table shows the per-day rate only.
get_analytics_report's posts[] carries the same postId as
§1.10, plus a ~100-character content excerpt with
contentTruncated: true. The list is the genuine top 20 of the whole
period, ranked by impressions in the database; postsTotal counts
every post in the period and postsTruncated says the list is capped —
page the full set with list_post_analytics.
summary carries three click figures from two sources, and the
dashboard never adds them up — report each under its own name:
| Field | Source | Attributed by | Dashboard surface |
|---|---|---|---|
totalLinkClicks | LinkedIn LINK_CLICKS — clicks on any link inside a post | the post's publish date | the Link clicks (LinkedIn) KPI card; part of the funnel's Clicks on LinkedIn stage; posts[].linkClicks per post |
totalTrackedLinkClicks | Scripe click tracking — the workspace's tracked links | the click's own date, workspace-wide (tracked links are workspace-owned, so projectId does not narrow it) | the clicks tracked by Scripe line under the conversion funnel, and the Click tracking section |
totalPremiumCtaClicks | LinkedIn PREMIUM_CTA_CLICKS — the profile's Premium button | the post's publish date | the Premium button clicks (LinkedIn) card; part of the funnel's Clicks on LinkedIn stage; posts[].premiumCtaClicks per post |
The funnel's Clicks on LinkedIn stage is totalLinkClicks + totalPremiumCtaClicks; the clicks tracked by Scripe line under it is
totalTrackedLinkClicks and nothing else. Never add the Scripe figure to
the LinkedIn ones: no click carries an id both sources share, so a sum
would count a click both measured twice (SCR-1795).
totalTrackedLinkClicks is null only when a report is built without
a workspace, which never happens on this surface.
1.13 "When's my next slot?" — say localTime, and ask again for the next one
get_next_free_slot is the one tool whose answer IS a time said out
loud, so it hands back the resolved wall clock rather than the inputs
to a conversion:
| Field | Meaning |
|---|---|
iso | The slot as an absolute UTC instant. |
timezone | The IANA zone the local fields are in. |
date / time | The slot on the project's own clock — 2026-08-15, 09:00. |
utcOffset | That zone's offset at this instant, e.g. +02:00. |
localTime | Sat, 15 Aug 2026 09:00 (Europe/Berlin) — quote this. |
source | template = a configured posting-time slot. fallback = the project has no posting schedule (or none of it was free), so 09:00 on the first open day is a suggestion; say so. |
- Do not convert
isoyourself. Projects that never picked a calendar timezone store the legacy"CET"— an abbreviation that names UTC+1 but observes EU summer time, so a naive conversion is an hour off half the year.timezoneis therefore always reported as the IANA name the stored zone is equivalent to. - "Free" is checked, so ask again for each post. A slot is free when no post is scheduled at that exact minute and nothing else on the calendar — a queue slot, a note, an idea placed on the board — reserves that day. Schedule the first post into the returned slot, then call again and it returns the next one.
1.14 Which clock get_posting_times answers on
An hour is not a value on its own — the same posts land on a different hour in every timezone — so the response always names the zone it used:
| Field | Meaning |
|---|---|
timeZone | The zone the hourly / weekly buckets are expressed in. |
timeZoneSource | project when it came from the project's calendar setting (the default), caller when the timeZone argument chose it. |
The default is the project's calendar timezone — the same zone
list_calendar renders days in and get_next_free_slot reports. Quote
timeZone whenever you name an hour back to a user. Pass timeZone
only to deliberately answer for a different audience; it must be a full
IANA name, and a name the server cannot resolve is rejected with
invalid_request rather than silently answered in UTC.
An absent hour was never tried. Only slots with count > 0 are
returned — a bucket the project has never posted in carries no
measurement, so serving it as {count: 0} would claim the hour
performs badly. Say it is untested; and when totalPosts is small
(below roughly three posts per slot), say the signal is thin rather
than naming a best time.
1.15 "Is anything engaging as me?" — read effective, not policy
get_engagement_policy returns two shapes of the same three actions,
and only one of them answers the question:
| Field | Meaning |
|---|---|
policy | The stored EngagementPolicy row, or null when the project has none. |
effective | What actually happens right now, always present, plus source. |
policy: null is the normal state — roughly 98% of production
projects have no row — and it is not "unconfigured, so anything might
happen": activation fails closed, so an actor with no policy row is
declined. effective reports that as all-OFF with
source: "no_policy", versus source: "policy" for stored defaults.
Answer the user from effective and never infer from a null.
The distinction is load-bearing. "Never answered" (no_policy) can
still be seeded with the entitled defaults when the workspace upgrades
its plan or reconnects LinkedIn; "answered, and said no" (a stored
all-OFF row) may never be promoted back by anything. That is why
update_engagement_policy writes no row when it disarms a project
that has none: the ask is already true, and the placeholder would
silently remove the project from seeding forever. The proposal says
exactly that in its effect field before the user confirms.
One direction only: the write can lower a default to OFF or
REQUEST, never raise one to ON. A project you disarm here cannot be
re-armed here — that is a dashboard action, and the proposal says so.
1.16 "Can I still generate posts this week?" — ask before you spend
Two 402s can refuse any AI verb (generate_post, generate_image,
generate_carousel, add_to_knowledge_base, create_source_file):
| Error | Limit | Resets |
|---|---|---|
usage_limit_exceeded | The weekly AI budget — the primary limit | Monday 00:00 UTC |
spend_cap_exceeded | The daily API AI-spend cap — an abuse valve | 00:00 UTC |
get_usage reports both before either fires — full field reference on
Usage and limits.
- Read
canGeneratefirst. It isfalseexactly when the next AI job would be refused, andblockedBynames which limit. Deriving it from the meters is easy to get wrong twice over: a meter at 100% only blocks whenai.enforcementisblock, and the daily cap compares spend + the job's estimate, so 2¢ of headroom is already blocked for a 5¢ post generation. - Before a batch, check once. Ten generations that die on the sixth leave the user with half a plan and no explanation.
- AI is a percentage, never money. The budget is denominated in provider cost, so the meaningful figure is how much of the allowance is gone. Storage is bytes; the daily cap is cents.
- A company page has no budget of its own — it spends from the
workspace pool, which
ai.scopenames.
1.17 Analytics across every workspace, in one call
Every other tool runs against one active workspace, and that is
structural: switch_workspace moves the anchor, it does not widen it,
and a project id from another workspace answers not_found on purpose.
So "give me the numbers for all my clients" used to be a loop the model
had to invent — switch_workspace → list_projects →
get_analytics_overview, per client, then add the figures up in prose.
Nothing told it to run that loop, so it usually answered from the
default project and stopped.
get_cross_workspace_analytics_overview and
get_cross_workspace_analytics_report do the loop server-side. Neither
takes projectId — they aggregate every project you can read in each
workspace, which is the question they exist to answer — and neither
requires (or performs) a workspace switch.
Reading their answers correctly:
- Every row is attributed.
data.workspaces[].workspacecarries theid, the displayname,is_default, andreach:member(Clerk membership) oragency_owner(the workspace is billed to an agency the user owns — the grant the dashboard has always applied). Name the workspace beside every figure you report. - Read
data.totals, never a sum of the rows. It is a fresh query over the union of every project in the response, because an engagement rate has no meaningful average. When it isnull, report no total at all —meta.totals_omitted_reasonsays whether there was nothing to total or the union was too large for another pass. meta.skippedis not zero engagement. It names reachable workspaces in which the caller can read no projects. That is missing visibility, not a quiet client.meta.workspaces_reachableis your full reach, on both selectors — not the count you named, and not a truncation signal. On an explicit selection it normally exceedsmeta.workspaces_returnedwhilemeta.truncatedis false; readtruncatedfor that.meta.truncatedmeans the response holds the firstmeta.max_workspacesreachable workspaces. Say so, and name the remaining workspaces explicitly inworkspaceIdto cover them — at mostmeta.max_workspacesper call, since an explicit list longer than the cap is refused rather than truncated (see Analytics).meta.projects_truncatednames any workspace holding more readable projects than one call measures, whose row — and its share ofdata.totals— therefore covers only its newest projects. Empty when every workspace fitted; measure those workspaces throughget_analytics_overviewwith explicitprojectIds.- A named workspace you cannot reach is refused
(
workspace_unavailable), never dropped — so a selection you asked for is either answered or reported, never silently narrowed. - The report variant is larger by design. Each entry's
report.posts[]holds only the topmeta.posts_per_workspaceof the period whilereport.postsTotalcounts them all; page the rest withlist_post_analytics, one workspace at a time.
The single-workspace tools are unchanged: use get_analytics_overview
for one workspace or for specific projects.
2. Write tools
| Tool | Scope | Notes |
|---|---|---|
switch_workspace | workspace:read | Sync. Moves the connection's active workspace (mutates connection state, not data — idempotent). See MCP § Workspaces. |
create_note | notes:write | Sync. Optional date places the note's calendar slot. |
update_note | notes:write | Sync. Edit body and/or move between folders (folderId: null = root). Calendar placement is not writable here. |
delete_note | notes:write (archive) / notes:destroy (permanent) | DEFAULT is the reversible archive — no confirmation. permanent: true removes the note and its paired calendar slot forever; that path is two-phase and requires notes:destroy. |
create_post_draft | posts:write | Sync. Pass a future scheduledFor to schedule, omit for an unsent draft. With scheduledFor set, the LinkedIn connection is verified first — a dead connection fails with conflict (details.reason + reconnectUrl) and creates nothing — and a workspace that requires review refuses the create-and-schedule shortcut with conflict (§1.3): create the draft, get it approved, then schedule_post. |
update_post | posts:write | Sync. Edit content/title/contentType, set status (statusCategory/statusId), unschedule via scheduledFor: null. Setting a schedule lives on schedule_post — a non-null scheduledFor here returns a teaching error. |
schedule_post | posts:write | One call, live. Schedules the post on the first call — no proposal, no token (since 2026-10-16) — and the post then goes live on LinkedIn by itself ~2 min after the scheduled time; the result's notice says so for the user, with how to undo it. The time is resolved in the project's timezone (§8, "Schedule") and a live LinkedIn pre-flight runs before anything is written. Re-scheduling re-arms configured team engagement against the new time; a planned draft (dated, still in a draft or review status) arms none — and asks no teammate for approval — until it is scheduled. In a workspace that requires review, a post without an approved review is refused with conflict (§1.3); a workspace with no plan that can publish is refused with plan_not_eligible. |
attach_media_to_post | posts:write + media:read | Full-state media write onto a post — §2.4 for the two accepted branches. kind: "none" clears. |
create_media_asset | media:write | Sync. The customer's OWN image into the project's media library, from inline content_base64 (small files, ≤ ~3 MB decoded; optional sha256 integrity check) or a two-step uploadId. Returns the img_… id attach_media_to_post takes. §2.5. |
delete_media_asset | media:destroy | Two-phase. Removes an image from the media library — the dashboard's tombstone delete: gone from search and every new attachment, while posts/carousels already carrying it keep it for 30 days (they hold their own copy of the stored file), after which the retention sweeps erase the stored file, every earlier version of it and the CDN copy, and those references 404. A LinkedIn-synced photo is never re-imported. Re-sending the identical bytes to create_media_asset restores it. §2.5. |
delete_post | posts:destroy | Two-phase and irreversible. The only way to remove a post — posts have no archive. The proposal previews the cascade and whether the LinkedIn copy survives. §2.6. |
publish_post | posts:publish | Two-phase and irreversible. Publishes an existing post to LinkedIn now. Never implied by posts:write or the write alias. Capped at 5 agent publishes per user per rolling 24 h. In a workspace that requires review, both phases refuse a post without an approved review with conflict (§1.3). §2.3. |
create_source_text | sources:write | Sync. Returns a Source envelope. |
create_upload_url | sources:write | Sync. Returns a presigned S3 PUT URL — openWorldHint: true. The upl_… handle it returns is consumed by create_media_asset, create_source_file or add_to_knowledge_base; it is never itself a media key. The preferred path for anything over the ~3 MB inline cap (§4). |
create_source_file | sources:write | Async. Returns a Job envelope. Accepts a two-step uploadId (preferred — a real recording never fits inline) or inline content_base64 for files under ~3 MB decoded. Streams progress (§5). |
delete_source | sources:destroy | Two-phase and irreversible. Deletes a source with its transcript, topics + hooks, knowledge-base copy (embeddings included) and stored file, and releases the storage quota. NOT total erasure: posts generated from the source and the usage ledger survive, and the proposal says so. Also on REST as DELETE /v1/sources/:sourceId, same two phases. §2.13. |
add_to_knowledge_base | knowledge:write | Async. Accepts text, file (inline or uploadId), URL, or YouTube. Omit projectId to use the default project; pass projectId: null explicitly for a workspace-wide document. Streams progress; get_knowledge_ingest(jobId) reports processing / ready / failed. A text ingest is also deduplicated on the text alone: the same text sent to the same place again within 10–20 minutes, under any name, answers with the first ingest (marked deduplicated when only the name or other arguments differ) unless that one failed, was cancelled, or its document was deleted — an identical retry after such a failure stores the text again. A long text is slow on the HOST — the model writes every character as the call's argument, about a minute per 20,000 characters — not on Scripe, which stores and indexes it in seconds. |
generate_post | posts:generate | Async. AI post generation; the worker loads the project's tone, pillars, voice samples, and knowledge base. Sources: text, note, topic, or idea (§8). Streams progress. scheduledFor is validated (parseable, in the future) but schedules nothing — the generation always lands a draft, so put the returned post on the calendar with schedule_post afterwards. In a workspace that requires review, a non-null scheduledFor is refused with conflict (§1.3) — generate the draft, get it approved, then schedule_post. |
generate_image | media:write | Async. AI image generation into the media library (kind: PHOTO / PHOTO_EDIT / IMAGE_EDIT; references and subjects by img_… id only). Metered by the AI budget; spend-capped per workspace per day (402 when exhausted). A picture of the user themselves needs their photo: PHOTO_EDIT with subjectMediaIds (a PHOTO of them is refused with 422). Result carries the new img_… id. Streams progress. |
generate_carousel | media:write | Async. AI carousel generation: the model picks a design, storyboards the deck, writes every slide in the project's voice, generates the imagery, stamps the brand identity, renders a PDF. Reserves 45¢ up front, then each image slot's own cost, and reconciles to actuals; a cap that runs out mid-job degrades (stand-in art) rather than fails. Result carries the img_… id, a documentKey for attach_media_to_post's document kind, and imageSlots ({ pending, filled, budgetDeclined, failed }). Decks run two to three minutes — poll get_job rather than sync-waiting. Withheld (not listed, and refused if called) while the deployment cannot render carousel PDFs. |
cancel_job | jobs:cancel | Sync. Idempotent on the resource — a second call leaves the job CANCELLED but errors not_cancellable. |
create_idea | ideas:write | Sync. Inserts at the top of the column (board-UI semantics; ordering is never an input). Status accepts only inbox | in_production — review/done are derived from the linked post. Optional campaignId (cmp_…) files the idea under a draft or active campaign of the workspace that is for this profile, checked before anything is written; a post later written from the idea joins the campaign. If the campaign write is refused after the idea was inserted (the campaign changed in between), the idea is still returned with campaign: null and a campaignNote saying to retry with update_idea — never an error, so never create it again. project echo on the response. |
update_idea | ideas:write | Sync. The board's brief field set; null clears a field. Same writable-status restriction. campaignId moves the idea into a campaign, null takes it out (a post already written from it keeps its own campaign). |
schedule_idea | ideas:write | Sync. DATE-level board placement (YYYY-MM-DD); null unplans. Planning only — never creates a post, never schedules to LinkedIn. |
attach_media_to_idea | ideas:write + media:read | Full-state media write onto a board idea — library images only (img_… ids; one generated carousel may be attached alone). Replaces whatever the idea carried, a reference-mode style pick included. kind: "none" clears the media decision, visual format included. §2.4. |
delete_idea | ideas:destroy | Two-phase, destructive. First call returns a cascade PREVIEW (linked posts kept, open input requests whose public answer links die, note-mirror warning) + token; replay hard-deletes the idea and its child records in one transaction. The preview names the project it resolved to, and the token is bound to it. |
delete_knowledge_doc | knowledge:destroy | Two-phase, destructive. Permanently deletes a document plus every chunk/embedding, its visibility assignments, and any stored file — transactionally. Workspace-wide docs additionally need workspace-admin authority. One case refuses: a page of a dashboard sync — §2.11. |
update_tone_of_voice | settings:write | Two-phase + admin-only. Project-level tone-of-voice/context write; the proposal renders the FULL new text next to current values plus a summary of the preference changes. Takes words, not magic numbers — §2.8. Org-level tone of voice is update_global_tone_of_voice. |
update_engagement_policy | settings:write | Two-phase + admin-only + direction-restricted. Sets auto-like/comment/reshare defaults to OFF or REQUEST only — ON returns a teaching error naming the dashboard. All-OFF (disarm) is always allowed and writes no row on a project that has none (§1.15); non-OFF needs an eligible plan. A LinkedIn connection is needed to publish, not to store the policy. |
update_post_engagements | posts:write + projects:read | Sync. Add / modify / remove one POST's queued team engagement — the editor's panel with its limits enforced server-side: Advanced+ plan (the editor's gate, removal included), actors must be same-workspace, LinkedIn-connected and non-OFF for the action, COMMENT ≤1250 chars, the editor's fixed delays, LIKE/REPOST once per actor, and rows already executed on LinkedIn can be neither edited nor removed (naming one refuses the whole call). Composing is not consent: policies resolve fail-closed at activation, granting a REQUEST approval is not available on this API — approvals happen in the Scripe dashboard or the mobile app — and editing an approved row's payload revokes the approval (same rule as the editor). Removing a row permanently deletes the tracked links bound to it, their click history and their redirect-cache entries. projects:read is required on top of posts:write for the same reason as the read — the response carries the workspace actor inventory, and this verb acts under those identities. |
update_posting_schedule | calendar:write | Two-phase, full-state. Replaces the project's RECURRING posting-time template — the weekly slots get_next_free_slot suggests from. The proposal renders the resulting week and names every slot that would be REMOVED. §2.7. |
update_positioning | settings:write | Admin-only. Empty fields write directly; replacing existing text is two-phase. Writes the WORKSPACE's company and target-audience documents. §2.10. |
update_global_tone_of_voice | settings:write | Admin-only + Business plan. The ORGANIZATION-wide tone of voice: custom instructions injected into every generation for every brand, the shared footer, and whether brands may add instructions of their own. Same empty-vs-occupied rule. §2.10. |
draft_positioning_from_website | settings:write | Website sync that PERSISTS NOTHING (readOnlyHint: true, openWorldHint: true). Crawls a public site and drafts positioning text for update_positioning to write. §2.10. |
update_personal_dna | settings:write | Admin-only. Empty fields write directly; replacing existing text is two-phase. Writes ONE profile's positioning (the person document: intro, credibility, opinions, stories); projectId required. §2.10. |
create_content_topic | settings:write | Admin-only. Sync. Adds one content topic — the list under Settings → Organization → Positioning — to the WORKSPACE, optionally assigning it to brands with profileIds. §2.14. |
assign_content_topic | settings:write | Admin-only. Sync. ADDS one content topic to one or more brands — the topic by wct_… id or exact title, each brand by proj_… id or exact display name; brands that already hold it keep it (alreadyAssigned). §2.14. |
unassign_content_topic | settings:write | Admin-only. Sync, single-phase, destructiveHint: true. Removes one content topic from the named brands only; the topic stays in the workspace, and the sub-topics each named brand kept under it are deleted. §2.14. |
add_profile_to_list | community:write | Sync. Adds ONE LinkedIn person to a community profile list, so Scripe starts collecting their posts. profile is the person's LinkedIn profile address (URL, country-subdomain URL, tracking parameters or bare handle); never a NAME — Scripe cannot look a person up by name (a name is refused, a single word is read as a handle). Optional note (≤500 chars). Adding an existing member is reported, not duplicated. Draws from its own tighter rate bucket and is capped per list and per brand per day. For more than one person use add_profiles_to_list. Does NOT like, comment or repost. §2.16. |
add_profiles_to_list | community:write | Sync. Adds UP TO 25 LinkedIn people to one community profile list in ONE call — the way to build or fill a list. Each entry is { profile, note? }, profile a LinkedIn profile address as for add_profile_to_list, never a name. Never fails as a whole for one bad profile: every entry answers added, already_member (a success), refused (with its reason), duplicate or not_attempted; notAttempted holds the entries to send again, and stoppedBy names a daily or list limit that ended the call. ONE unit of the profile_add bucket per call. Does NOT like, comment or repost. §2.16. |
create_label | posts:write | Sync. Creates a workspace label (title; optional colour — the dashboard's next palette colour when omitted —, description, label group). §2.17. |
update_label | posts:write | Sync. Rename, recolour, re-describe, or move a label into or out of (labelGroupId: null) a group of the same workspace. §2.17. |
delete_label | posts:write | Two-phase, destructive. Removes the label from every post, LinkedIn post, idea, calendar slot, media asset and campaign carrying it (the preview counts them), then deletes it. The items are kept. §2.17. |
update_post_labels | posts:write | Sync. Adds (add), removes (remove) or sets exactly (replace) labels on one or many posts (≤50); every post must be in a project the user can access and every label in the workspace, or nothing is written. A post's published LinkedIn twin is kept in step. §2.17. |
create_campaign | calendar:write | Sync. A campaign with title, start and end date, and optionally emoji, summary, brief, status (default draft), profiles (proj_…) and labels. §2.17. |
update_campaign | calendar:write | Sync. Any of those fields; profileIds and labelIds REPLACE their sets ([] = workspace-wide / no labels). Narrowing detaches ideas of profiles that drop out; moving to active notifies the people assigned to its profiles, once. §2.17. |
delete_campaign | calendar:write | Two-phase, destructive. Deletes the campaign with its attachments and comments; its posts and ideas are kept and lose the campaign (the preview counts them). §2.17. |
set_post_campaign | posts:write | Sync. Puts one or many posts (≤50) into a campaign of the workspace, or out of their campaign with campaignId: null. §2.17. |
create_content_question | ideas:write | Sync. Adds a content question to an idea — a draft for whoever answers for profileIds, or for the people in respondentUserIds; addToCheckIn: true schedules it into the next content check-in. §2.17. |
update_content_question | ideas:write | Sync. Text and assignment of a draft or scheduled question (editing a scheduled one returns it to draft); a sent or answered question refuses with conflict. §2.17. |
delete_content_question | ideas:destroy | Two-phase. Deletes a question from its idea; a scheduled one leaves the check-in it was waiting for. §2.17. |
send_content_question | ideas:write | Two-phase, whatever when says. Sends a question to its assignees: next_check_in, at (sendAt + timezone) or now. The proposal names every recipient, channel and when it goes out; nothing is sent or scheduled before the confirmed call. §2.17. |
send_content_questions | ideas:write | The same for up to 30 questions across any number of ideas, in ONE go: each person they reach gets ONE notification (inbox, push, email) with all of their questions and one answer page grouped by idea. Whole or nothing — one question that cannot be sent refuses the batch, naming it. Two-phase, whatever when says. §2.17. |
nudge_content_question | ideas:write | Sync. Reminds one pending recipient (cqr_…); at most once per person per 24 hours (conflict with details.resetsAt inside the window). §2.17. |
answer_content_question | ideas:write | Sync. Answers a question sent to the signed-in user THEMSELF; nobody answers on someone else's behalf. §2.17. |
save_for_you_idea | ideas:write | Sync, idempotent. The For You card's Save: a board idea (idea_…) on one profile — projectId, or the profile that generated it — optionally assigned to every other profile the idea suits (forAllSuitedProfiles). Saving again returns the same board idea. §2.18. |
dismiss_for_you_idea | ideas:write | Sync, idempotent, reversible. "Not interested": hides the idea for every profile; restore: true undoes it. Needs access to the profile that generated the idea. §2.18. |
fill_calendar_slot | posts:write (+ ideas:write with forYouIdeaId) | Sync, idempotent. Puts a DRAFT into one empty planned slot: post text you wrote (content, optionally from a forYouIdeaId or board ideaId, which it links), or an existing unscheduled postId. Never schedules: schedule_post with scheduledFor = the slot's at is the separate step, and it schedules live on the first call. §2.18. |
update_icp | settings:write | Admin-only. Empty fields write directly; replacing a field that already holds a value is two-phase. Writes the WORKSPACE's ICP field by field, and every write confirms it. §2.20. |
generate_post_ideas | ideas:write | Sync, one premium model call. Turns material — pasted text, or a kb_… knowledgeDocumentId — into suggested post ideas on the brand's idea pool (the dashboard's "Suggested ideas for you"), each with a hook, an angle and a brief citing the passages it was built on. Never writes to the idea board (create_idea does that). Draws from the job rate bucket and is capped per brand per hour (rate_limited). §2.15. |
preview_plan | ideas:write | Planning step 1, writes nothing to the calendar (readOnlyHint: true; draws from the read bucket). Validates up to 60 plan items — profile, an open slot's at, and an idea the agent wrote, a board idea or a For You idea — against the live calendar and stores the plan for 24 hours as a planId with a verdict per item (ready | conflict | invalid) and warnings. §2.19. |
commit_plan | ideas:write | Planning step 2. Sync, idempotent, ONE write unit per call. Turns a previewed plan's ready items (or itemKeys) into board ideas planned INTO their slots (the slot reads planned). A slot taken since the preview is reported conflict, never overwritten. No post, nothing scheduled. §2.19. |
draft_planned_posts | posts:write | Planning step 3, idempotent per idea, ONE write unit per call. Up to 20 planned ideas become DRAFT posts in the slots they reserve. By default Scripe writes them (mode: "scripe"): the app's own pipeline — hooks in the post's lane, the post in the profile's voice, the voice pass — with the agent's research per item (angle, intent, exact facts and their sources), in the background, answered at once with a runId to poll with get_draft_run. mode: "agent_text" places the agent's own content instead, and any item may name an existing unscheduled postId. Optional teamEngagement: true (Advanced+) sets up the standard team engagement on each post. Never schedules, never sends for review and never adds a visual: request_review and add_post_media each preview for the user's yes. §2.19. |
set_up_team_engagement | posts:write + projects:read | Background, idempotent. The post editor's "Generate engagements" for up to 20 posts: every teammate whose policy allows it likes the post, every teammate who allows comments gets one comment written in THEIR voice (each knowing the others), staggered over the first hour after publication; teammates already engaging are skipped. Answers a runId for get_draft_run. Composing is not consent — policies resolve fail-closed when the post is scheduled. Advanced+ plans; job rate bucket. §2.19. |
request_review | posts:write | Two-phase. Sync, idempotent, ONE write unit per call. Sends up to 50 posts for review and assigns the reviewer (user id, email or exact name); ONE notification to the reviewer per call. The proposal names the posts, what happens to each and how the reviewer is told; nothing is sent before the confirmed call. Never approves or rejects. Needs a plan with content approvals (plan_not_eligible below it). §2.19. |
add_post_media | posts:write + media:read (+ media:write for a visual Scripe makes) | Two-phase. The proposal shows each post's visual and whether it uses Scripe AI credits; nothing is attached or made before the confirmed call. Puts a visual on up to 20 posts in one call: recommended (the visual the data picks), library (mediaIds, an image the agent made and uploaded included) or scripe (Scripe makes a QUOTE_CARD, CAROUSEL, INFOGRAPHIC, BILLBOARD or MEME from its templates in the workspace's brand, in the background — poll get_draft_run). A post with media keeps it unless replace: true. Never generates a photo of a person. job rate bucket. §2.19. |
create_tracked_link | posts:write | Sync. A tracked link to destinationUrl (UTM parameters kept as given), minted exactly like one on Settings → Links, on the workspace's own link domain when it has one. Optional name and projectId. With postId it is attached to that post the way the editor tracks a link: the workspace's link for that exact destination is reused or created and the post gets its own short URL (placement.shortUrl, also urlToShare), which goes into the post text. Business plan (plan_not_eligible below it). MCP only. §2.21. |
tag_post_mentions | posts:write | Sync, idempotent. Turns names a post writes into LinkedIn tags. Scripe resolves each name itself and tags only when sure (one of the workspace's own profiles or pages, a tag its posts already made, or the agent's hint — company or role — matching exactly one candidate); otherwise the name stays untagged and the answer gives a COUNT of candidates, never who. §2.19. |
Annotations: readOnlyHint: false on every write except
draft_positioning_from_website, which makes no persistent change and
says so, and preview_plan, which writes nothing the calendar or any
other read sees (it stores the preview itself for 24 hours, to be
committed by id). destructiveHint: true on the destroy verbs
(delete_idea, delete_knowledge_doc, delete_note, delete_post,
delete_source, delete_media_asset, delete_label, delete_campaign,
delete_content_question), on update_post_engagements,
whose remove permanently deletes the engagement's tracked links, their
click history and their redirect-cache entries, and on
unassign_content_topic, which deletes the sub-topics a brand kept under
the topic — both, unlike the destroy verbs, single-phase, so the hint is
the host's only cue to ask first.
idempotentHint: true on the naturally idempotent writes —
attach_media_to_post and attach_media_to_idea (full-state),
cancel_job, schedule_idea
(re-placing on the same day is a no-op), switch_workspace,
update_engagement_policy, update_tone_of_voice,
update_positioning, update_global_tone_of_voice,
update_personal_dna, update_icp, assign_content_topic and
unassign_content_topic (set semantics), update_label,
update_post_labels, update_campaign, set_post_campaign,
update_content_question, nudge_content_question and
answer_content_question, the planning writes commit_plan,
draft_planned_posts, request_review, set_up_team_engagement and
tag_post_mentions (repeating one writes nothing new), and the delete verbs whose replay reads as
not_found / a no-op (delete_knowledge_doc,
delete_note, delete_source, delete_media_asset, publish_post
via its already_published short-circuit). delete_idea is not idempotent — its replay
hard-deletes a cascade, so a second call is a different operation.
2.1 Idempotency
The resource-creating tools create_note, create_post_draft,
create_source_text, create_source_file, add_to_knowledge_base,
create_content_topic, add_profile_to_list and generate_post accept
an optional
idempotencyKey argument —
the tool-argument equivalent of the REST Idempotency-Key header,
since MCP has no header surface. Pass the same key when retrying a call
that may already have succeeded and the original envelope is returned
instead of a second resource. Reusing a key with different arguments
returns idempotency_key_conflict.
Omitting it is still safe. The server derives a fallback key from
the tool arguments, scoped to a short window, so an agent that retries
an identical call gets the original result rather than a duplicate.
This matters most for create_post_draft with scheduledFor —
duplicates there would each publish to LinkedIn. To deliberately create
the same resource twice, wait out the window or pass distinct
idempotencyKey values.
update_post_engagements is dedup'd the same way but takes no
idempotencyKey argument — only the derived fallback key. Its add
entries mint rows, and only LIKE and REPOST are caught by the
once-per-actor rule, so a retried call would otherwise queue the same
comment twice under the acting brand's LinkedIn identity.
Because it changes rows rather than creating a resource, its replay is
additionally checked against the current state: the stored envelope comes
back only while the rows that call determined are still as it left them —
every row it added still exists, every row it modified still carries the
payload it set, every row it removed is still gone. If that is no longer
true the call executes again. So a deliberate repeat inside the
window is honoured rather than swallowed: add an entry, remove it, send
the identical add again and the entry really is queued again, and
re-sending a modify you have since reverted re-applies it. A genuine
retry — one whose writes are all still in place — still replays without
a second write. Only what the call itself set is compared, so a row
moving through its lifecycle underneath you (an approval granted in the
dashboard, the scheduler firing) does not turn a retry into a second
write, and neither does an edit to a row the call never touched.
The two generators have their own replay form: generate_image and
generate_carousel take an idempotencyKey that dedups for 24 hours
by attaching a retried call to the job already running instead of
billing twice. create_media_asset needs no key at all — it dedups on
the content itself (§2.5).
2.2 Two-phase confirmation
The irreversible verbs — publish_post,
delete_post, delete_idea, delete_knowledge_doc, delete_source,
delete_media_asset, delete_label, delete_campaign,
delete_content_question, permanent delete_note,
update_tone_of_voice, update_engagement_policy, and
update_posting_schedule, plus the positioning writes (workspace and
per-profile) when they would replace existing text, and every verb that
tells a person something or changes a post's media — send_content_question
and send_content_questions whatever when says, request_review and
add_post_media — share one server-enforced contract:
- Propose. Call the tool without
confirmationToken. Nothing executes. The result is a proposal — exactly what will happen, plus aconfirmationTokenandtokenExpiresAt(~5-minute TTL). - Show the proposal to the user and get an explicit yes. This step is yours. The server cannot see it: both calls arrive from the model, so a host that chains them executes without any human in the loop. The yes is the user's, never the agent's, also on autopilot. The four verbs that tell a person something or change a post's media keep their token 30 minutes, so the user has time to read the proposal.
- Execute. Call the tool again with the identical arguments plus the token.
Rules the server enforces, not suggestions:
- The token is bound to the action, the acting user, the subject, and
the proposed change — the arguments you sent. Confirm with
different arguments than you proposed and the replay fails with
invalid_request(details.reason: "confirmation_invalid"). Re-propose so the user sees what changed. An expired token fails the same way withdetails.reason: "confirmation_expired". - What that binding does not cover, for most verbs, is the subject
drifting underneath you. Three kinds of verb also bind what the user
saw:
publish_posta hash of the post's content, media, and engagement flags;send_content_questionandsend_content_questionsa hash of each question's wording and the people it reaches, so a question reassigned or reworded between the proposal and the confirm fails the token instead of reaching someone the user never saw (the send moving a question's status is not part of it, so a retry of a confirmed send still verifies); andadd_post_mediathe visuals it decided (below). Editing a note between adelete_noteproposal and its confirm does not — that token binds{ permanent }, not the note's body. If the subject changing matters to your flow, re-read it before you confirm. - Eight verbs additionally fold the surface into the bound action, so a
token minted in chat cannot be redeemed over MCP or vice versa:
publish_post,update_positioning,update_global_tone_of_voice,update_personal_dna,request_review,send_content_question,send_content_questionsandadd_post_media. Every other verb's token is not surface-scoped —update_tone_of_voiceincluded, despite writing settings text. add_post_mediabinds the visual each post GETS, as the proposal decided it, not the arguments' spelling: confirm with the same arguments or with the proposal'sconfirmItems(the same visuals, named concretely). Arecommendedpick that would now resolve to another visual — a new library picture, media added to the post in between — fails the token instead of adding what nobody saw.- Tokens are not single-use; duplicate protection comes from conditional writes on the subject (see §2.3), so a network retry of a confirmed call is safe.
The scope model backs this up: posts:publish, settings:write, and
the *:destroy scopes are never implied by anything, so a grant that
can propose a destructive action is always one a human consented to by
name. Reversible paths deliberately stay cheap — delete_note's
default archive rides plain notes:write with no confirmation, and
delete_post has no such half only because a post is not archivable
anywhere in the product.
2.3 Publishing (publish_post)
publish_post makes an irreversible external write, so it carries the
strictest version of the contract.
Phase 1 — propose.
{ "name": "publish_post", "arguments": { "projectId": "proj_…", "postId": "post_…" } }Nothing is published. The server re-reads the post, resolves the
publishing identity, checks the per-user budget, renders the commentary,
and pings LinkedIn. The proposal carries commentary (the exact string
LinkedIn will render, footer included), postsAs + identityKind
(personal profile vs company page), preflight (the live LinkedIn
connection check), the post's engagement flags, publishesThisWindow
(e.g. "2/5"), irreversible: true, and the confirmationToken.
preflight is reported, not enforced: anything other than "ok" means
the LinkedIn connection is expired, revoked, or (for a company page) has
no admin with a working connection — publishing anyway will fail at the
LinkedIn call, so surface the warning and let the user reconnect first.
Phase 2 — confirm. After an explicit yes, the same call plus the token. The server re-verifies everything it showed:
- Token binding — the content hash covers the rendered commentary, the
media (a poll counts as media here), and the engagement flags, so a
draft edited between the two calls (including via
attach_media_to_postorupdate_post) fails closed rather than publishing something the human never saw. - Every eligibility check re-runs: reshare, empty draft, existing LinkedIn URN, in-flight publish lock, published status, the workspace's review requirement (§1.3), and the 5-per-24h budget.
- The publish itself is guarded twice: a stored LinkedIn URN
short-circuits to
result: "already_published"without contacting LinkedIn, and an in-flight lock returnsconflict. There is no force/override argument. - The post's configured extras run — auto-like, employee advocacy, Slack notification, engagement activation. These are the same side effects a scheduled publish runs; an agent publish and a cron publish are deliberately the same operation.
- Success returns
result: "published"with the LinkedIn URN.
Operational edges:
postIdis the publicpost_…id — the one every other tool returns and accepts. The internal database id is not an input anywhere on this surface; passing one isnot_foundlike any other unknown id.result.postIdcomes back in the same public form.- A publish that failed within the last 10 minutes blocks a retry
with
conflict— a failure after LinkedIn accepted the post is indistinguishable from one before it, so check the post on LinkedIn instead of retrying. - Exceeding the 5-per-24h budget returns
rate_limitedon both phases — schedule the post instead. - Reshares, empty drafts, and posts already live are refused with teaching errors.
- Only treat a post as live once you have seen
result: "published". - A video's custom cover is fail-closed: a
thumbnailKeyLinkedIn will not take fails the whole publish rather than posting the video with another cover — see Media § attaching to a post. - A post that carries no media but does carry a link publishes with a
LinkedIn link-preview card, built from the linked page's Open Graph
metadata. LinkedIn does not scrape URLs, so Scripe supplies the card;
it is best-effort and not part of the proposal — a page with no
og:title, or a thumbnail that cannot be fetched, simply publishes with a smaller card or none. A post that carries its own media is unaffected: media always wins over the link.
To publish at a future time, use schedule_post — a time in the request
is a schedule, never a publish. To unschedule (revert to an unsent
draft), update_post({ postId, scheduledFor: null }) — reversible, so
no confirmation.
2.4 Media on a post, and on an idea (attach_media_to_post, attach_media_to_idea)
The media write is full-state: it replaces everything the post
carried. So "add one more image" is a read-then-write, and the read is
get_post, whose media block reports the post in exactly the shape
this tool takes back. To add a second image, resend the first one by
key:
{
"kind": "images",
"images": [
{ "source": "key", "key": "rrb7bw8pfuc.png", "alt": "the chart" },
{ "source": "library", "assetId": "img_d85211200fcc4f68" }
]
}Two branches, and picking the wrong one is refused rather than stored:
| you have | branch |
|---|---|
an img_… id from search_media or a finished generate_image | { "source": "library", "assetId": "img_…" } |
a key from the post's own media block | { "source": "key", "key": "…" } |
a displayUrl, an upl_… upload handle | neither — turn it into an img_… id with create_media_asset first |
key is a stored file key, and the publish step classifies it by
extension to decide whether the asset goes to LinkedIn as an image, a
document or a video. A key it cannot classify — an img_… id, a CDN
URL — is rejected with unprocessable naming the branch that works.
(It used to be accepted with a 200 and then fail on the publish cron
hours later, with the post looking attached the whole time.)
The response carries the post's resulting media, because the
resolution is not the identity: a library id comes back as the stored
key you will need next time, and a .pdf sent under kind: "images"
comes back as kind: "document" — which is how LinkedIn will publish
it.
A post the dashboard gave a LinkedIn poll refuses media with conflict
and is left unchanged — a post publishes media or a poll, never both; see
Media § Attaching to a post.
kind: "none" on such a post is allowed and leaves the poll alone.
Idea media (attach_media_to_idea) is the same full-state contract
on a BOARD IDEA, with a narrower payload: library images only, as
{ "kind": "images", "images": [{ "assetId": "img_…" }] } (first =
cover, max 20), or kind: "none" to clear. There is no key branch —
an idea's media is a library reference, not a stored post file — and no
video/PDF kinds; the one non-image case is a single carousel that
carries a cover image, attached alone: that cover becomes the media
image and the idea's visual format becomes CAROUSEL. That includes
a carousel built by generate_carousel: the job renders a page-1
cover and re-hosts it to Scripe's CDN, so a freshly finished deck may
answer the transient processing refusal for the few seconds the
re-host takes, then attaches like any image. A carousel generated
before covers existed stored a rendered document with no durable cover,
so attaching one of those is refused permanently (not_attachable),
not temporarily; it is attachable to a POST either way. A multi-image
stack needs an image-producing visual format (a CAROUSEL-format idea
refuses a stack with the repair named), and an asset is otherwise refused
until Scripe has re-hosted it on its CDN — an idea stores the image URL
itself, so only the DURABLE one may be written onto a card, and
status: "READY" is not the signal (a READY row can still be waiting
on the re-host). That is stricter than attach_media_to_post, which
stores a storage key and so attaches a just-created asset immediately;
here, retry shortly (usually seconds) — but only when the refusal is the
transient one, since a media with no durable image at all says so and
will answer the same on every retry. Reference-only style picks (shown
by get_idea as media.mode: "reference") are made in the Scripe app
and are readable but not writable here — and because the write is
full-state, it DISCARDS one the idea was carrying (with either kind),
which nothing on this API can recreate. Clearing an idea that has media
also clears its assetFormat, exactly like the idea page's "remove
media"; on an idea that carries none it changes nothing, because the
visual format on its own is a brief label update_idea owns — the
response's wrote: false is how you tell that no-op from a real clear.
The media URL fields get_idea returns
(media.images[].imageUrl, media.styleImageUrl) are display links to
the image bytes and need media:read alongside ideas:read; without
it they come back null while the img_… ids, mode, style name and
image count stay intact — images with a null imageUrl mean the link
is withheld, not that the idea has no media (media: null is what
that means).
A reference pick often has no picture to link at all: the Scripe app
draws the preview live from the customer's own brand — their quote
card carrying the idea's hook, a carousel cover in their identity,
their brand over one of their own photographs. Those come back with
media.styleImageUrl: null whether or not you hold media:read, and
the decision in media.brandPreview: kind (quote-card |
carousel-cover | photo-overlay), the text the card carries, an
optional designId / variant, and for a photo overlay a photoUrl
— the one link in it, gated by media:read like the others and simply
absent without that scope. It is a structure, not an image: there is
nothing to fetch, and redrawing it takes the app's renderers. The
field is MCP-only — the REST detail read (GET /v1/ideas/{ideaId})
emits the properties its OpenAPI schema documents and no brand
preview.
2.5 The user's own image (create_media_asset)
attach_media_to_post takes an img_… library id or a key the post
already carries. A photo the user just handed you is neither —
create_media_asset is the step in between. The whole arc is two
calls:
// 1. the bytes become a library asset
create_media_asset({
"content_base64": "<the image>",
"fileName": "keynote-stage.jpg",
"alt": "On stage at the keynote"
})
// → { "data": { "id": "img_d85211200fcc4f68", "status": "PROCESSING", … } }
// 2. the id goes into the branch attach already had
attach_media_to_post({
"postId": "post_…",
"media": { "kind": "images",
"images": [{ "source": "library", "assetId": "img_d85211200fcc4f68" }] }
})status: "PROCESSING"does not mean "wait". The asset is attachable the instant it is returned — attach resolves the stored file, not the status. Cloudflare re-hosting and vision tagging run asynchronously;search_media(which listsREADYrows only) shows it once they finish.- Two ways in, exactly one per call.
content_base64is simpler for SMALL files (≤ ~3 MB decoded — anything larger dies at the platform edge as a bare HTTP 413, so pre-check the size). Passsha256of the decoded bytes alongside when the host can compute it; a mismatch is rejected instead of storing corrupted bytes.uploadId— anupl_…handle fromcreate_upload_url, after you have PUT the bytes to its signed URL — is the path for everything else, up to the full 25 MB image cap. - Images only. SVG is refused, and a PDF or an audio file is
refused naming the tool that takes it (
add_to_knowledge_base,create_source_file). - Retries are free. The same bytes, or the same upload handle, return the asset already created rather than a second copy — and re-sending bytes that were previously deleted revives the asset.
- An
upl_…handle whose bytes were never PUT is a422that says so, not a broken asset.
The undo is delete_media_asset (media:destroy, two-phase): the
dashboard's tombstone delete, so the image leaves the library and every
future attachment while posts already carrying it keep their copy and a
LinkedIn-synced photo is never re-imported. A foreign or unknown
img_… id answers the same not_found either way.
Deleting needs the OWNING project, attaching does not. Profile
assignment makes an image reachable — enough for search_media to list
it and for attach_media_to_post to use it — but the delete runs the
dashboard's own check against the project that owns the row. So
search_media can hand back an img_… whose delete answers
not_found; the refusal says the rule out loud, and a host should
report it as an image owned by a different project rather than as one
that does not exist.
"Keep their copy" is bounded at 30 days. The delete stamps the
row's updatedAt, and the retention sweep erases the stored file and
the CDN copy 30 days later — every embedded reference (saved carousel decks, saved templates, exported PDFs) then returns 404. A post
scheduled to publish more than 30 days out loses its image. Posts
already published to LinkedIn are unaffected, because LinkedIn hosts
its own copy. Both phases of the tool say this, and
kept.storedFileForExistingReferencesDays carries the number rather
than a boolean, so a host cannot read it as permanent.
And the erasure reaches every version, not only the current file. Every render of a generated asset repoints the row at a fresh storage key, so an asset edited more than once left one stored object per render. A nightly inventory sweep erases every media-library object no row references once it is older than the same 30-day window, working from the bucket itself rather than from anything a writer recorded — a superseded render is by definition older than its replacement, so all of them are gone within 30 days of the delete. Both phases say so, and a host may report the delete as total removal of the stored file and its history.
The REST twin is POST /v1/media — see Media; deletion
is MCP-only.
2.6 Removing a post (delete_post)
delete_post is two-phase and irreversible, and the two facts are
connected: posts have no archive. A note archives by default and
only confirms on the permanent path; a post has nowhere to go, so every
post delete confirms. The first call returns a proposal, not a
deletion:
{
"phase": "proposal",
"postId": "post_a1b2c3d4",
"title": "Why we stopped doing demos",
"status": "Published",
"contentExcerpt": "If I had to go from 1-10k followers again…",
"scheduledFor": "2026-08-05T06:00:00.000Z",
"alsoRemoved": {
"calendarSlots": 1,
"ideaBoardLink": { "id": "idea_9f8e7d6c", "title": "Demo-less sales" },
"versionHistoryAndVariations": true,
"armedAutoEngagements": true
},
"linkedInPost": {
"stillLive": true,
"permalink": "https://www.linkedin.com/feed/update/urn:li:share:7490647861550800896/"
},
"confirmationToken": "…"
}Read linkedInPost before you show the user anything. "Delete my
post" almost always means take it off LinkedIn, and this tool cannot
do that: it removes Scripe's copy. A published post stays live at the
permalink, and its analytics history is kept but stops linking back to
a Scripe post. Say so, and offer the LinkedIn link.
What the confirmed delete removes, in one transaction: the post, its calendar slot, its labels, review rows, version history, variations, carousel deck, inline comments, and any armed team engagement. What it keeps: analytics rows, tracked links and notifications — records of something that really happened — and the idea, its attached source material and the topic the post was written from, whose pointer to the post is cleared rather than the row deleted.
To take a post out of the schedule instead, update_post with
scheduledFor: null is reversible and needs no confirmation.
2.7 The posting schedule (update_posting_schedule)
Two different asks share the word "schedule", and they are different tools:
| Ask | Tool |
|---|---|
| "Post this on Tuesday at 9" | schedule_post — one post, one instant |
| "Post me every Tuesday and Thursday at 8" | update_posting_schedule — the recurring template |
The template is what get_next_free_slot walks, what the calendar
draws as an open slot, and what the pillar rotation offers next. Most
projects do not have one, which is why that tool commonly answers
source: "fallback".
Read it from get_settings, where the calendar timezone and week
start already live:
"calendar": {
"timezone": null,
"effectiveTimezone": "Europe/Berlin",
"weekStartsOn": "monday",
"schedule": {
"slots": [
{ "time": "09:00", "hour": 9, "minute": 0,
"days": ["monday", "thursday", "friday"] }
],
"postsPerWeek": 3
}
}slots[].time is a wall clock in effectiveTimezone — always an IANA
name, so it can be converted safely. postsPerWeek is slots × their
days, the number the user says out loud.
The write is full-state. The slots you send become the whole
week, so read the current ones and send them back with your edit
applied. An empty array removes the schedule. One entry per time of
day: daily 09:00 is one slot with seven days, not seven slots —
sending the same time twice is refused rather than merged, because a
merge would guess which day-set you meant.
The first call writes nothing and returns the resulting week plus the diff:
{
"phase": "proposal",
"current": { "slots": [ … ], "postsPerWeek": 3 },
"new": { "slots": [ … ], "postsPerWeek": 2 },
"summary": [
"08:00 on Tue, Thu — added",
"09:00 on Mon, Thu, Fri — REMOVED",
"Posting times per week: 3 → 2"
],
"readable": "08:00 on Tue, Thu (Europe/Berlin)",
"confirmationToken": "…"
}Show the REMOVED lines. They are the only place a user finds out that a slot they set months ago is about to disappear, and a whole-template replace is exactly the shape where that happens by accident.
The executed call answers with schedule (what is stored now),
previous (what it displaced — enough to put it back) and changed.
Pass timeZone (IANA) to change the zone the times are expressed in,
or omit it to keep the project's. Setting a template publishes nothing
and does not move posts that are already scheduled.
2.8 Writing style preferences (update_tone_of_voice)
Three of the fields are stored as integers whose numbers are neither
ordinal nor consistent with each other — 3 is the longest post
length and the shortest sentence style. So the tool takes the word,
and get_settings returns the word next to the number it read:
| field | words it accepts | stored as |
|---|---|---|
postLengthPreference | short | standard | long | 1 | 2 | 3 |
formattingPreference (sentence length) | short | standard | long | 3 | 0 | 2 |
emojiPreference | none | moderate | frequent | 1 | 3 | 5 |
The legal numbers are still accepted, and a word and its number bind to the same confirmation token. Any other number is refused: the columns have no scale between these values, so writing one changes nothing about generation while telling the user their settings changed.
On the read side, get_settings's context carries
postLengthPreferenceLabel, formattingPreferenceLabel and
emojiPreferenceLabel, derived through the same mapping the generators
use — so a project holding a legacy value no picker offers still reads
back as what it will actually produce. What "never chose" reads back as
differs by project type: a company page stores 0 for "never set" and
reads back moderate, the band it generates at; a personal-brand project
stores 1 (its column default) and reads back none.
2.9 Engagement policy writes
update_engagement_policy is covered by its table row plus §1.15 —
the read and the write share the same semantics, including the
deliberate no-row disarm.
2.10 Positioning and the org-wide voice
Three tools write at WORKSPACE level, where every brand shares the result:
update_positioningwrites the company and target-audience documents. A field that is still empty is written on the first call. A field that already holds text is written only viaoverwrite: true, which returns a PROPOSAL naming each field with its current and new text and writes nothing until the token is replayed. Withoutoverwrite, such a field comes back inneedsConfirmationwith itscurrenttext — not a refusal, the cue to show the user what a replacement would cost.overwriteis inside the confirmation binding, andreplacedreturns the previous text once, since positioning has no version history.update_global_tone_of_voicewrites the organization-wide tone of voice: custom instructions injected into every generation for every brand, the shared footer, and whether brands may add instructions of their own. Business plan and above (plan_not_eligiblenamescompanyToneOfVoicebelow it). Same empty-vs-occupied rule; only the two TEXT fields ever needoverwrite, andoverwriteis per-CALL — set it and the whole change set, boolean switches included, becomes two-phase.draft_positioning_from_websitereads a company's public site and drafts the positioning documents, returningwouldFill(empty fields — a direct write) andwouldReplace(fields that would go through the overwrite confirmation) inupdate_positioning's own field names. It persists nothing — nothing changes until you callupdate_positioningwith the parts the user accepted. Splitting the scrape from the write is what lets the write's confirmation hash the exact text that lands.
A fourth write is per-profile, not workspace-wide:
update_personal_dnawrites one profile's positioning — the person document the dashboard shows as the profile folder's Positioning in the knowledge base and as "You" on the Strategy card (the tool keeps its original wire name): who the author is (intro, 1–2 sentences),credibilitypoints,opinions& hot takes, and personalstories(the list sections hold one entry per line, exactly as the dashboard stores them).projectIdis required — this text becomes prompt content for the profile, so the model must name the project. The same empty-vs-occupied rule applies, with the sameoverwriteconfirmation,needsConfirmation/currentreport and one-timereplacedreturn. The read side isget_personal_dna, which does fall back to the default project and echoesproject { id, name, fromDefault }.
All four writes are admin-only. The workspace-level read side is
get_positioning.
2.11 Removing knowledge (delete_knowledge_doc)
Everything add_to_knowledge_base creates can be removed again,
including a page ingested with type: "url". The delete is two-phase
and takes the document's chunks, their embeddings and its
project-visibility assignments with it, in one transaction — so "take
that page back out" actually stops it answering questions.
One case refuses, and only one: a document that is one page of a dashboard sync (a website crawl, a Notion connection, or a GitHub, Google Drive, Granola or Post history synced source). Deleting a single page of a sync is pointless — the next sync run restores it — so the refusal names the sync instead:
{
"code": "conflict",
"message": "This document is one page of a website sync (\"acme.com\") and cannot be deleted on its own — the next sync run would restore it. Manage that sync in the dashboard instead."
}Which dashboard control that means depends on the sync: a profile's Post history folder is curated post by post, with Remove from knowledge base — see Synced knowledge sources.
Sync membership is what a document's folder says, not what its
type says. type: "website" only means the text came from a web
page, which is equally true of a one-off
add_to_knowledge_base({ type: "url" }) — those have no folder, no
sync behind them, and delete normally.
2.12 Generating for a company page
A workspace's projects are not interchangeable. list_projects reports
a type per project, and generate_post treats two of them
differently:
type | writes as | profile it writes from | tone of voice |
|---|---|---|---|
PERSONAL_BRAND / AMPLIFIER | the person, in the first person | the connected LinkedIn profile | the project's personal tone of voice |
COMPANY_PAGE | the company, as "we" | the LinkedIn organisation profile — name, description, industries, specialties, positioning | the page's own tone of voice |
Pass the company page's proj_… id and nothing else changes about the
call. list_company_pages is where you find it: only a page with
status: "activated" has an activatedProject.id; a discovered page
has no project to write into yet.
A company page's language, post length, formatting and emoji
preferences are stored on the page itself, not inherited from whichever
personal brand discovered it — read them with
get_settings({ projectId }) and set them with
update_tone_of_voice({ projectId }), the same as any other project.
If the page's LinkedIn organisation details are missing (a partially
connected page), generation fails unprocessable with a message saying
to reconnect the page — not ai_error. Retrying will not help; fixing
the connection will.
2.13 Removing a source (delete_source)
delete_source is two-phase and irreversible, and — uniquely among the
destroy verbs — it also exists on REST (DELETE /v1/sources/:sourceId,
same two phases, token via ?confirmationToken=). The proposal names
the blast radius: the source row, the full transcript (paragraphs and
sentences), its topics and their hooks, its knowledge-base copy
(document, chunks, embeddings — knowledge search stops returning the
content immediately), and the stored audio/video/file object, whose
storage quota is released.
Two things deliberately survive, and both the proposal and the executed response say so — do not present this as total erasure:
- Posts generated from the source are kept. They are the user's
content, not the source's child; only their link to the source goes
stale. The proposal counts them (
kept.derivedPosts); removing one isdelete_post, its own two-phase decision. - The internal usage-accounting ledger survives — deleting it would silently rewrite historical cost reporting.
If any of those knowledge-base documents was shared with the whole
company (workspace-wide), the delete additionally requires an admin of
the workspace that document is shared with, acting through an OAuth
grant — not necessarily the caller's own, since moving a project between
workspaces leaves its company-shared documents behind. That is the same
authority rule delete_knowledge_doc applies to those rows, enforced in
both phases.
Without it the call fails admin_required and nothing is deleted; the
proposal counts them in toDelete.workspaceSharedKnowledgeDocuments and
warns that colleagues outside the project rely on them.
Executing emits source.deleted (identity and last status only — the
transcript is never in the payload; a receiver that wanted the content
had source.created). Requires sources:destroy, which no alias and
no sources:write grant ever implies. A token is bound to the source
and the acting principal; a replay after success reads not_found and
deletes nothing further.
2.14 Content topics (list_content_topics, create_content_topic, assign_content_topic, unassign_content_topic)
A content topic is one of the themes the workspace wants to be known for. The dashboard lists them under Content topics at Settings → Organization → Positioning; the older per-brand board called the same thing a topic group, and the tool's description still carries that word so a model that learned the old name finds it. Scripe mines the LinkedIn corpus around each one and builds post ideas from it.
Four nearby things it is not, each with its own tool:
- the positioning documents (
update_positioning) and the tone of voice (update_tone_of_voice,update_global_tone_of_voice) — free text, no topic list; - the topics a recording was cut into (
tpc_…ids onget_source); - the legacy per-project
pillarsarrayget_settingsstill reports; - an idea (
create_idea), which is one post-to-be rather than a theme.
The tool is workspace-scoped: a topic belongs to the workspace, not
to a brand, and workspaceId may only name the connection's active
workspace (omit it and it does). Because one topic reaches every brand
it is assigned to, the write needs a workspace-admin acting user on
top of settings:write — the same bar the dashboard enforces. An
API-key principal has no acting human and is therefore refused.
profileIds is optional and takes public proj_… ids from
list_projects. Each is re-authorized on its own; a project outside the
workspace and an amplifier (which never owns content topics) both refuse
the whole call with not_found. Omitting it creates the topic
unassigned, and the response says so: an unassigned topic is listed
but influences no generation until it is assigned (assign_content_topic
below). Renames, deletes, Scripe's topic suggestions and the relevance
analysis stay in the dashboard.
The response carries the new topic's wct_… id, its title,
searchTerm, status, order and the profiles it was assigned to.
Creating twice with the same title creates two topics (the dashboard
allows that too); retries are covered by the shared idempotency window
(§2.1), so pass an idempotencyKey when retrying a call that may
already have succeeded.
Reading and changing assignments. list_content_topics returns the
workspace's active topics in list order, each with the brands it is
assigned to — an assigned topic is one Scripe builds that brand's post
ideas around. It is membership-level, like the dashboard list, and takes
an optional profileId to narrow to one brand. A member whose own reads
would not admit a brand sees it counted in otherProfileCount rather
than named.
assign_content_topic and unassign_content_topic change who holds one
topic. They require settings:write and a workspace-admin acting user,
the bar the dashboard's assignment control enforces, and write through
the same code. They are two verbs rather than one "set the profiles"
write on purpose: each takes only the brands to add or remove, and
leaves every brand it was not told about as it was, so a model changing
one person never has to resend — and cannot accidentally drop — the
rest. Naming a brand that already holds the topic (assign) or does not
(unassign) is a no-op reported under alreadyAssigned / notAssigned.
topicis thewct_…id or the topic's exact title as listed, compared case-insensitively after trimming and collapsing whitespace —"führung & leadership"finds Führung & Leadership;"Führung"does not. Only active topics are found; a suggestion is accepted in the dashboard.profilestakes, per entry, aproj_…id or the brand's exact display name aslist_projectsshows it, under the same comparison. Every resolved brand is re-authorized likecreate_content_topic'sprofileIds, and amplifiers are refused.- An unknown title or name answers
not_foundwith the candidates indetails.candidates; a title or name shared by two rows answersinvalid_requestwithdetails.reasonambiguous_topic/ambiguous_profileand both ids. One bad reference refuses the whole call before anything is written. - A topic is held by at most 100 brands in total. An assign that would
take it past that answers
unprocessablewithdetails.reasontopic_profile_limitand thelimit,current,remainingandrequestedcounts, and writes nothing.
Unassigning deletes the brand's copy of the topic together with any
sub-topics kept under it for that brand; assigning again restores the
topic (with the workspace's analysis), not those sub-topics. It is
single-phase, like the dashboard toggle, and advertises
destructiveHint: true so a host can ask first.
2.15 Post ideas from material (generate_post_ideas)
generate_post_ideas is the dashboard chat's idea cards on MCP: the
same producer, the same result. It turns material into suggested
ideas — the recommendations the dashboard shows on Explore → "Suggested
ideas for you" —
and persists them to the brand's idea pool, where they appear under the
"Your files" source filter. Each idea carries a title, a hook, an
angle, the postType and pillar it suggests, its funnelStage
(the strategy, derived from the postType, and null for a format
Scripe cannot place in one), a relevanceScore, the brief (the
argument for the idea, as prose) and the citedPassages of the
material that brief was built on. The response
also echoes the project it resolved to (id, name, fromDefault),
because projectId is optional here as it is for the whole idea family.
Material comes from one of three places:
text— an article, notes, a transcript the user pasted, passed verbatim. Scripe cuts it into passages, scores each against the LinkedIn corpus and cites the ones an idea is built on.knowledgeDocumentId— akb_…document fromlist_knowledge. It is held to the same tenant rule the dashboard applies: the authorized brand's own documents and its workspace's; anything else answersnot_found. When given,textis ignored.- neither — the ideas build on the brand's strategy, knowledge base and the request alone.
There is no file upload on this tool. A recording goes through
create_source_file; once processed its transcript is in the knowledge
base, and from there it is a knowledgeDocumentId.
count is optional and bounded (1-12); omit it unless the user named a
number — the generator then picks a sensible three to six. request
carries the user's own words, and a number named there counts too.
funnelStage is optional and takes one of REACH, TRUST or CONVERT
— the strategy the user asked for BY NAME ("give me Convert ideas").
Pass it only then: the ideas are then confined to that strategy's post
formats (every returned idea's funnelStage reads that strategy), while a
run without it lets the brand's strategy weights and the model choose.
The strategies and what each is for are the same three the dashboard shows
(Reach — top of funnel, Trust — middle, Convert — bottom); they are a
mix, not a sequence.
What it does not do: it never writes to the idea board. The chat
card's "Add" is create_idea here — call it with an idea's title and
hook to board one the user picked. It is not generate_post (a post)
and not search_viral_posts (a read of the corpus).
Cost and bounds: one premium reasoning call per invocation, so the tool
draws from the job rate bucket (§5 in MCP), and on top of
that the producer's own per-brand hourly bound — shared with the
dashboard chat — answers rate_limited when the brand has generated
ideas too often in the last hour. A run that produced no usable idea
(the model refused, or every candidate duplicated an idea the brand
already holds) answers unprocessable with the reason. Retries are
covered by the shared idempotency window (§2.1).
2.16 Community profile lists (list_profile_lists, get_profile_list, add_profile_to_list, add_profiles_to_list)
A profile list is a named watchlist of LinkedIn people whose posts Scripe collects, so the customer can read and engage with them from inside the product. The dashboard shows them under Community → Profiles. A list belongs to ONE brand, and a member is somebody else's LinkedIn profile — never a Scripe brand.
The sentence these exist for is "this hot lead just came in from the CRM — add them to my Prospects list":
// 1. find the list
list_profile_lists({})
// → { lists: [{ id: "plist_…", name: "Prospects", memberCount: 12, … }],
// project: { id: "proj_…", name: "Anna Schmidt", fromDefault: true } }
// 2. add the person
add_profile_to_list({
listId: "plist_…",
profile: "linkedin.com/in/anna-schmidt",
note: "Hot lead from Acme, demo 20 Sep"
})Building or filling a list is ONE call, not one per person.
add_profiles_to_list takes up to 25 entries and answers each on its own:
add_profiles_to_list({
listId: "plist_…",
profiles: [
{ profile: "linkedin.com/in/anna-schmidt", note: "Speaker, AI Summit" },
{ profile: "ben-meyer" },
{ profile: "https://de.linkedin.com/in/clara-weiss?utm_source=hubspot" }
]
})
// → { summary: { requested: 3, added: 2, alreadyMember: 1, refused: 0,
// duplicate: 0, notAttempted: 0 },
// results: [{ profile: "…anna-schmidt", status: "added", author: {…},
// postsCollected: true }, …],
// notAttempted: [], stoppedBy: null, note: "…" }status | Meaning |
|---|---|
added | on the list now |
already_member | was on the list already — a success; nothing was duplicated |
refused | not added; error carries the same code, message and reason add_profile_to_list would answer |
duplicate | the same person appears earlier in this call (sameAs is its index) |
not_attempted | not tried in this call |
Profiles go not_attempted for one of two reasons. The call stops
STARTING new adds after about 10 seconds so it always answers inside the
host's time limit (a person Scripe has never seen takes a LinkedIn
lookup, a few seconds each); then stoppedBy is null and the agent
sends notAttempted — the entries themselves, notes included — in a
further call. Or a limit that would refuse everyone after it was hit (the
daily additions, a full list, the plan's member cap, the lookup budget);
then stoppedBy names it, and sending the rest again only helps once the
limit has lifted. Re-sending a profile is always safe: it answers
already_member.
What profile accepts, and what it refuses
The LinkedIn profile URL is the one thing the caller has to supply.
A full URL, a country-subdomain URL (de.linkedin.com/in/…), a URL
carrying CRM tracking parameters, or a bare vanity handle all resolve to
the same person — a paste straight out of a CRM record needs no
cleaning up.
Scripe cannot look a person up by NAME, so a name is not an input
this tool can resolve. LinkedIn publishes no people-search API and
Scripe's own corpus is a corpus of creators, not of people — 14.8% of
its rows share a display name with another row. A best match would put a
stranger on a list the customer then engages from under their own
LinkedIn identity. A name with a space or a non-ASCII character is
refused (invalid_request) with what to supply instead; a single bare
word is indistinguishable from a vanity handle and is treated as one,
resolving to whoever owns it — so pass the URL or handle from the CRM
record, never the person's name. Company pages and email addresses are
refused.
A person who has never posted is a member like any other
add_profile_to_list resolves a profile whether or not they have ever
published, and reports it: postsCollected: false on the add,
postsCollected: 0 on get_profile_list. That is an ordinary state,
not a half-failure — most CRM leads read LinkedIn rather than write on
it. They are watched from now on, and posts appear if and when they
publish. Somebody already on the list answers added: false, alreadyMember: true with a note saying nothing changed — before any
limit below is consulted, since nothing would be written.
A profile that does NOT resolve is one of three things, and the answer says which:
| The provider… | Answer | What to do |
|---|---|---|
| answered positively that there is no such profile | not_found | fix the URL |
| gave its one soft-failure answer, which it gives for a wrong address AND for a brief overload alike | unprocessable, reason: "profile_unresolved" | check the URL against the person's LinkedIn page first; if it is right, retry once after a few minutes — never in a loop |
| could not be reached at all, or refused with an error | service_unavailable | retry in a few minutes |
The middle row is what a non-existent handle usually produces: probed on
2026-09-17, /profile/detail for a made-up username answered HTTP 200
with {"success":false,"message":"error, try again later"} — the same
status, shape and message as a throttled request — so Scripe cannot
honestly claim either reading and says so.
Limits
Each added profile is a standing obligation — Scripe re-checks them for new posts until somebody removes them — so these verbs are bounded more tightly than an ordinary write:
| Bound | Answer when hit |
|---|---|
| Per-list membership cap | conflict, naming the cap |
| Per-brand daily additions (100), across all of its lists | conflict, naming the cap and that it resets at 00:00 UTC |
Their own rate bucket, 30 calls a minute — an add_profiles_to_list call is ONE of those however many profiles it carries | rate_limited, with retry_after in seconds |
| Scripe's global profile-lookup budget | service_unavailable |
On add_profile_to_list every one of them refuses the call and adds
nothing; on add_profiles_to_list a cap refuses the profile it was
reached on and ends the call, with stoppedBy saying which, while the
rate limit refuses the whole call before anything is added. None
truncates silently. The two caps are counted per add with no lock, so
concurrent adds can overshoot one slightly — they are soft caps, not
guaranteed maxima.
get_profile_list returns at most 200 members, newest first.
list.memberCount is always the list's true count; a list holding more
than that answers membersTruncated: true with a note saying how many
are shown of how many there are.
Not in this family, by decision
There is no tool that likes, comments on or reposts. Every
engagement in Scripe is an explicit human click in the dashboard
(captain decision, 2026-09-01) and the data model admits nothing else —
two-phase confirmation would not restore the human, since both calls are
model-initiated (§3 in MCP). There is also no
remove_profile_from_list (removal stops the collection, a
destroy-family verb) and no create_profile_list (a model that cannot
find a list would invent one). Removing a profile, creating a list and
engaging all happen in the Scripe dashboard.
Profile lists are on every paid plan; a workspace on the free plan
gets plan_not_eligible. Below Business a brand's lists hold at most 50
profiles in all, and add_profile_to_list past that answers conflict
naming the limit (add_profiles_to_list refuses that profile the same
way and ends the call, stoppedBy naming it). Client accounts of an agency workspace cannot reach
any of these tools, matching the dashboard.
2.17 Labels, campaigns and content questions
The dashboard's labels (Settings → Labels and the label picker on a post), its campaigns (the Campaigns page and the home campaign tile) and an idea's content questions (the idea page's Questions rail), managed and assigned by an agent exactly as a person does it there — the same services write the rows, so a label, campaign or question an agent makes is indistinguishable from one made in the dashboard.
Who may do what is the dashboard's rule. Labels and campaigns belong
to the WORKSPACE: any member manages them, and the agency client role is
refused every tool in this section (as the dashboard hides these
surfaces from a client). A post or an idea is reached only through its
project — the owner, a workspace admin or a member assigned to the
project — so a member labelling or campaigning a post in a profile they
are not assigned to gets forbidden_project, and the WHOLE call writes
nothing (update_post_labels and set_post_campaign take up to 50
posts and are all-or-nothing). A label, label group, campaign, idea or
question of another workspace answers exactly like a missing one
(not_found).
Labels. update_post_labels is the one assignment verb: add keeps
every label a post already carries, remove takes off only the ones it
names, replace sets the exact list (replace: [] clears). A post that
already matches is reported changed: false and not rewritten. Deleting
a label (delete_label, two-phase) strips it off everything it is on —
the preview says how many posts, LinkedIn posts, ideas, calendar slots,
media assets and campaigns — and deletes nothing else.
Campaigns. Dates are ISO dates (2026-11-03) or timestamps, and a
campaign never ends before it starts — update_campaign checks a moved
date against the stored other one. profileIds narrows a campaign to
profiles of the workspace (an empty list makes it workspace-wide again);
narrowing detaches the campaign from ideas of profiles that drop out,
which keep everything else. get_campaign's members are the home
tile's rows: per narrowed profile, whether its campaign post is
posted, scheduled, inProgress or still missing. A post belongs to
at most one campaign, so set_post_campaign replaces any campaign it
had.
Content questions. A question hangs off an idea, so these tools take
the idea family's optional projectId and echo
project { id, name, fromDefault }. Assign a question to the people
who answer for profileIds (Settings → Content check-in, else the
profile's first assigned user) or directly to respondentUserIds (user
ids from list_team); naming people replaces the profile-derived
assignment. Every send is two-phase, whatever when says: the proposal
names who receives it, through which channels and when, and nothing is
sent or scheduled before the confirmed call — a question reaches people
on a schedule too, only later. now delivers on that call;
next_check_in and at schedule, and the time is part of what was
confirmed (another sendAt fails the token). A scheduled question stays
editable — editing it takes it back to draft until it is sent again. A sent or answered question cannot change
(conflict); create a new one. nudge_content_question reminds one
pending recipient and is refused inside 24 hours of the last reminder,
with details.resetsAt. answer_content_question answers only a
question sent to the signed-in user.
Several questions go out as ONE batch. send_content_questions
takes up to 30 { ideaId, questionId, projectId? } across any number of
ideas and delivers them together: each person gets one notification —
one inbox entry, one push, one email — holding all of their questions,
and one answer page grouped by idea, where six questions sent one by one
would be six. Every idea is authorized like a single send's, and the
batch is whole or nothing: a question already sent, blank or assigned to
nobody refuses the call, naming it, before anything is sent. when works
as for one question, and every batch previews who receives how many
questions, when and through which channels, sending or scheduling only
on the second call with the token.
2.18 For You, goals and planned calendar slots
The read half and the fill of a calendar an agent plans on its own —
"fill my empty slots for the next two weeks" — over the dashboard's own
data: Explore's "For you" ideas, the home's goal tile and the Posts
calendar's lane-tagged slots. The fill_calendar prompt (§7) chains them.
How the pieces relate. A profile's posting GOAL is a number of posts
per week, split across three lanes by its lane mix: REACH (stories and
opinions that need no prior relationship), TRUST (first-hand work and
expertise) and CONVERT (offers and results from the client's own
material). The posting schedule turns the goal into PLANNED SLOTS, and
each slot takes the lane the goal gives its position in the week — the
same rule the dashboard's calendar chips use, so a slot reads the same
lane in both places. The goal counts a slot as filled only by a post of
ITS lane. list_calendar_slots additionally treats a post planned at
exactly a slot's instant as holding that slot whatever its lane, so it is
never offered for a second fill: a slot held by a draft reads draft
(still to schedule), a scheduled or published one fills it. So the loop is: get_goals for what each lane
still needs, list_calendar_slots({ onlyEmpty: true }) for where,
list_for_you_ideas({ lane }) + get_for_you_idea for what, and
fill_calendar_slot once per slot.
For You ideas are generated per profile (ownerProjectId) and may be
tagged for sibling profiles (profileIds). Omitting projectId on
list_for_you_ideas reads every profile the user can access, as the
workspace For You page does. get_for_you_idea's references are the
idea's SOURCES: each numbered like the marker in argument, with its
URL (a LinkedIn post, an article) or knowledgeDocumentId (kb_…),
the quoted excerpt, and citedFor — the words of the argument it backs.
Ground a post written from the idea in those sources; never invent a
figure they do not hold. save_for_you_idea is "for me" by default (one
profile's board) and "for everyone" with forAllSuitedProfiles: true.
dismiss_for_you_idea hides the idea for every profile, so only the
generating profile's people may do it, and only when the user asked.
Goals count published and scheduled posts in the planning period
(the workspace's cycle, 1–12 weeks, Monday to Sunday in the profile's
calendar timezone); drafts and ideas planned in the period are reported
beside them, never counted as done. A profile whose numbers cannot be
computed is named in failedProjectIds, never left out silently.
Filling a slot. slotAt must be the at of an EMPTY planned slot of
the profile — another instant, a past one or a taken slot is refused —
and the post must be the profile's. Written content becomes a draft in
the workspace's draft status, planned at that instant; a For You idea is
saved to the board first (idempotently) and linked, as a board idea is.
The draft takes the lane of the idea it was written from; when that is
not the slot's lane the result says laneMatchesSlot: false: the slot
is taken in list_calendar_slots, but the goal still counts its lane as
unfilled. Sending the same fill again returns the first
draft (created: false) and writes nothing. A scheduled or published
post is never moved by this tool — rescheduling is schedule_post.
Who may do what is the dashboard's rule: every profile is reached through its project (owner, workspace admin or assignment), a workspace read leaves out the profiles the user cannot reach and never names them, and the agency client role is refused all seven tools.
2.19 The planning autopilot
Planning a period of content for a whole workspace — "plan ideas for
everyone for the current period", "the next 7 weeks for our team" —
in a handful of calls instead of dozens. The plan_content prompt (§7)
chains them; weekly_planning renders the same loop for a number of
weeks.
| Tool | Scope | Rate bucket | Behind the gate |
|---|---|---|---|
get_planning_overview | calendar:read | read | no |
get_profile_planning_context | calendar:read | read | no |
get_planning_signals | calendar:read | analytics_fanout (10/min) | no |
check_plan_ideas | ideas:read | read | no |
search_knowledge | knowledge:read | read | no |
read_knowledge_document | knowledge:read | read | no |
list_waiting_for_me | posts:read | read | no |
preview_plan | ideas:write | read | yes |
commit_plan | ideas:write | write | yes |
draft_planned_posts | posts:write | write | yes |
get_draft_run | posts:read | read | no |
set_up_team_engagement | posts:write + projects:read | job | no (Advanced+ plan) |
request_review | posts:write | write | yes |
get_post_media_options | posts:read + media:read | read | no |
add_post_media | posts:write + media:read (+ media:write) | job | yes |
check_post_readiness | posts:read | read | no |
tag_post_mentions | posts:write | write | yes |
The loop. get_planning_overview once → in one turn,
get_profile_planning_context (who each profile is; up to 10 per call) and
get_planning_signals (what works in their niche now, what their sources
published, knowledge documents added lately with their openings and the
most relevant stored summary per profile, their own performance, idea
stock with reasons; up to 10 per call) for the profiles with open slots, plus search_knowledge /
read_knowledge_document to ground ideas in the customer's own
material → the agent decides what to plan and writes the ideas (a plan
longer than one preview checked whole with check_plan_ideas) → preview_plan with every item
→ the user approves → commit_plan → (where an idea needs what only the
person knows, content questions proposed in ONE send_content_questions
batch and sent after the user's yes, §2.17) → draft_planned_posts with
the idea ids and the agent's research per post (Scripe writes the
posts with it; optionally teamEngagement) → get_draft_run until the
run is done → ONE add_post_media with source: "recommended" for the
visuals, shown to the user and done only after their yes →
check_post_readiness on the drafts, fixing what failed
(tag_post_mentions, a leftover placeholder with update_post) → ONE
request_review for the finished posts, shown to the user and sent only
after their yes. Autopilot skips the plan's approval and nothing else:
questions, visuals and the review always wait for the user. Scheduling
stays the separate schedule_post, called when the user asks; nothing here
schedules or publishes.
One range rule. Every planning read takes the range one of three
ways: period — current (the default; the workspace's goal period,
1–12 weeks starting Monday, that today falls in), next or previous;
weeksAhead — N whole weeks from today (or from from), 1–13; or
explicit from / to (YYYY-MM-DD, inclusive; at most 91 days).
get_goals (period only), get_planning_overview,
get_profile_planning_context and list_calendar_slots (which also
keeps its from / to / days form, ≤31 days; naming both forms is
invalid_request) all resolve it through the same rule, in each
profile's own calendar timezone, so "the current period" is the same
days in every answer. The overview echoes the range with planFrom, the
first day that can still take a post; a range wholly in the past has
none (list_calendar_slots refuses it with invalid_request).
What each tool does.
get_planning_overview— input: the range, optionalprofileIds(≤50; omitted = every profile the user can reach — a workspace admin reaches all of them, a member their own),detail(concise|full),timeZone. Output:range(withplanFrom,daysLeftand the workspace'sgoalPeriodWeeks),ideasReserveSlots,workspace(reviewRequired, the campaigns running in the range,waitingForYou: { reviews, questions }), and per profile: LinkedIn health, the goal (weekly, and per lanetarget/done/todo), the slots (total,empty,planned,draft,scheduled,published, theopenslots withat/local/lane, and theplannedIdeas), the idea stock per lane (For You and board) andgaps(no_posting_schedule,cannot_publish,goal_exceeds_slots_by_N,no_<lane>_ideas).workspace.repeatedThemesnames up to eight two-word phrases the range's calendar already carries more than once — in the posts (draft, scheduled, published) and the planned ideas still ahead, across every profile read — each with how many items carry it and whose they are, so new ideas take other angles (the same phrase rulepreview_plan's quality warnings judge by). Concise lists up to 24 open and planned slots per profile (listsTruncatedsays when cut);fulllists every one and adds per-lane draft and idea counts and the three strongest For You ideas per lane. A profile that could not be read is named infailedProfileIds, never left out silently.get_profile_planning_context— input:profileIds(1–10, takes precedence) orprojectId(optional, default profile),detail(concise|full; several profiles default toconcise, one tofull) and the range. Output, once for the call:detail,companyPositioning,postFormats(the post format keyspreview_planaccepts aspostType, per lane) and thecampaignsrunning in range with their brief. Thenprofiles[], every block size-bounded bydetail:profile,range,positioning(the profile's own person document:intro,credibility,opinions,stories),targetAudience(the profile's own override, else the workspace's —sourcesays which),toneOfVoice,contentTopics,postHistory(90-day window: per-lane post counts, the top posts, and therecentOpeningsan idea must not repeat; medians and lifts areget_planning_signals' alone),knowledge(folders with their last sync, the newest documents withinRangemarking ones created inside the range), itscampaigns(with the brief; a concise call carries each brief once, at the top), andideas(strongest unsaved For You ideas per lane and unplaced board ideas). A post the customer removed from the knowledge base is left out of the post history too. Every answer stays under a host's 50,000-character inline limit:trimmed: truesays the idea stock, list tails or long texts were cut — ask for fewer profiles.get_planning_signals— input:profileIds(1–10) orprojectId(omitted = every profile the user can reach, the first 10 read and the rest named inomittedProfileIds),include(any ofniche,sources,performance,ideas,knowledge; default all),nicheDays(7–90, default 30),sourcesSince(YYYY-MM-DD, default 7 days ago, at most 60 days back),knowledgeDays(7–365, default 30),ideasPerLane(1–15) anddetail(concise|full). Output:windows,niche[](author, headline, date, opening, language, media format, post format, lane, engagement, comments,outlierMultiple, permalink,profileIds; Explore's Relevance neighbourhood of each profile narrowed to posts at 2x or more their author's median — each profile'snicheBasissaysprofile, ortrendingwhen it has no positioning signal),sourceItems[](title, source and kind, date, link, excerpt, and per following profile its scansimilarityand theforYouIdeaIdit became) withsourceItemsTotal, and per profileperformance(posts and median engagement / views over 90 days;byFormatwith lane,byLane,byMedia,byTopic— content topics are the built-in topic labels — each withposts,medianEngagementandliftagainst the profile's own median, headed bysummary: what performed for that profile in words and without a number — its strongest and weakest lane and the format, media format and topic above its usual, named only with at least two posts behind them; null with no posts),sources(following,itemsSince),forYou(id, title, lane, format,why, source) andboard(unplaced board ideas), plusknowledge[](document id, name, kind, folder, date,excerpt— the document's opening, or a recording's or interview's chapters — andprofileIds; newest first, each listed once, read whole withread_knowledge_document; noexcerptwhen the document is inknowledgeSummaries) andknowledgeSummaries[](per profile the most relevant document Scripe already summarised in the last 180 days — its own first, then the one whose chapters share words with its content topics, then the newest:sourcechapterswith every chapter'stitleandsummary, ormeeting_summarywith a Granola meeting's ownsummary; each listed once withprofileIds; stored at ingest, no model call).ideasPerLanesays what was applied; when the per-call cap of 60 For You ideas lowered it,ideasPerLaneNotesays how to get the rest with an ideas-only call. A section that could not be read for a profile is named in itsunavailablelist and never fails the call. Every answer stays under a host's 50,000-character inline limit:trimmed: truesays list tails were cut, weakest first. Reads only; no generation model is called — the niche read reuses the profile embedding Explore caches. A post the customer removed from the knowledge base is left out ofperformance.search_knowledge— input:query(2–500 chars),projectId(optional),folderId,documentIds(≤20kb_…),limit(default 5, max 10). Output:results[]of{ documentId, document, kind, relevance, text }(each passage ≤1,500 chars). Searches the profile's uploads, synced Drive / GitHub files, Granola meetings and its own post history. It is for finding passages, not for listing what exists — a folder's name appears in no passage; that islist_knowledge.read_knowledge_document— input:documentId,projectId(optional),maxCharacters(500–24,000, default 12,000). Output:document(id,name,kind,folder,createdAt),text,truncated.list_waiting_for_me— input:kind(all|reviews|questions),limit(≤50 per list, default 20). Output:reviews[](post, profile, title, excerpt, review stage, planned date, review deadline,urlinto the post editor) andquestions[](question, why, its idea, profile, when asked,urlinto the idea page). The same conditions as the home page's "Waiting for your review". Approving stays in Scripe;answer_content_questionanswers a question the user dictates.preview_plan— input:items[](1–60), each withprojectId(required per item),slotAt(an open slot'sat), exactly one idea —ideathe agent wrote (title, optionalhook,angle,notes,postTypeorlane; the lane defaults to the slot's),ideaId(a board idea of that profile) orforYouIdeaId— withforYouIdeaIdoptionallyoverrides(hook,angle,notes: the agent's own take on that For You idea; refused beside anything else) — and an optionalcampaignId. Each item is checked against the live calendar: the slot exists, is ahead and is free; the idea belongs to the profile and is used once; the user may plan for the profile. Output:planId,expiresAt(24 hours), per item akey, the slot (at,local,lane), the idea,status(ready|conflict|invalid) with areason, the For You idea'sangle(the agent's override when it wrote one,edited: true), andwarnings(a lane that is not the slot's, two posts on one day, and the ideas' quality: an angle close to another item's — within a profile or across profiles — or to a post or idea the calendar already holds in the days the plan covers, or an item that is only a title; quality warnings are advice and never change a verdict). Overrides on a For You idea the profile's board already holds are not written — that idea keeps the team's version, and a warning says so. Nothing reaches the calendar; preview again to fix items.commit_plan— input:planId, optionalitemKeys(default: every ready item). Only the user who previewed the plan, in the same workspace, can commit it; an expired or foreign plan isnot_found. Every committed item becomes a board idea — created from the agent's text, saved from For You (with the agent'soverrideswritten onto it: its hook, and a body of hook, angle and notes), or the existing one — planned INTO its slot and filed under its campaign. Each slot is re-checked first, and again after the reservation, so two plans racing for one slot leave one holder and report the other asconflict(that idea stays on the board, unplanned). Output:results[](planned|conflict|skipped|failed, theideaId, whether it wascreatedIdea), asummary,calendarLinksper profile, andreplayed: truewhen the call only repeated an earlier commit. A commit that runs out of time reports the items it never reached asfailed("Not tried"); calling it again with the sameplanIdwrites only what is still missing and reuses an idea an earlier attempt already created, never a second copy. A commit already running answersconflict(details.reason: "commit_in_progress").draft_planned_posts— input:mode(scripe, the default, oragent_text),items[](1–20), each a plannedideaId, plus optionalteamEngagement. It writes drafts and nothing more: no review request and no visual (captain, 2026-10-10). Once the drafts exist,add_post_mediaandrequest_revieweach show the user a proposal and act only after their yes.- In
scripemode an item takes the agent'sresearchfor that post, an opening line the user chose (hook, used verbatim; omitted, Scripe generates hook options in the post's lane and opens with the strongest) and briefnotesfor the writer — never the post itself (an item carryingcontentfails with a reason).researchis what the agent learned while planning:angle(and why),intent(what the slot is for in the plan),facts[](≤15 facts, numbers and quotes, each exactly as its source states it),sources[](≤8, eachtitle, optionalurland theexcerptrelied on) andnotes. Both of Scripe's writers read it — the hook generator as its context, the post writer as material it must quote exactly and may not invent beyond — on top of the profile's tone of voice, positioning, knowledge, outlier hooks and own posts. Scripe writes each post with the app's own pipeline: hooks, the post in the profile's voice, then the voice pass that removes AI phrasing. That takes about a minute a post, all in parallel, so the call answers at once — each itemwritingwith arunId— and the posts appear as the run finishes; pollget_draft_run. A retry while a run is still writing the idea answers from that run instead of starting a second one; once it finished, the idea answersalready_drafted. - In
agent_textmode each item carries exactly one ofcontent(the post text, ≤3,000 chars, optionaltitle) orpostId(an existing unscheduled post of the profile) and lands throughfill_calendar_slot's rules — for when the user asked the agent to write the text itself. ApostIditem is placed the same way in either mode. - Each draft lands at its idea's slot instant, linked to the idea, in
the workspace's draft status. An idea that is not planned into a slot
fails with a reason. Output: per idea
drafted|already_drafted|writing|failedwithpostId,slotAt,profileId, aurland therunId, the run (runId, how many posts it writes, how many get team engagement), and asummary. teamEngagement: trueneeds an Advanced or Business plan (plan_not_eligiblebelow it, before anything is written) and addsset_up_team_engagement's standard engagement to each post as soon as it exists.
- In
get_draft_run— input:runId, optionalwaitSeconds(0–15). Per item:kind(write/media/engage),status, the idea, profile and slot, thepostIdandurl, the opening line the post went out with (hook) and the otherhookOptionsScripe generated,polished(whether the voice pass ran), themediaput on the post (attachedwith its format, source, theimg_…id and the template Scripe used, orskipped/occupied/failedwith why), theengagementit added (likes, comments, comments that could not be drafted, the teammates, or why nothing was added), and areasonon failure. A stalled item is re-driven when the run is read; one that keeps failing endsfailedwith a reason. Runs are kept 24 hours and are readable only by the user who started them, in that workspace (not_foundotherwise).set_up_team_engagement— input:postIds(1–20). Exactly the post editor's "Generate engagements": one like from every teammate whose engagement policy allows likes (in their chosen reaction), and one comment from every teammate who allows comments, each written by Scripe in that teammate's own voice and knowing what the others said, staggered over the first hour after the post goes live. A teammate already engaging on the post is skipped, so a second call adds nothing; a comment that could not be drafted is left out (the like stays) and reported. Output: per postqueued|failed(a post the caller cannot reach answers like a missing one), and therunIdto read withget_draft_run. Composing is not consent: when the post is scheduled each teammate's policy is resolved fail-closed (REQUESTqueues an approval in Scripe,OFFnever fires). To choose the teammates or write the comments yourself, useupdate_post_engagements.request_review— input:postIds(1–50),reviewer(a workspace member's user id fromlist_team, email or exact name), optionalreviewDeadline, and on the second call theconfirmationToken. Two-phase (§2.2): the first call sends nothing and answers a proposal — the reviewer, per post its profile, title, opening, planned date, stage and what happens to it (request;already_requested, nothing changes;reopen, a review the reviewer already decided starts over;reassign, the review moves from another reviewer to this one;refused, with why), the posts not found, and how the reviewer is told: ONE notification in the Scripe inbox, plus push (a signed-in phone), email and Slack (connected) as their notification settings allow, or none when the workspace sends no notifications. A batch that would send nothing is refused before any token. Confirmed, each post moves into the workspace's review stage with a pending review for that person and the reviewer gets ONE notification for the whole call. Output per post:requested|already_requested|refused|not_found, the stage it moved to, aurland a reason. Needs a plan with content approvals (plan_not_eligiblebelow it). It never approves or rejects.get_post_media_options— input:postIds(1–20). Per post the RECOMMENDED visual, decided from data rather than taste: the post type's own visual (the one table Scripe's generators read), Scripe's 2026 LinkedIn trend report and the profile's own posts by media — thereasonsays it in words and quotes no numbers (performance numbers live inget_planning_signals).recommendation.sourceislibrary(a real photo from the media library fits;mediaIdnames it),scripe(a template visual Scripe makes: QUOTE_CARD, CAROUSEL, INFOGRAPHIC, BILLBOARD),upload(only the person can supply it, e.g. a video) ornone;needsPersonPhotosays a real photo of the person would do better than the template it fell back to,needsDirectionthat an INFOGRAPHIC needs its content spelled out. Beside it, up to five media-library pictures ranked by MEANING for that post (why, whether people are in them, whether another recent post already wears them, a preview) and the media the post carries now. Unattended library picks are REUSABLE rows only; a picture never repeats within one batch.add_post_media— input:items[](1–20), each apostIdand asource:recommended,librarywithmediaIds(up to 20 images, or ONE carousel/document alone), orscripewith aformatand an optionaldirection(what the visual shows: the line of a quote card, the title and points of a carousel or infographic, the headline of a billboard; an INFOGRAPHIC without it becomes a carousel of the post), and on the second call theconfirmationToken.replace: trueswaps media the post already carries; without it such a post keeps it. Two-phase (§2.2): the first call attaches and makes nothing and answers a proposal — per post the visual it would get (the library picture with its title and what it shows, or what Scripe would make: format and direction), whether it keeps its media or gets none and why, andusesAiCredits(a Scripe-made visual spends the workspace's AI credits; a library picture does not), plusconfirmItems: the same visuals named concretely, which the confirming call may send asitems. A call that would add nothing is refused before any token. Confirmed, library pictures attach in the call and a visual Scripe makes runs as a draft runmediaitem (about a minute, in parallel) and lands on the post when done — the answer carries therunId. Output per post:attached|adding|occupied|skipped|failed|not_found, with theimg_…ids, the run and a reason. Every visual is stored as a durable library row in the post's profile, so it survives on a published post.check_post_readiness— input:postIds(1–20). Per post the checks, eachpass|warn|failwith what to do: text within LinkedIn's limit, an opening line LinkedIn would not cut before "…more", leftover placeholders or markup LinkedIn would show as characters, styled letters the brand never turned on,@Nametext that would publish as plain text, hashtags against the profile's habit, a visual where the post type reads best with one, images without alt text — plus the tags already in the post, the media, the review state and whether the workspace requires an approval.readymeans no check failed; warnings are the reviewer's call.tag_post_mentions— input:postId,mentions[](1–15), each thetextexactly as the post writes it, optionalkind(person|company),hint(their company or role) orurn+namefor a member or page id already tagged in another Scripe post. Scripe resolves every name on the server and tags only on evidence: one of the workspace's own profiles or company pages, a tag its posts already made under that name, or thehintmatching exactly one candidate's LinkedIn headline. A name it cannot settle answersambiguous(with the NUMBER of candidates) ornot_foundand stays text for a person to tag in the editor — the candidates themselves never leave Scripe, because LinkedIn's terms forbid exporting member data. A name is tagged once, at its first mention outside an existing tag (later mentions keep the plain name, a typed@dropped), and not again when the post already tags them; the previous text goes to the post's version history. The tag shows the post's own words, because LinkedIn links a member mention on one of the member's names (first, last or full) and a company page only on its full name: a first name stays a first name ("Thanks Anna!" tags "Anna"), a name the post continues is taken whole ("Anna Schmidt joined" tags "Anna Schmidt"), and a company the post names only in part stays untagged, the answer naming the full name to write. Posts already on LinkedIn are refused.
Who pays the AI tokens. Planning is the agent's: it writes the idea
titles and angles on its own model and tokens, and the planning tools
only read, validate, store and place — the one model call among them is
search_knowledge embedding its query. The POSTS are Scripe's by
default (captain, 2026-10-09): draft_planned_posts in scripe mode
and set_up_team_engagement run Scripe's own writing models on the
workspace's AI budget, exactly as the same work in the app does. Each
run is checked before it starts against the connection's daily API spend
cap and the profile's weekly AI budget, and every model call is metered
to the profile under the feature agent:draft-run; an item whose budget
is used up fails with a reason rather than writing. agent_text mode
calls no Scripe model. A visual Scripe makes (add_post_media, or
draft_planned_posts with media) is a draft run item on the same
budget and feature, estimated before the run starts at the price of one
generation; get_post_media_options embeds the posts and the library's
picture descriptions (cached for two weeks) under
agent:post-media-match.
Rate limits. The reads and preview_plan draw from the read bucket
(120/min); commit_plan, draft_planned_posts and request_review
cost ONE unit of the write bucket (120/min on MCP) per call however many items
they carry — committing a 22-idea plan as two writes per idea is what
exhausted that bucket before these tools existed. set_up_team_engagement
costs one unit of the job bucket, like the other tools that start
Scripe AI work, and so does add_post_media; tag_post_mentions and
send_content_questions cost one write unit per call;
get_draft_run, get_post_media_options and check_post_readiness
read from the read bucket.
Open to every customer. preview_plan, commit_plan,
draft_planned_posts, request_review, add_post_media and
tag_post_mentions carry no rollout gate (2026-10-16): they work on every
plan that has MCP, for every caller. What still binds is each tool's own
rule — request_review needs a plan with content approvals
(plan_not_eligible below it), draft_planned_posts' teamEngagement
and set_up_team_engagement need Advanced or above, the scopes above,
and the daily AI spend cap. An idea planned at a slot's exact instant
always RESERVES the slot, which then reads planned
(list_calendar_slots, the overview, its ideasReserveSlots) — the same
reading the web calendar uses, so the user and the agent see one
calendar.
Who may do what is the dashboard's rule: every profile is reached
through its project (owner, workspace admin or assignment), a workspace
read leaves out the profiles the user cannot reach, a plan item for a
profile the user cannot plan for is invalid, and the agency client
role is refused every one of them.
2.20 The Ideal customer (get_icp, update_icp)
The Ideal customer (ICP) is the workspace's description of who it sells to; the people who engage with a profile's posts are scored against it. Those people themselves are never named on this API.
- A pilot, stated rather than hidden. Both tools are listed on every connection but answer only in a workspace inside Scripe's Signals pilot. Anywhere else a call returns a teaching error naming the pilot, and nothing is read or written.
- Scopes.
workspace:read/settings:write, like the positioning documents beside it. update_icpfollows the positioning writes (§2.10). Admin-only; an empty field is written directly; replacing a field that holds a value comes back inneedsConfirmationwith itscurrenttext, andoverwrite: truereturns a proposal and aconfirmationToken. There is no version history, so the replaced values are returned once, inreplaced. Every write confirms the profile; a write into one Scripe had only suggested says so inconfirmedSuggestion.- Project resolution.
get_icpreads the workspace's ICP; aprojectIdadds what that one profile is scored against. The project must belong to the connection's active workspace.
Client accounts of an agency workspace cannot reach either tool, matching the dashboard.
2.21 Tracked links (create_tracked_link, list_tracked_links)
A tracked link is a Scripe short URL that redirects to a destination and counts its clicks — Settings → Links in the dashboard. The two tools run the dashboard's own code, so a link created here appears there like any other, with the same short URL and preview.
- One link per destination. Pass the full destination with its UTM
parameters; they are kept exactly as given. "One UTM link per person"
is one
create_tracked_linkcall per person, each a link of its own with its own click count. The short code is generated; custom slugs are not supported.namelabels the link in Scripe. - For a post, put the PLACEMENT's URL in the post. With
postIdthe link is attached to the post the way the editor tracks a link: the workspace's link for that exact destination is reused (reusedExistingLink: true) or created, and the post gets its own placement under it.placement.shortUrl(alsourlToShare) is the URL to write into the post withupdate_post; its clicks count toward the link.projectIddefaults to the post's profile and must match it. - Clicks.
list_tracked_linksreportsclicksper link — every non-bot click attributed to it, the number the dashboard shows — and each placement's own clicks. How a click is counted is the dashboard's rule, unchanged. - Scope, plan and reach.
posts:writeto create,analytics:readto list; both act on the connection's active workspace. Link tracking is a Business-plan feature: on a lower plan both tools stay listed and answerplan_not_eligible, as the dashboard's Links page locks. A project or post outside the workspace answersnot_found. Client accounts of an agency workspace cannot reach either tool. - Ids. Links and placements carry
tlk_…ids.
2.22 Comments on your own posts (list_post_comments)
The comments under one of the user's own LinkedIn posts, for the model to analyse: the questions people ask, the objections they raise, the angles they pick up on, ideas for the next post.
list_post_comments({ linkedInPost: "https://www.linkedin.com/feed/update/urn:li:activity:7381234567890123456/" })
→ { data: {
post: { title, url, publishedAt, commentCount: 14, reactionCount: 212 },
profileId: "proj_…",
freshness: { state: "fresh", fetchedAt },
comments: [
{ text: "Would love a version of this for agencies", createdAt,
byPostAuthor: false,
replies: [{ text: "On the list!", createdAt, byPostAuthor: true, replies: [] }] }
],
totalTopLevelComments: 14, notAvailable: 0, nextCursor: null,
anonymised: true } }- Anonymised by design. A comment carries its text, its date, its
replies and
byPostAuthor(written from the user's workspace: their own profile or page, or a reply a teammate sent from Scripe), and nothing that says who wrote it: no name, headline, picture, profile link or id. A person tagged inside a comment reads@[someone](located by the mention spans of the live LinkedIn read); a LinkedIn profile link and an e-mail address in the text are redacted too; a tagged company keeps its name. The commenters are third parties, and LinkedIn's terms do not allow their identities to leave Scripe. - Only the user's own posts. The post must belong to one of the
user's profiles in the connection's workspace (a post published through
Scripe, or one imported from the profile's LinkedIn history). Anyone
else's post — another workspace's, a creator's from Explore, a profile
the user is not assigned to — answers
not_found, exactly like an id that does not exist. - Naming the post.
postId(post_…, fromlist_postsor alist_post_analyticsrow'spost_id) orlinkedInPost: the post's LinkedIn URL as the user pasted it (…/feed/update/urn:li:activity:…/or…/posts/…-activity-…) or its URN (alist_post_analyticsrow'slinkedin_post_id, which reaches posts not published through Scripe). Exactly one of the two. - How current. Scripe polls a post's comments on a schedule set by
the post's age: every 45 minutes in its first 72 hours, every 4 hours
until it is 14 days old, daily until 90 days, and only when opened
after that. Every call of
list_post_commentsadditionally re-reads the post on LinkedIn live (freshness.state: "fresh") through the profile's own LinkedIn connection, inside the same daily budget the comments inbox uses, so a comment written a minute ago is there. Only that live read says where a comment tags a person, so a comment it did not return (a reply thread the bounded read skipped on a busy post) is left out and counted innotAvailablerather than returned from Scripe's stored text. Whenfreshness.stateis anything butfresh(budget_exhausted,rate_limited,connection_broken, …) no comment comes back at all: every top-level comment is left out with its replies, those sent from Scripe included, all counted innotAvailable. A reply sent from Scripe comes back only under a parent that was read live. - Pages. Up to
limit(default 50, max 100) top-level comments per page, newest first, each with all its replies, oldest first. PassnextCursorback ascursor; every page re-reads LinkedIn live, and the cursor is an offset into that call's list, so a comment arriving between pages can shift it (one may repeat or be skipped). - What is not there. Reactions on individual comments (Scripe stores
none;
post.reactionCountandpost.commentCountare LinkedIn's totals for the whole post), comments their authors deleted, and replies that never reached LinkedIn. - Scope, plan and reach.
posts:read. The comments inbox is a paid-plan feature: on Free the tool stays listed and answersplan_not_eligible. Client accounts of an agency workspace cannot reach it (Community is withheld from them).
3. Tool error envelope
Failed tools return isError: true with structured content:
{
"isError": true,
"content": [
{
"type": "text",
"text": "{\"error\":{\"code\":\"plan_not_eligible\",\"message\":\"…\",\"request_id\":\"…\",\"docs_url\":\"…\"}}"
}
],
"structuredContent": {
"error": {
"code": "plan_not_eligible",
"message": "\"Update global tone of voice\" is not included in this workspace's plan. Upgrade to use it.",
"request_id": "mcp-…",
"docs_url": "https://scripe.io/developers/api/v1/errors#plan_not_eligible"
}
}
}The envelope is identical to the REST error body,
so one parser handles both surfaces. code is stable and shared with
the error reference. Models react to the structured
form; hosts that don't parse it show the JSON-stringified text fallback.
3.1 Unknown arguments are refused, not ignored
Every tool's input schema is closed — tools/list advertises
additionalProperties: false, and a call carrying a parameter the tool
does not declare is rejected before the handler runs:
MCP error -32602: Input validation error: Invalid arguments for tool
update_post: [{"code":"unrecognized_keys","keys":["status"],"message":
"Unknown parameter `status` for `update_post`. This tool accepts only:
autoComment, autoLike, content, contentType, postId, scheduledFor,
statusCategory, statusId, title. Did you mean `statusCategory` or
`statusId`? A parameter this tool does not declare is never applied, so
the call was refused rather than answered without it — retry with a
supported parameter, or omit it and narrow the result yourself."}]This is deliberate and it is the one place a tool answers outside the
error envelope above: a schema rejection is raised by the MCP SDK
before the request reaches Scripe, so it arrives as an isError text
block with no code and is not audit-logged.
The alternative was worse. Until 2026-08 an undeclared parameter was
silently discarded, so list_posts({ q: "pricing" }) returned the
newest 50 posts with a 200, and update_post({ postId, status: "published" }) returned a success envelope having changed nothing — a
wrong answer that nothing downstream could tell from a right one. If
you are porting an integration that relied on extra keys being
tolerated, drop them; the refusal names every parameter the tool
accepts.
Nested object arguments follow the same rule only where they opt in:
generate_post.source and generate_post.options reject unknown keys
and name the object in the message. The add / modify entries of
update_post_engagements do not — an unknown key inside one of those
entries is still silently stripped, so validate them against the
tool's schema yourself.
The REST API remains permissive today — an unknown query parameter or body key is ignored there, so port REST habits to MCP with care.
4. Inline file content (content_base64)
create_source_file, add_to_knowledge_base, and create_media_asset
accept file content inline as base64, in addition to the two-step
create_upload_url + signed-PUT flow. Inline is for small files
only (the cap below) — it exists because most hosts cannot reliably
drive a separate signed PUT from a tool call. For anything larger the
two-step flow is the only path, and it is the preferred one whenever
the host can drive it.
{
"type": "file",
"content_base64": "JVBERi0xLjQKJ…",
"mimeType": "application/pdf",
"filename": "Q3-strategy.pdf"
}| Field | Required | Notes |
|---|---|---|
content_base64 | yes | Raw file bytes, base64-encoded. No data: URL prefix. |
mimeType | yes | e.g. application/pdf, audio/mpeg, image/png, text/plain. |
filename | yes | Display name + extension fallback for content-type detection. |
sha256 | no | Hex SHA-256 of the decoded bytes. Strongly recommended when the host can compute it — base64 relayed through model output corrupts silently, and a digest mismatch is rejected naming both digests instead of storing the wrong bytes. Inline path only: passing it beside uploadId is refused, and on add_to_knowledge_base it is accepted for type: "file" with inline bytes only, since the other types send no bytes to check. |
Limits: ~3 MB decoded per file (~4 MB on-wire as base64). The
serving platform rejects request bodies at ~4.5 MB on the wire with a
bare 413 FUNCTION_PAYLOAD_TOO_LARGE that never reaches the API — no
error envelope, no docs_url — so pre-check the file size and use
the two-step presigned-PUT path for anything larger rather than
retrying inline. Scripe's own cap sits just under that edge so a
payload slightly over it still gets a teaching error naming the
repair. The per-content-type caps from
Uploads (image
25 MB, PDF/docx 100 MB, audio/video 500 MB) apply to the presigned-PUT
path, which is the only way to send a file the inline cap does not
fit.
Idempotent by construction: the inline path derives the storage key from a hash of the content, so retries with an identical payload land on the same upload handle and the ingest workers de-duplicate — a tool retry never creates a duplicate source or knowledge row.
Two-step form ({ "type": "file", "uploadId": "upl_…" }) is the
required path for files over the inline cap, and what non-MCP
integrations use.
5. Progress streaming
The async tools (create_source_file, add_to_knowledge_base,
generate_post, generate_image, generate_carousel) stream MCP
notifications/progress when the host opts in by passing a
progressToken in the request _meta:
{
"method": "tools/call",
"params": {
"name": "generate_post",
"arguments": { "source": { "type": "text", "text": "…" } },
"_meta": { "progressToken": "client-12345" }
}
}- An initial 0-progress notification as soon as the tool sees a
non-terminal job, updates every ~750 ms while the worker reports
(capped at 60 notifications per request), and a final
progress: 1when the job reaches a terminal state (DONE/FAILED/CANCELLED).total: 1is always set so hosts render percentages correctly. - The tool's result returns after the final notification with the latest job state.
- Cancellation: if the host sends
notifications/cancelled, the tool stops streaming and returns the current job state. The underlying job keeps running — callcancel_jobexplicitly to stop the worker (hosts often cancel a call to start a follow-up, not to kill the job). - Hard cap: 18 seconds — deliberately under the route's 25-second
platform ceiling, so the answer has room to serialise and reach you.
At the cap the tool returns whatever job state exists — for a post
generation (32–35 s end to end) that is normally a
RUNNINGjob. The job continues in the background: readdata.idand pollget_job. (The cap used to equal the platform ceiling, so a streamed call could never answer — hosts got no JSON-RPC response at all and lost the job id with it. ARUNNINGjob you can poll is strictly better than a dead socket.)
6. Resources
scripe:// resources let the host attach a record for @-mention or
inspection:
| URI template | Body | Required scope |
|---|---|---|
scripe://workspace/me | Workspace JSON (includes defaultProject) | workspace:read |
scripe://project/{id} | Project JSON | projects:read |
scripe://post/{id} | Post JSON | posts:read |
scripe://note/{id} | Note JSON | notes:read |
scripe://source/{id} | Source JSON (includes topics[] once processed — hooks[] stays empty on API-created sources) | sources:read |
scripe://job/{id} | Job JSON | jobs:read |
All resources return mimeType: "application/json" with the same
envelope the REST API uses. Resources are read-only; mutate via the
matching write tool. They are read-on-demand — the server does not emit
notifications/resources/updated, so re-read when you need fresh state.
7. Prompts
Slash-command-style entry points the host renders in its UI. They produce a single user-role message that primes the model with investigate-then-execute steps; they never call tools themselves.
| Prompt | Arguments | Purpose |
|---|---|---|
weekly_planning | projectId?, weeksOut? | Plan the next week(s) of LinkedIn content — renders plan_content with weeksAhead = weeksOut (1–13), so the two never teach different flows. |
repurpose | sourceRef (a note_*, src_*, or post_* id), format?, projectId? | Turn an existing asset into 1–3 new drafts, staying tonally consistent with recent posts. |
inbox_zero | projectId?, ageDays? | Triage stale notes and failed jobs. Clusters by theme and confirms each batch. |
plan_content | projectId?, period? (current | next), weeksAhead?, theme?, draft?, reviewer?, autopilot?, questions?, media? | The planning autopilot (§2.19): one overview, each profile's context and planning signals, ideas the agent writes for the open slots of the period or the next N weeks (1–13), preview_plan → approval → commit_plan; with questions: "true" also one send_content_questions batch; with draft: "true" also draft_planned_posts, a visual per post (add_post_media, unless media: "false") and, with reviewer, a review request for those drafts. Shows the plan first unless autopilot: "true", which skips the plan's approval and nothing else: questions, visuals and the review are previewed and sent only after the user's yes; never schedules. |
fill_calendar | projectId?, days?, period? (current | next), autopilot? | Fill the empty planned slots of the next days (default 14, max 31) or of a goal period with DRAFTS written from For You ideas of each slot's lane (§2.18), starting from get_planning_overview. Proposes the plan first unless autopilot: "true"; never schedules unless asked. |
All arguments are strings (per MCP Prompt.arguments constraints);
numeric ones (weeksOut, ageDays, days, weeksAhead) are parsed server-side with
sensible defaults.
8. Canonical flows
The server's instructions steer models through these; they're documented here so you can validate a host renders them correctly.
Draft a post. generate_post with the user's brief as a text
source — the worker applies the project's tone, pillars, voice samples,
and knowledge base, so the brief shouldn't over-prescribe. The model
shows the draft and asks before scheduling anything.
File post text the user wrote themselves. create_post_draft
stores that text verbatim as a real post — it lands with the project's
posts, never on the idea board, and generate_post is the tool that
AI-writes a draft instead. create_idea is the board card for
something still to be written, so any "post draft" ask resolves to
create_post_draft; when a request hands over finished post text
without naming either ("send this to my Scripe project"), the model is
instructed to ask which the user meant rather than guess.
Capture a note for a future day. create_note with content and a
resolved date — the note lands on that day in the dashboard calendar.
Send a file to the knowledge base. add_to_knowledge_base with
inline content_base64 for a small file — over the inline cap (§4),
create_upload_url + PUT first and pass the uploadId — or
type: "text" / "url" / "youtube".
Omitting projectId uses the default project; explicitly passing
projectId: null scopes it workspace-wide ("my org's KB"). Returns a
Job; progress streams.
Ingest a source, then draft. create_source_file → wait for the
job → get_source — once status is "Success" the response carries
topics[], best-first by score (ranking is just the position
in the served order, and fromSeconds says where the topic sits in the
recording — an earlier chapter is not a better one). The model presents
the topics, the user picks, then generate_post with
source: { "type": "topic", "topicId": "tpc_…" }. The server reads
that topic's transcript chunk itself — the full transcript is
deliberately never served over this API, so the topic id is the only
way to generate from what the user actually said rather than from the
summary about it. 404 means the topic's source is outside your
workspace; 422 means it belongs to a different project than the
projectId you passed. Two things not to build on: hooks[] is
empty for anything ingested through this API (hooks are a dashboard
artefact, and post generation writes its own), and the flow is
deliberately not auto-chained — a source can surface several distinct
topics, and the user picks the angle.
Turn a board idea into a post. Pass the id — never re-type the
card into source.text:
{ "name": "generate_post",
"arguments": { "source": { "type": "idea", "ideaId": "idea_…" } } }An idea card is a brief, not a sentence — title, body, hook, topic,
post format, visual format, capture evidence, attached material. The
server renders the whole brief itself, so nothing is lost to a
copy-paste, and two things follow that a caller cannot do any other
way: the card's pillar fills options.contentType when you did not
send one (an explicit value always wins), and the generated post is
linked back to the idea — the idea's status is derived from the linked
post, so this is what moves its status from inbox to in_production
(when no post was linked to it yet — the card's board column is not
changed, since columns are customised per workspace and move only by
hand), and what stops the next session seeing an untouched card and generating
the same post again. Read it back with get_idea (linkedPost.id).
Schedule. schedule_post schedules in ONE call — there is no
proposal and no confirmation token (since 2026-10-16; hosts already ask the
user before each write). Call it once the user has asked for the post to go
out at that time; the post then goes live on LinkedIn by itself. The result
carries:
scheduled: true,goesLiveAutomatically: true, andscheduledForas an absolute UTC instant, withscheduledForLocalrendering it in the project's calendar timezone (timeZone) — e.g.Tue, 18 Aug 2026 09:00 (Europe/Berlin).notice— the sentence to relay: the post will be published to LinkedIn automatically atscheduledForLocal, no further action is needed, and how to undo it. Tell the user plainly that it is scheduled; a raw instant is not something a user can check.- the post itself, as
get_postreturns it.
A timestamp with no offset (2026-08-18T09:00:00) is read in the
project's timezone, not UTC and not the server's — that is what a user
means by "9am". Send an offset when you mean an absolute instant.
Nothing is written when the schedule cannot happen: an unparseable time
(resolve "next Tuesday" yourself — the server does no date math), a bare
YYYY-MM-DD (it would schedule midnight; use get_next_free_slot for a
sensible hour) or a past time is 422 unprocessable; a post that needs an
approved review is 409 conflict (§1.3); a workspace with no plan that can
publish is plan_not_eligible; and the LinkedIn connection is checked
live — a dead one is 409 conflict with details.reason
(not_connected | token_invalid | no_company_page_admin),
details.reconnectUrl and details.retryable: false. Relay the reason and
the link; only a human can reconnect LinkedIn. (no_company_page_admin
carries no reconnectUrl: its repair is on a different project — some
admin's own LinkedIn account.)
The schedule stamps the scheduled-category status with the calendar slot
in one step, emits a post.scheduled webhook, and the publish cron posts to
LinkedIn within ~2 minutes of the scheduled time. To reschedule, call
schedule_post again with the new time; to unschedule,
update_post({ scheduledFor: null }) (reversible — it emits
post.unscheduled). A confirmationToken an older conversation still
sends is accepted and ignored.
Publish now. Two-phase publish_post (§2.3).
Work the idea board. create_idea per idea (top of the inbox
column) → schedule_idea for date-level planning → update_idea to
edit → two-phase delete_idea to remove.