Scripe relaunch is live.See what's new

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

ToolScopeReturns
get_workspaceworkspace:readThe active workspace + plan + principal, including defaultProject: { id, name } | null.
list_workspacesworkspace:readEvery workspace the user can reach (id, name, isDefault).
get_workspace_contextworkspace:readOne-call overview: plan + features, review-workflow gate, up to 25 projects each with its linkedIn connection block (§1.4).
list_teamworkspace:readTeam 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_projectsprojects:readPaginated list of projects. Every row carries linkedIn — whether Scripe can publish as it right now (§1.4).
get_projectprojects:readSingle project read, with the same linkedIn block (§1.4).
list_company_pagesprojects:readCompany pages — the delta over list_projects: activation status, STORED admin-token health, follower count, engagement-policy state.
list_notesnotes:readList notes for a project (date / cursor + free-text q search, §1.5). Reports pagination.total (§1.6).
get_notenotes:readSingle note read.
list_postsposts:readList 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_postposts:readSingle post read — the whole body, which list_posts only excerpts (§1.7).
list_post_statusesposts:readThe 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_calendarcalendar:readA 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_slotcalendar:readThe 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_ideasideas:readPaginated 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_ideaideas:readOne 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_sourcessources:readPaginated source (transcription) list — previews only; fetch a single source for its topics.
get_sourcesources:readSingle 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_knowledgeknowledge:readPaginated knowledge-document list visible to a project (workspace docs honour assignment visibility; ephemeral chat uploads never appear).
get_knowledge_docknowledge:readOne knowledge document: metadata, status, tags, chunk count, 2,000-char preview — never the full body.
get_knowledge_ingestknowledge:readWhether 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_mediamedia:readSearch the project's reachable media-library images (owned + profile-assigned, READY only): display URLs, tags, provenance.
get_settingsworkspace:readCurated 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_policyworkspace:readEngagement defaults (OFF | REQUEST | ON), per-requester overrides, stored connection state, plan-lock state. Answer from effective, not policy — §1.15.
get_post_engagementsposts:read + projects:readOne 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_runposts:readA 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_positioningworkspace:readThe 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_topicsworkspace:readThe 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_dnaworkspace:readThe 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_listscommunity:readThe 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_listcommunity:readOne 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_commentsposts:readThe 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_labelsposts:readThe workspace's labels (Settings → Labels): lbl_… id, title, colour, description and label group (lblg_…). §2.17.
list_posts_with_labelposts:readThe 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_campaignscalendar:readThe 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_campaigncalendar:readOne 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_questionsideas:readOne 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_questionideas:readOne 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_ideasideas:readThe "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_ideaideas:readOne 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_goalscalendar:readThe 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_slotscalendar:readA 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_overviewcalendar:readThe 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_contextcalendar:readThe 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_signalscalendar:readWhat 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_ideasideas:readThe 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_knowledgeknowledge:readA 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_documentknowledge:readOne 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_meposts:readWhat 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_icpworkspace:readThe 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_usageworkspace:readUsage 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_overviewanalytics:readCurrent vs previous-period rollup (posts, impressions, engagement rate, follower growth, activity).
list_post_analyticsanalytics:readPer-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_postsanalytics:readSemantic 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_insightsnoneWhat 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_reportanalytics:readFull 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_overviewanalytics:read + workspace:readThe 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_reportanalytics:read + workspace:readThe 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_timesanalytics:readThe 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_jobsjobs:readList async jobs (paginated).
get_jobjobs:readSingle job read with progress snapshot.
list_tracked_linksanalytics:readThe 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:

FieldVocabularyWhat it is
statuslifecycle enumwaitingProcessing | draft | waitingApproval | approved | rejected | published | scheduled | suggested
statusCategorykanbansuggested | draft | inProgress | review | scheduled | published — what list_post_statuses returns
statusIdkanbanThe workspace column the post sits in; update_post moves a post to it and list_posts({ statusId }) filters by it
statusTitlekanbanIts human name, e.g. Good to go
publishedAttimestampWhen 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:

  • draft covers two columns. draft and inProgress both render as status: "draft", so a workspace with an "In Progress" and a "Good to go" column reports both as drafts. Read statusTitle when the user asks where a post is.
  • Filter on the axis you mean. status filters the lifecycle value (and draft therefore also returns the inProgress columns); statusCategory filters the board column and is the only way to ask for inProgress or review. 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:

jsonc
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." } } }
stateMeansWill anything try again?
not_scheduledIn a non-scheduled column. A scheduledAt here is a leftover intention.No
scheduledFuture time, armed.Yes
dueTime has passed, inside the retry window — publishing now, or failing and retrying.Yes
missedPast 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
publishedIt went out.—
  • Never report a post as "on its way" from status alone. On production, 145 posts across 44 projects have been sitting scheduled and 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.detail is the sentence to relay. It names the failure and the repair. lastPublishError beside it is the raw message from the last attempt — useful, but not written for a user.
  • retryUntil is the deadline. While state is due the user can still fix the cause (usually reconnecting LinkedIn) and the post goes out by itself. After it, only schedule_post will.
  • 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:

jsonc
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, … } }
stateWaiting on someone?Means
not_requestednono review is open at the post's current board column
pendingyesa reviewer was asked and has not decided
approvednosigned off — it does not move on by itself
rejectednosent back; notes carries the reason when one was given
  • A review board 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. So statusCategory: "review" answers "what needs my approval?" 22.7% correctly. Filter by review: "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 PENDING on an already-published post. Only the row for the post's current column is reported here, which is why a published post reads not_requested.
  • reviewerUserId is a Clerk user id. list_team returns the same id as userId — resolve it there rather than showing the raw id.
  • Nothing on this surface can decide a review. Approving and rejecting happen in the dashboard; detail says so on every state that is waiting on a person.
  • requiredByWorkspace can be ENFORCED on this surface (since 2026-09; it mirrors get_workspace_context.requireReviewWorkflow). Two things have to be true before it refuses anything, and requiredByWorkspace: true on 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 on requiredByWorkspace alone: treat it as "this workspace may refuse an unreviewed post" and handle the 409 when it comes, rather than pre-emptively blocking a schedule the API would have accepted. Where the gate does bind, schedule_post and publish_post refuse a post without an approved review with 409 conflict (details.reason: review_not_requested / review_pending / review_rejected; details.reviewerUserId is always present — the Clerk id of the reviewer on the post's current-stage review when one exists, pending or rejected, null otherwise — and the message itself never shows the raw id, so resolve it via list_team). A post the caller could not schedule anyway answers the ordinary not_found / forbidden_project, never a review state. create_post_draft / generate_post refuse a non-null scheduledFor outright 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:

jsonc
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" } } }
statePublishes?Means
connectedyesa token is on file and nothing is wrong
expiring_soonyesthe access token lapses within 14 days (or already has) and has not been renewed
degradedyesrecent LinkedIn calls failed; three consecutive failures revoke the connection
disconnectednono usable token — never connected, or the token was cleared
revokednothe authorization was withdrawn
  • Branch on canPublish / state, never on connectionStatus. connectionStatus is the stored health cache. On production it reads CONNECTED for 38 accounts that hold no access token at all, and UNKNOWN for 15,352 that were simply never connected. state applies token presence first.
  • This read never probes LinkedIn. It reports stored health as of lastValidatedAt. The live check runs inside schedule_post and publish_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; reconnectUrl is 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 linkedIn block (and its reconnectUrl) describe that account. null means 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 searchedposts: body, title and hook. notes: the body.
Matchingcase- and accent-insensitive substring
Multiple wordsAND-ed — every word must appear somewhere in the row
"quoted phrase"matched whole
% and _matched literally; they are not wildcards to the caller
Orderingunchanged: newest-first. q narrows the page, it does not rank by relevance
Limits200 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: pricing does not find priced. 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 q returns 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.

jsonc
list_posts({ projectId, status: "draft", limit: 1 })
→ { "data": [ /* 1 row */ ],
    "pagination": { "next_cursor": "eyJ…", "has_more": true, "total": 128 } }
  • limit: 1 is 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 contentTruncated is 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 small limit. 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:

jsonc
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_posts once per category.
  • counts.byCategory[c] is the same number list_posts({ statusCategory: c, limit: 1 }) reports as pagination.total. counts.total always equals the sum of byCategory plus uncategorized (posts whose column was deleted).
  • Without a projectId (REST callers only — the MCP tool defaults it) every postCount is null and counts is null: 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 a 400 invalid_request, never answered as an empty column. The same id is what update_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:

jsonc
list_posts({ publishedFrom: "2026-08-01", publishedTo: "2026-08-08", content: "none" })
  • publishedAt is the only field that dates a publish. scheduledAt is what somebody asked for and stays set on posts that never went out; updatedAt moves on any edit.
  • For a post Scripe published, publishedAt is 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: null on 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/publishedTo only ever return published posts, and combine with every other filter.
  • The page is still ordered newest-DRAFTED first. Sort by publishedAt yourself 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:

fieldisaccepted by
idthe analytics measurement rownothing
post_idthe Scripe post, post_…, or nullget_post, update_post, schedule_post, publish_post, delete_post
linkedin_post_id / permalinkthe LinkedIn activity URN and its URLLinkedIn 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:

jsonc
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.
  • meta appears only when postId was passed, and it is what makes an empty answer readable: meta.unmeasured says which named post has no numbers and why — not_published (never went out) or not_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.
  • sort does not rank by rate. impressions ranks by views and engagement by total interactions; if the user meant rate, sort, then compare metrics.engagement_rate yourself.

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_to is 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 lower minOutlierMultiplier (0 opts out of the outlier floor entirely and serves every post above minEngagement, scored or not), a wider publishedWithinDays, no mediaFormats, language: "all", or a lower minEngagement.
  • mediaFormats is how you ask for a shape. carousel is a swipeable deck (a document post), multi_image several photos in one post, image at most one; then video, text (no media), article (a shared link) and other. A deck is a small share of the corpus, so expect a shorter answer there than for an unfiltered search.
  • An outlier_multiplier of null means NOT SCORED, never "average" — the post is too fresh, below the scoring floor, or its author has no usable baseline. It is only reachable with minOutlierMultiplier: 0. Only outlier_baseline: "AUTHOR" supports a sentence about that author's own usual performance; CORPUS compared the post to a corpus median instead.
  • meta.returned is how many posts came back, capped by limit (default 12, max 30). There is no paging and no corpus-wide total.
  • metrics.views is null, not 0, 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 a null as unknown. metrics.reactions does not have to add up to total_engagement, because LinkedIn reports reaction types it does not break out.
  • Two of the three ids are links, and the third is not usable. permalink opens the post, author.profile_url opens its writer, and id is the internal feed row — no Scripe tool accepts it. To act on an angle you liked, feed the post's content to generate_post as 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:

FieldSourceAttributed byDashboard surface
totalLinkClicksLinkedIn LINK_CLICKS — clicks on any link inside a postthe post's publish datethe Link clicks (LinkedIn) KPI card; part of the funnel's Clicks on LinkedIn stage; posts[].linkClicks per post
totalTrackedLinkClicksScripe click tracking — the workspace's tracked linksthe 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
totalPremiumCtaClicksLinkedIn PREMIUM_CTA_CLICKS — the profile's Premium buttonthe post's publish datethe 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:

FieldMeaning
isoThe slot as an absolute UTC instant.
timezoneThe IANA zone the local fields are in.
date / timeThe slot on the project's own clock — 2026-08-15, 09:00.
utcOffsetThat zone's offset at this instant, e.g. +02:00.
localTimeSat, 15 Aug 2026 09:00 (Europe/Berlin) — quote this.
sourcetemplate = 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 iso yourself. 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. timezone is 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:

FieldMeaning
timeZoneThe zone the hourly / weekly buckets are expressed in.
timeZoneSourceproject 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:

FieldMeaning
policyThe stored EngagementPolicy row, or null when the project has none.
effectiveWhat 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):

ErrorLimitResets
usage_limit_exceededThe weekly AI budget — the primary limitMonday 00:00 UTC
spend_cap_exceededThe daily API AI-spend cap — an abuse valve00:00 UTC

get_usage reports both before either fires — full field reference on Usage and limits.

  • Read canGenerate first. It is false exactly when the next AI job would be refused, and blockedBy names which limit. Deriving it from the meters is easy to get wrong twice over: a meter at 100% only blocks when ai.enforcement is block, 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.scope names.

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[].workspace carries the id, the display name, is_default, and reach: member (Clerk membership) or agency_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 is null, report no total at all — meta.totals_omitted_reason says whether there was nothing to total or the union was too large for another pass.
  • meta.skipped is 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_reachable is your full reach, on both selectors — not the count you named, and not a truncation signal. On an explicit selection it normally exceeds meta.workspaces_returned while meta.truncated is false; read truncated for that.
  • meta.truncated means the response holds the first meta.max_workspaces reachable workspaces. Say so, and name the remaining workspaces explicitly in workspaceId to cover them — at most meta.max_workspaces per call, since an explicit list longer than the cap is refused rather than truncated (see Analytics).
  • meta.projects_truncated names any workspace holding more readable projects than one call measures, whose row — and its share of data.totals — therefore covers only its newest projects. Empty when every workspace fitted; measure those workspaces through get_analytics_overview with explicit projectIds.
  • 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 top meta.posts_per_workspace of the period while report.postsTotal counts them all; page the rest with list_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

ToolScopeNotes
switch_workspaceworkspace:readSync. Moves the connection's active workspace (mutates connection state, not data — idempotent). See MCP § Workspaces.
create_notenotes:writeSync. Optional date places the note's calendar slot.
update_notenotes:writeSync. Edit body and/or move between folders (folderId: null = root). Calendar placement is not writable here.
delete_notenotes: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_draftposts:writeSync. 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_postposts:writeSync. 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_postposts:writeOne 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_postposts:write + media:readFull-state media write onto a post — §2.4 for the two accepted branches. kind: "none" clears.
create_media_assetmedia:writeSync. 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_assetmedia:destroyTwo-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_postposts:destroyTwo-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_postposts:publishTwo-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_textsources:writeSync. Returns a Source envelope.
create_upload_urlsources:writeSync. 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_filesources:writeAsync. 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_sourcesources:destroyTwo-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_baseknowledge:writeAsync. 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_postposts:generateAsync. 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_imagemedia:writeAsync. 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_carouselmedia:writeAsync. 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_jobjobs:cancelSync. Idempotent on the resource — a second call leaves the job CANCELLED but errors not_cancellable.
create_ideaideas:writeSync. 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_ideaideas:writeSync. 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_ideaideas:writeSync. DATE-level board placement (YYYY-MM-DD); null unplans. Planning only — never creates a post, never schedules to LinkedIn.
attach_media_to_ideaideas:write + media:readFull-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_ideaideas:destroyTwo-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_docknowledge:destroyTwo-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_voicesettings:writeTwo-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_policysettings:writeTwo-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_engagementsposts:write + projects:readSync. 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_schedulecalendar:writeTwo-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_positioningsettings:writeAdmin-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_voicesettings:writeAdmin-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_websitesettings:writeWebsite 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_dnasettings:writeAdmin-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_topicsettings:writeAdmin-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_topicsettings:writeAdmin-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_topicsettings:writeAdmin-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_listcommunity:writeSync. 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_listcommunity:writeSync. 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_labelposts:writeSync. Creates a workspace label (title; optional colour — the dashboard's next palette colour when omitted —, description, label group). §2.17.
update_labelposts:writeSync. Rename, recolour, re-describe, or move a label into or out of (labelGroupId: null) a group of the same workspace. §2.17.
delete_labelposts:writeTwo-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_labelsposts:writeSync. 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_campaigncalendar:writeSync. A campaign with title, start and end date, and optionally emoji, summary, brief, status (default draft), profiles (proj_…) and labels. §2.17.
update_campaigncalendar:writeSync. 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_campaigncalendar:writeTwo-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_campaignposts:writeSync. Puts one or many posts (≤50) into a campaign of the workspace, or out of their campaign with campaignId: null. §2.17.
create_content_questionideas:writeSync. 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_questionideas:writeSync. 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_questionideas:destroyTwo-phase. Deletes a question from its idea; a scheduled one leaves the check-in it was waiting for. §2.17.
send_content_questionideas:writeTwo-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_questionsideas:writeThe 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_questionideas:writeSync. Reminds one pending recipient (cqr_…); at most once per person per 24 hours (conflict with details.resetsAt inside the window). §2.17.
answer_content_questionideas:writeSync. Answers a question sent to the signed-in user THEMSELF; nobody answers on someone else's behalf. §2.17.
save_for_you_ideaideas:writeSync, 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_ideaideas:writeSync, 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_slotposts: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_icpsettings:writeAdmin-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_ideasideas:writeSync, 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_planideas:writePlanning 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_planideas:writePlanning 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_postsposts:writePlanning 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_engagementposts:write + projects:readBackground, 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_reviewposts:writeTwo-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_mediaposts: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_linkposts:writeSync. 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_mentionsposts:writeSync, 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:

  1. Propose. Call the tool without confirmationToken. Nothing executes. The result is a proposal — exactly what will happen, plus a confirmationToken and tokenExpiresAt (~5-minute TTL).
  2. 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.
  3. 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 with details.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_post a hash of the post's content, media, and engagement flags; send_content_question and send_content_questions a 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); and add_post_media the visuals it decided (below). Editing a note between a delete_note proposal 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_questions and add_post_media. Every other verb's token is not surface-scoped — update_tone_of_voice included, despite writing settings text.
  • add_post_media binds the visual each post GETS, as the proposal decided it, not the arguments' spelling: confirm with the same arguments or with the proposal's confirmItems (the same visuals, named concretely). A recommended pick 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.

json
{ "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:

  1. 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_post or update_post) fails closed rather than publishing something the human never saw.
  2. 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.
  3. The publish itself is guarded twice: a stored LinkedIn URN short-circuits to result: "already_published" without contacting LinkedIn, and an in-flight lock returns conflict. There is no force/override argument.
  4. 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.
  5. Success returns result: "published" with the LinkedIn URN.

Operational edges:

  • postId is the public post_… id — the one every other tool returns and accepts. The internal database id is not an input anywhere on this surface; passing one is not_found like any other unknown id. result.postId comes 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_limited on 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 thumbnailKey LinkedIn 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:

jsonc
{
  "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 havebranch
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 handleneither — 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:

jsonc
// 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 lists READY rows only) shows it once they finish.
  • Two ways in, exactly one per call. content_base64 is simpler for SMALL files (≤ ~3 MB decoded — anything larger dies at the platform edge as a bare HTTP 413, so pre-check the size). Pass sha256 of the decoded bytes alongside when the host can compute it; a mismatch is rejected instead of storing corrupted bytes. uploadId — an upl_… handle from create_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 a 422 that 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:

json
{
  "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:

AskTool
"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:

json
"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:

json
{
  "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:

fieldwords it acceptsstored as
postLengthPreferenceshort | standard | long1 | 2 | 3
formattingPreference (sentence length)short | standard | long3 | 0 | 2
emojiPreferencenone | moderate | frequent1 | 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_positioning writes 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 via overwrite: true, which returns a PROPOSAL naming each field with its current and new text and writes nothing until the token is replayed. Without overwrite, such a field comes back in needsConfirmation with its current text — not a refusal, the cue to show the user what a replacement would cost. overwrite is inside the confirmation binding, and replaced returns the previous text once, since positioning has no version history.
  • update_global_tone_of_voice writes 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_eligible names companyToneOfVoice below it). Same empty-vs-occupied rule; only the two TEXT fields ever need overwrite, and overwrite is per-CALL — set it and the whole change set, boolean switches included, becomes two-phase.
  • draft_positioning_from_website reads a company's public site and drafts the positioning documents, returning wouldFill (empty fields — a direct write) and wouldReplace (fields that would go through the overwrite confirmation) in update_positioning's own field names. It persists nothing — nothing changes until you call update_positioning with 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_dna writes 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), credibility points, opinions & hot takes, and personal stories (the list sections hold one entry per line, exactly as the dashboard stores them). projectId is 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 same overwrite confirmation, needsConfirmation/current report and one-time replaced return. The read side is get_personal_dna, which does fall back to the default project and echoes project { 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:

json
{
  "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:

typewrites asprofile it writes fromtone of voice
PERSONAL_BRAND / AMPLIFIERthe person, in the first personthe connected LinkedIn profilethe project's personal tone of voice
COMPANY_PAGEthe company, as "we"the LinkedIn organisation profile — name, description, industries, specialties, positioningthe 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 is delete_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 on get_source);
  • the legacy per-project pillars array get_settings still 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.

  • topic is the wct_… 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.
  • profiles takes, per entry, a proj_… id or the brand's exact display name as list_projects shows it, under the same comparison. Every resolved brand is re-authorized like create_content_topic's profileIds, and amplifiers are refused.
  • An unknown title or name answers not_found with the candidates in details.candidates; a title or name shared by two rows answers invalid_request with details.reason ambiguous_topic / ambiguous_profile and 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 unprocessable with details.reason topic_profile_limit and the limit, current, remaining and requested counts, 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 — a kb_… document from list_knowledge. It is held to the same tenant rule the dashboard applies: the authorized brand's own documents and its workspace's; anything else answers not_found. When given, text is 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":

jsonc
// 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:

jsonc
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: "…" }
statusMeaning
addedon the list now
already_memberwas on the list already — a success; nothing was duplicated
refusednot added; error carries the same code, message and reason add_profile_to_list would answer
duplicatethe same person appears earlier in this call (sameAs is its index)
not_attemptednot 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…AnswerWhat to do
answered positively that there is no such profilenot_foundfix the URL
gave its one soft-failure answer, which it gives for a wrong address AND for a brief overload alikeunprocessable, 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 errorservice_unavailableretry 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:

BoundAnswer when hit
Per-list membership capconflict, naming the cap
Per-brand daily additions (100), across all of its listsconflict, 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 carriesrate_limited, with retry_after in seconds
Scripe's global profile-lookup budgetservice_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.

ToolScopeRate bucketBehind the gate
get_planning_overviewcalendar:readreadno
get_profile_planning_contextcalendar:readreadno
get_planning_signalscalendar:readanalytics_fanout (10/min)no
check_plan_ideasideas:readreadno
search_knowledgeknowledge:readreadno
read_knowledge_documentknowledge:readreadno
list_waiting_for_meposts:readreadno
preview_planideas:writereadyes
commit_planideas:writewriteyes
draft_planned_postsposts:writewriteyes
get_draft_runposts:readreadno
set_up_team_engagementposts:write + projects:readjobno (Advanced+ plan)
request_reviewposts:writewriteyes
get_post_media_optionsposts:read + media:readreadno
add_post_mediaposts:write + media:read (+ media:write)jobyes
check_post_readinessposts:readreadno
tag_post_mentionsposts:writewriteyes

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, optional profileIds (≤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 (with planFrom, daysLeft and the workspace's goalPeriodWeeks), ideasReserveSlots, workspace (reviewRequired, the campaigns running in the range, waitingForYou: { reviews, questions }), and per profile: LinkedIn health, the goal (weekly, and per lane target / done / todo), the slots (total, empty, planned, draft, scheduled, published, the open slots with at / local / lane, and the plannedIdeas), the idea stock per lane (For You and board) and gaps (no_posting_schedule, cannot_publish, goal_exceeds_slots_by_N, no_<lane>_ideas). workspace.repeatedThemes names 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 rule preview_plan's quality warnings judge by). Concise lists up to 24 open and planned slots per profile (listsTruncated says when cut); full lists 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 in failedProfileIds, never left out silently.
  • get_profile_planning_context — input: profileIds (1–10, takes precedence) or projectId (optional, default profile), detail (concise | full; several profiles default to concise, one to full) and the range. Output, once for the call: detail, companyPositioning, postFormats (the post format keys preview_plan accepts as postType, per lane) and the campaigns running in range with their brief. Then profiles[], every block size-bounded by detail: profile, range, positioning (the profile's own person document: intro, credibility, opinions, stories), targetAudience (the profile's own override, else the workspace's — source says which), toneOfVoice, contentTopics, postHistory (90-day window: per-lane post counts, the top posts, and the recentOpenings an idea must not repeat; medians and lifts are get_planning_signals' alone), knowledge (folders with their last sync, the newest documents with inRange marking ones created inside the range), its campaigns (with the brief; a concise call carries each brief once, at the top), and ideas (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: true says the idea stock, list tails or long texts were cut — ask for fewer profiles.
  • get_planning_signals — input: profileIds (1–10) or projectId (omitted = every profile the user can reach, the first 10 read and the rest named in omittedProfileIds), include (any of niche, 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) and detail (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's nicheBasis says profile, or trending when it has no positioning signal), sourceItems[] (title, source and kind, date, link, excerpt, and per following profile its scan similarity and the forYouIdeaId it became) with sourceItemsTotal, and per profile performance (posts and median engagement / views over 90 days; byFormat with lane, byLane, byMedia, byTopic — content topics are the built-in topic labels — each with posts, medianEngagement and lift against the profile's own median, headed by summary: 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) and board (unplaced board ideas), plus knowledge[] (document id, name, kind, folder, date, excerpt — the document's opening, or a recording's or interview's chapters — and profileIds; newest first, each listed once, read whole with read_knowledge_document; no excerpt when the document is in knowledgeSummaries) and knowledgeSummaries[] (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: source chapters with every chapter's title and summary, or meeting_summary with a Granola meeting's own summary; each listed once with profileIds; stored at ingest, no model call). ideasPerLane says what was applied; when the per-call cap of 60 For You ideas lowered it, ideasPerLaneNote says how to get the rest with an ideas-only call. A section that could not be read for a profile is named in its unavailable list and never fails the call. Every answer stays under a host's 50,000-character inline limit: trimmed: true says 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 of performance.
  • search_knowledge — input: query (2–500 chars), projectId (optional), folderId, documentIds (≤20 kb_…), 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 is list_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, url into the post editor) and questions[] (question, why, its idea, profile, when asked, url into the idea page). The same conditions as the home page's "Waiting for your review". Approving stays in Scripe; answer_content_question answers a question the user dictates.
  • preview_plan — input: items[] (1–60), each with projectId (required per item), slotAt (an open slot's at), exactly one idea — idea the agent wrote (title, optional hook, angle, notes, postType or lane; the lane defaults to the slot's), ideaId (a board idea of that profile) or forYouIdeaId — with forYouIdeaId optionally overrides (hook, angle, notes: the agent's own take on that For You idea; refused beside anything else) — and an optional campaignId. 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 a key, the slot (at, local, lane), the idea, status (ready | conflict | invalid) with a reason, the For You idea's angle (the agent's override when it wrote one, edited: true), and warnings (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, optional itemKeys (default: every ready item). Only the user who previewed the plan, in the same workspace, can commit it; an expired or foreign plan is not_found. Every committed item becomes a board idea — created from the agent's text, saved from For You (with the agent's overrides written 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 as conflict (that idea stays on the board, unplanned). Output: results[] (planned | conflict | skipped | failed, the ideaId, whether it was createdIdea), a summary, calendarLinks per profile, and replayed: true when the call only repeated an earlier commit. A commit that runs out of time reports the items it never reached as failed ("Not tried"); calling it again with the same planId writes only what is still missing and reuses an idea an earlier attempt already created, never a second copy. A commit already running answers conflict (details.reason: "commit_in_progress").
  • draft_planned_posts — input: mode (scripe, the default, or agent_text), items[] (1–20), each a planned ideaId, plus optional teamEngagement. It writes drafts and nothing more: no review request and no visual (captain, 2026-10-10). Once the drafts exist, add_post_media and request_review each show the user a proposal and act only after their yes.
    • In scripe mode an item takes the agent's research for 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 brief notes for the writer — never the post itself (an item carrying content fails with a reason). research is 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, each title, optional url and the excerpt relied on) and notes. 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 item writing with a runId — and the posts appear as the run finishes; poll get_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 answers already_drafted.
    • In agent_text mode each item carries exactly one of content (the post text, ≤3,000 chars, optional title) or postId (an existing unscheduled post of the profile) and lands through fill_calendar_slot's rules — for when the user asked the agent to write the text itself. A postId item 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 | failed with postId, slotAt, profileId, a url and the runId, the run (runId, how many posts it writes, how many get team engagement), and a summary.
    • teamEngagement: true needs an Advanced or Business plan (plan_not_eligible below it, before anything is written) and adds set_up_team_engagement's standard engagement to each post as soon as it exists.
  • get_draft_run — input: runId, optional waitSeconds (0–15). Per item: kind (write / media / engage), status, the idea, profile and slot, the postId and url, the opening line the post went out with (hook) and the other hookOptions Scripe generated, polished (whether the voice pass ran), the media put on the post (attached with its format, source, the img_… id and the template Scripe used, or skipped / occupied / failed with why), the engagement it added (likes, comments, comments that could not be drafted, the teammates, or why nothing was added), and a reason on failure. A stalled item is re-driven when the run is read; one that keeps failing ends failed with a reason. Runs are kept 24 hours and are readable only by the user who started them, in that workspace (not_found otherwise).
  • 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 post queued | failed (a post the caller cannot reach answers like a missing one), and the runId to read with get_draft_run. Composing is not consent: when the post is scheduled each teammate's policy is resolved fail-closed (REQUEST queues an approval in Scripe, OFF never fires). To choose the teammates or write the comments yourself, use update_post_engagements.
  • request_review — input: postIds (1–50), reviewer (a workspace member's user id from list_team, email or exact name), optional reviewDeadline, and on the second call the confirmationToken. 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, a url and a reason. Needs a plan with content approvals (plan_not_eligible below 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 — the reason says it in words and quotes no numbers (performance numbers live in get_planning_signals). recommendation.source is library (a real photo from the media library fits; mediaId names it), scripe (a template visual Scripe makes: QUOTE_CARD, CAROUSEL, INFOGRAPHIC, BILLBOARD), upload (only the person can supply it, e.g. a video) or none; needsPersonPhoto says a real photo of the person would do better than the template it fell back to, needsDirection that 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 a postId and a source: recommended, library with mediaIds (up to 20 images, or ONE carousel/document alone), or scripe with a format and an optional direction (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 the confirmationToken. replace: true swaps 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, and usesAiCredits (a Scripe-made visual spends the workspace's AI credits; a library picture does not), plus confirmItems: the same visuals named concretely, which the confirming call may send as items. 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 run media item (about a minute, in parallel) and lands on the post when done — the answer carries the runId. Output per post: attached | adding | occupied | skipped | failed | not_found, with the img_… 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, each pass | warn | fail with 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, @Name text 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. ready means no check failed; warnings are the reviewer's call.
  • tag_post_mentions — input: postId, mentions[] (1–15), each the text exactly as the post writes it, optional kind (person | company), hint (their company or role) or urn + name for 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 the hint matching exactly one candidate's LinkedIn headline. A name it cannot settle answers ambiguous (with the NUMBER of candidates) or not_found and 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_icp follows the positioning writes (§2.10). Admin-only; an empty field is written directly; replacing a field that holds a value comes back in needsConfirmation with its current text, and overwrite: true returns a proposal and a confirmationToken. There is no version history, so the replaced values are returned once, in replaced. Every write confirms the profile; a write into one Scripe had only suggested says so in confirmedSuggestion.
  • Project resolution. get_icp reads the workspace's ICP; a projectId adds 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.

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_link call per person, each a link of its own with its own click count. The short code is generated; custom slugs are not supported. name labels the link in Scripe.
  • For a post, put the PLACEMENT's URL in the post. With postId the 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 (also urlToShare) is the URL to write into the post with update_post; its clicks count toward the link. projectId defaults to the post's profile and must match it.
  • Clicks. list_tracked_links reports clicks per 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:write to create, analytics:read to 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 answer plan_not_eligible, as the dashboard's Links page locks. A project or post outside the workspace answers not_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.

text
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_…, from list_posts or a list_post_analytics row's post_id) or linkedInPost: the post's LinkedIn URL as the user pasted it (…/feed/update/urn:li:activity:…/ or …/posts/…-activity-…) or its URN (a list_post_analytics row's linkedin_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_comments additionally 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 in notAvailable rather than returned from Scripe's stored text. When freshness.state is anything but fresh (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 in notAvailable. 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. Pass nextCursor back as cursor; 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.reactionCount and post.commentCount are 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 answers plan_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:

json
{
  "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.

json
{
  "type": "file",
  "content_base64": "JVBERi0xLjQKJ…",
  "mimeType": "application/pdf",
  "filename": "Q3-strategy.pdf"
}
FieldRequiredNotes
content_base64yesRaw file bytes, base64-encoded. No data: URL prefix.
mimeTypeyese.g. application/pdf, audio/mpeg, image/png, text/plain.
filenameyesDisplay name + extension fallback for content-type detection.
sha256noHex 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:

json
{
  "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: 1 when the job reaches a terminal state (DONE/FAILED/CANCELLED). total: 1 is 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 — call cancel_job explicitly 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 RUNNING job. The job continues in the background: read data.id and poll get_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. A RUNNING job 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 templateBodyRequired scope
scripe://workspace/meWorkspace JSON (includes defaultProject)workspace:read
scripe://project/{id}Project JSONprojects:read
scripe://post/{id}Post JSONposts:read
scripe://note/{id}Note JSONnotes:read
scripe://source/{id}Source JSON (includes topics[] once processed — hooks[] stays empty on API-created sources)sources:read
scripe://job/{id}Job JSONjobs: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.

PromptArgumentsPurpose
weekly_planningprojectId?, weeksOut?Plan the next week(s) of LinkedIn content — renders plan_content with weeksAhead = weeksOut (1–13), so the two never teach different flows.
repurposesourceRef (a note_*, src_*, or post_* id), format?, projectId?Turn an existing asset into 1–3 new drafts, staying tonally consistent with recent posts.
inbox_zeroprojectId?, ageDays?Triage stale notes and failed jobs. Clusters by theme and confirms each batch.
plan_contentprojectId?, 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_calendarprojectId?, 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:

json
{ "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, and scheduledFor as an absolute UTC instant, with scheduledForLocal rendering 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 at scheduledForLocal, 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_post returns 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.