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

# Mcp tools

# MCP tools & contracts

The full catalogue of the Scripe MCP server's 76 tools (39 reads, 37
writes including `switch_workspace`), 6 resources, and 3 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](./mcp.md).

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 (two-phase `schedule_post` and
`publish_post`, the destroy verbs, the settings, posting-schedule and
positioning writes, `create_content_topic`, `generate_post_ideas`, the
community profile-list trio, and
the per-post auto-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](./mcp.md#22-the-default-project-and-which-tools-fall-back-to-it).

***

## 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` also advertises
`readOnlyHint: true` but is listed with the write family in §2.10,
because it exists to feed a write.)

| Tool                                     | Scope                               | Returns                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ---------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get_workspace`                          | `workspace:read`                    | The active workspace + plan + principal, including `defaultProject: { id, name } \| null`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `list_workspaces`                        | `workspace:read`                    | Every workspace the user can reach (`id`, `name`, `isDefault`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `get_workspace_context`                  | `workspace:read`                    | One-call overview: plan + features, review-workflow gate, up to 25 projects each with its `linkedIn` connection block (§1.4) and posting streak.                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `list_team`                              | `workspace:read`                    | Team members (≤100; `truncated` + `totalCount` when more): name, email, role (the dashboard's label, e.g. `Admin`, `Client`), per-member project assignments capped at 25 distinct projects and at the caller's own reach. Never returns credentials.                                                                                                                                                                                                                                                                                                                                               |
| `list_projects`                          | `projects:read`                     | Paginated list of projects. Every row carries `linkedIn` — whether Scripe can publish as it right now (§1.4).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `get_project`                            | `projects:read`                     | Single project read, with the same `linkedIn` block (§1.4).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `list_company_pages`                     | `projects:read`                     | Company pages — the delta over `list_projects`: activation status, STORED admin-token health, follower count, engagement-policy state.                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `list_notes`                             | `notes:read`                        | List notes for a project (date / cursor + free-text `q` search, §1.5). Reports `pagination.total` (§1.6).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `get_note`                               | `notes:read`                        | Single note read.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `list_posts`                             | `posts:read`                        | List posts for a project — `status` / `statusCategory` / `statusId` / `review` / `delivery` filters, `q` search, creation and publication date windows, `content` modes. Rows carry the `delivery` (§1.2) and `review` (§1.3) verdicts and `pagination.total` (§1.6).                                                                                                                                                                                                                                                                                                                               |
| `get_post`                               | `posts:read`                        | Single post read — the whole body, which `list_posts` only excerpts (§1.7).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `list_post_statuses`                     | `posts:read`                        | The pipeline: every kanban column with `postCount`, plus a `counts` rollup for the project (§1.8). Each row's `id` is the `statusId` both `update_post` and `list_posts` take.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `list_calendar`                          | `calendar:read`                     | A project's content calendar for a date range (max 92 days): scheduled/planned posts with workflow status + any last publish error, note slots, idea placements, the posting-time template. Days are interpreted in the project's calendar timezone.                                                                                                                                                                                                                                                                                                                                                |
| `get_next_free_slot`                     | `calendar:read`                     | The next free posting slot, spelled out (`localTime`, `date`, `time`, `utcOffset` beside `iso`/`timezone`, plus `source: template \| fallback`) — the same computation the dashboard and chat scheduling use (§1.13).                                                                                                                                                                                                                                                                                                                                                                               |
| `list_ideas`                             | `ideas:read`                        | Paginated compact idea-board list: brief fields, the DERIVED workflow status (`inbox \| in_production \| review \| done`), calendar placement, whether the idea is waiting on someone's input. Responses echo `project { id, name, fromDefault }`.                                                                                                                                                                                                                                                                                                                                                  |
| `get_idea`                               | `ideas:read`                        | One idea in full: creative brief, capture evidence, the idea's `media` (attached images / a carousel cover with `img_…` ids, or a reference-only style pick — the display URLs additionally need `media:read`, §2.4), AI readiness with open questions, attached sources, linked post. Same `project` echo.                                                                                                                                                                                                                                                                                         |
| `list_sources`                           | `sources:read`                      | Paginated source (transcription) list — previews only; fetch a single source for its topics.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `get_source`                             | `sources:read`                      | Single source read. Includes `topics[]`, best-first by `score`, once `status: "Success"`. Pass a topic id to `generate_post` (§8); `hooks[]` is a dashboard artefact and is empty here.                                                                                                                                                                                                                                                                                                                                                                                                             |
| `list_knowledge`                         | `knowledge:read`                    | Paginated knowledge-document list visible to a project (workspace docs honour assignment visibility; ephemeral chat uploads never appear).                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `get_knowledge_doc`                      | `knowledge:read`                    | One knowledge document: metadata, status, tags, chunk count, 2,000-char preview — never the full body.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `search_media`                           | `media:read`                        | Search the project's reachable media-library images (owned + profile-assigned, READY only): display URLs, tags, provenance.                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `get_settings`                           | `workspace:read`                    | Curated project settings: generation-context knobs with word labels (§2.8), project tone of voice, org custom-instruction state, calendar settings including the recurring posting-time template (`calendar.schedule`, §2.7), content pillars. Structured fields only — never the rendered prompt context.                                                                                                                                                                                                                                                                                          |
| `get_engagement_policy`                  | `workspace:read`                    | Engagement defaults (`OFF \| REQUEST \| ON`), per-requester overrides, stored connection state, plan-lock state. Answer from `effective`, not `policy` — §1.15.                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `get_post_engagements`                   | `posts:read` + `projects:read`      | One POST's auto-engagement timeline (the editor's auto-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_positioning`                        | `workspace:read`                    | The WORKSPACE's positioning, shared by every brand in it: the company document (description, mission, vision, core statements, audience line, promised results, synced website), the target-audience document, and the organization-wide tone of voice with its plan-lock state. Distinct from `get_settings`, which is per-project.                                                                                                                                                                                                                                                                |
| `get_personal_dna`                       | `workspace:read`                    | The BRAND's personal DNA — the per-project person document: 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-brand, unlike the workspace-wide `get_positioning`. §2.10.                                                                                                                                                                                                                                                                   |
| `list_profile_lists`                     | `community:read`                    | The brand's community profile lists — the named watchlists of LinkedIn people whose posts Scripe collects, under Community → Profiles. Returns each list's `plist_…` id, name, description and member count. `projectId` optional (default-project fallback, `project` echo on the response). The only way to name a list; call it before `get_profile_list` or `add_profile_to_list`. §2.16.                                                                                                                                                                                                       |
| `get_profile_list`                       | `community:read`                    | One list with its members: name, headline, LinkedIn profile URL, follower count, the note whoever added them left, whether they are muted, when Scripe last checked them, and how many of their posts Scripe holds. `postsCollected: 0` is an ordinary state, not a failure. §2.16.                                                                                                                                                                                                                                                                                                                 |
| `get_usage`                              | `workspace:read`                    | Usage and limits: the weekly AI budget as a percentage, storage, today's API AI-spend cap, and `canGenerate` — whether the next AI job would be accepted at all. The pre-flight for both 402s (§1.16).                                                                                                                                                                                                                                                                                                                                                                                              |
| `get_analytics_overview`                 | `analytics:read`                    | Current vs previous-period rollup (posts, impressions, engagement rate, follower growth, activity).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `list_post_analytics`                    | `analytics:read`                    | Per-post LinkedIn metrics (views, likes, comments, shares, engagement rate) for posts already live on LinkedIn, newest first. Offset-paginated. For one specific post pass `postId` (§1.10) — never scan a date window. Body is an excerpt by default (`content` modes).                                                                                                                                                                                                                                                                                                                            |
| `search_viral_posts`                     | `analytics:read`                    | Semantic search over the inspiration feed for high-performing posts on a topic (excludes your own author). **Outliers only by default** — posts that beat their own author's median by 2x or more — in the project's language, above `minEngagement`, inside `publishedWithinDays` (90 by default), optionally narrowed to a media shape with `mediaFormats` (§1.11).                                                                                                                                                                                                                               |
| `get_analytics_report`                   | `analytics:read`                    | Full report as JSON: executive summary + earned media value, per-author performance, the period's top 20 posts by impressions (`postsTotal` counts every post; `list_post_analytics` pages the rest), daily engagement series and the three click figures (§1.12), follower timeline. (REST's `GET /v1/analytics/report` renders the same figures as PDF/CSV with the period's posts, row-capped — see [Analytics](./analytics.md#semantics-worth-knowing).)                                                                                                                                        |
| `get_cross_workspace_analytics_overview` | `analytics:read` + `workspace:read` | The same rollup as `get_analytics_overview`, for EVERY workspace the connection can reach, in one call — the tool for "how are all my clients doing", instead of a `switch_workspace` loop. Each row names the workspace it belongs to and the grant that reaches it; `data.totals` is one query over the union, not a sum (§1.17).                                                                                                                                                                                                                                                                 |
| `get_cross_workspace_analytics_report`   | `analytics:read` + `workspace:read` | The cross-workspace twin of `get_analytics_report`: one whole report per reachable workspace. Visits fewer workspaces per call than the overview and keeps fewer top posts each, because it returns a document per workspace (§1.17).                                                                                                                                                                                                                                                                                                                                                               |
| `get_posting_times`                      | `analytics:read`                    | The project's own best posting times (hourly + weekday×hour buckets), on the project's calendar clock by default and carrying only the slots it has actually posted in (§1.14). Plan-gated — lower plans get `plan_not_eligible` with an upgrade pointer.                                                                                                                                                                                                                                                                                                                                           |
| `list_jobs`                              | `jobs:read`                         | List async jobs (paginated).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `get_job`                                | `jobs:read`                         | Single job read with progress snapshot.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

The subsections below are the read contracts an integrator (or an
agent) needs to answer a user correctly. Each is shaped around the ask
it serves.

### 1.1 A post's status: two vocabularies, one truth

Every post row carries several status fields, because Scripe has two
vocabularies for the same thing:

| Field            | Vocabulary     | What it is                                                                                                           |
| ---------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- |
| `status`         | lifecycle enum | `waitingProcessing \| draft \| waitingApproval \| approved \| rejected \| published \| scheduled \| suggested`       |
| `statusCategory` | kanban         | `suggested \| draft \| inProgress \| review \| scheduled \| published` — what `list_post_statuses` returns           |
| `statusId`       | kanban         | The workspace column the post sits in; `update_post` moves a post to it and `list_posts({ statusId })` filters by it |
| `statusTitle`    | kanban         | Its human name, e.g. `Good to go`                                                                                    |
| `publishedAt`    | timestamp      | When the LinkedIn copy went live — the only field that dates a publish (§1.9)                                        |

The truth is the **custom status** (`statusId`), because that is what
the product writes: publishing sets it in one indivisible UPDATE and
the scheduler does the same. `status` is **derived** from that status's
category, and only falls back to the stored column for a post that
carries no custom status at all. (Until 2026-08 this field served the
stored column directly, and nothing had written `published` to it since
2025-12 — so every post published in that window read back `draft`, and
`list_posts({ status: "published" })` answered an empty list.)

Two consequences worth knowing before you answer a user:

* **`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 theme={null}
get_post({ postId: "post_9f4a…" })
→ { "data": { "status": "scheduled",
              "scheduledAt": "2026-08-11T06:00:00.000Z",
              "lastPublishError": "LinkedIn access for this brand was revoked, so this post was not published. Reconnect your LinkedIn account to resume publishing.",
              "delivery": {
                "state": "missed",
                "willRetry": false,
                "retryUntil": "2026-08-12T06:00:00.000Z",
                "detail": "This post never went out. The last attempt failed: … The scheduler stopped retrying at 2026-08-12T06:00:00.000Z (24h after the scheduled time). Fix the cause, then re-schedule it with schedule_post." } } }
```

| `state`         | Means                                                                               | Will anything try again? |
| --------------- | ----------------------------------------------------------------------------------- | ------------------------ |
| `not_scheduled` | In a non-scheduled column. A `scheduledAt` here is a leftover intention.            | No                       |
| `scheduled`     | Future time, armed.                                                                 | Yes                      |
| `due`           | Time has passed, inside the retry window — publishing now, or failing and retrying. | Yes                      |
| `missed`        | Past the window (or no time at all). Nothing will pick it up again.                 | **No**                   |
| `published`     | It 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 theme={null}
list_posts({ review: "pending", content: "none" })
→ { "data": [ { "id": "post_…", "statusCategory": "review",
                "review": {
                  "state": "pending",
                  "awaitingDecision": true,
                  "reviewerUserId": "user_33O0iUlcQyhyVo5FJG3mIcZ0y2d",
                  "deadline": "2026-07-30T22:00:00.000Z",
                  "overdue": true,
                  "decidedAt": null,
                  "notes": null,
                  "requiredByWorkspace": false,
                  "detail": "Waiting for a decision from user_33O0… , who was asked to review it by 2026-07-30T22:00:00.000Z — that deadline has passed. Approvals happen in the Scripe dashboard; nothing on this API can decide it." } } ],
    "pagination": { "total": 1, … } }
```

| `state`         | Waiting on someone? | Means                                                    |
| --------------- | ------------------- | -------------------------------------------------------- |
| `not_requested` | no                  | no review is open at the post's current board column     |
| `pending`       | **yes**             | a reviewer was asked and has not decided                 |
| `approved`      | no                  | signed off — it does not move on by itself               |
| `rejected`      | no                  | sent back; `notes` carries the reason when one was given |

* **A `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](./posts.md).

### 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 theme={null}
get_project({})
→ { "data": { "id": "proj_…", "name": "Luke Matthews",
              "linkedIn": {
                "state": "revoked",
                "canPublish": false,
                "connectionStatus": "REVOKED",
                "lastValidatedAt": "2026-06-02T08:11:00.000Z",
                "accessTokenExpiresAt": null,
                "detail": "Scripe's LinkedIn authorization for this project was revoked on 2026-07-30T08:00:00.000Z: … Nothing will publish as this project until someone reconnects LinkedIn in Scripe. Reconnecting cannot be done through the API.",
                "reconnectUrl": "https://app.scripe.io/oauth?projectId=…&reconnect=true" } } }
```

| `state`         | Publishes? | Means                                                                            |
| --------------- | ---------- | -------------------------------------------------------------------------------- |
| `connected`     | yes        | a token is on file and nothing is wrong                                          |
| `expiring_soon` | yes        | the access token lapses within 14 days (or already has) and has not been renewed |
| `degraded`      | yes        | recent LinkedIn calls failed; three consecutive failures revoke the connection   |
| `disconnected`  | **no**     | no usable token — never connected, or the token was cleared                      |
| `revoked`       | **no**     | the authorization was withdrawn                                                  |

* **Branch on `canPublish` / `state`, never 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 searched   | posts: body, title and hook. notes: the body.                                                 |
| Matching          | case- and accent-insensitive **substring**                                                    |
| Multiple words    | AND-ed — every word must appear somewhere in the row                                          |
| `"quoted phrase"` | matched whole                                                                                 |
| `%` and `_`       | matched literally; they are not wildcards to the caller                                       |
| Ordering          | unchanged: newest-first. `q` narrows the page, it does **not** rank by relevance              |
| Limits            | 200 characters, 6 terms; a longer query is a `400 invalid_request`, never a silent truncation |

* **An empty result means the words are absent, not the topic.** This
  is a literal substring match with no stemming and no synonyms:
  `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 theme={null}
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 theme={null}
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 theme={null}
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.
* **`publishedAt: null` on a published post means "moments ago".** The
  date comes from LinkedIn's own measurement row, which lands within
  the hour. It is never guessed from the schedule.
* `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:

| field                            | is                                    | accepted by                                                               |
| -------------------------------- | ------------------------------------- | ------------------------------------------------------------------------- |
| `id`                             | the analytics measurement row         | **nothing**                                                               |
| `post_id`                        | the Scripe post, `post_…`, or `null`  | `get_post`, `update_post`, `schedule_post`, `publish_post`, `delete_post` |
| `linkedin_post_id` / `permalink` | the LinkedIn activity URN and its URL | LinkedIn itself                                                           |

`post_id` is `null` for any post not published through Scripe —
imported history, posts written directly on LinkedIn. That is most of a
mature account's back catalogue, and it is the honest answer rather
than an error. Use `permalink` to show the user the post, and `content`
(with `content: "full"`) as source text for a `generate_post` rewrite.

When the user asks about **one post**, pass the id:

```jsonc theme={null}
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 cannot work at all for a post published minutes
  ago, whose `publishedAt` is still null.
* **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:

| Field                    | Source                                                                      | Attributed by                                                                                                   | Dashboard surface                                                                  |
| ------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `totalLinkClicks`        | LinkedIn `LINK_CLICKS` — clicks on any link inside a post                   | the post's publish date                                                                                         | the *Link clicks* KPI card; `posts[].linkClicks` per post                          |
| `totalTrackedLinkClicks` | Scripe click tracking — the workspace's tracked links                       | the click's own date, **workspace-wide** (tracked links are workspace-owned, so `projectId` does not narrow it) | the conversion funnel's *Tracked link clicks* stage and the Click tracking section |
| `totalPremiumCtaClicks`  | LinkedIn `PREMIUM_CTA_CLICKS` — the profile's Premium call-to-action button | the post's publish date                                                                                         | the *Premium CTA clicks* card; `posts[].premiumCtaClicks` per post                 |

A reading off the funnel's *Tracked link clicks* stage (labelled *Link
clicks* before SCR-1719) is `totalTrackedLinkClicks` and nothing else;
one off the *Link clicks* KPI card is `totalLinkClicks`. Neither is a
component of the other, and `totalPremiumCtaClicks` belongs to neither.
`totalTrackedLinkClicks` is `null` only when a report is built without
a workspace, which never happens on this surface.

### 1.13 "When's my next slot?" — say `localTime`, and ask again for the next one

`get_next_free_slot` is the one tool whose answer IS a time said out
loud, so it hands back the resolved wall clock rather than the inputs
to a conversion:

| Field           | Meaning                                                                                                                                                                         |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `iso`           | The slot as an absolute UTC instant.                                                                                                                                            |
| `timezone`      | The IANA zone the local fields are in.                                                                                                                                          |
| `date` / `time` | The slot on the project's own clock — `2026-08-15`, `09:00`.                                                                                                                    |
| `utcOffset`     | That zone's offset at this instant, e.g. `+02:00`.                                                                                                                              |
| `localTime`     | `Sat, 15 Aug 2026 09:00 (Europe/Berlin)` — quote this.                                                                                                                          |
| `source`        | `template` = a configured posting-time slot. `fallback` = the project has no posting schedule (or none of it was free), so 09:00 on the first open day is a suggestion; say so. |

* **Do not convert `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:

| Field            | Meaning                                                                                                                   |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `timeZone`       | The zone the `hourly` / `weekly` buckets are expressed in.                                                                |
| `timeZoneSource` | `project` when it came from the project's calendar setting (the default), `caller` when the `timeZone` argument chose it. |

The default is the **project's calendar timezone** — the same zone
`list_calendar` renders days in and `get_next_free_slot` reports. Quote
`timeZone` whenever you name an hour back to a user. Pass `timeZone`
only to deliberately answer for a different audience; it must be a full
IANA name, and a name the server cannot resolve is rejected with
`invalid_request` rather than silently answered in UTC.

**An absent hour was never tried.** Only slots with `count > 0` are
returned — a bucket the project has never posted in carries no
measurement, so serving it as `{count: 0}` would claim the hour
performs badly. Say it is untested; and when `totalPosts` is small
(below roughly three posts per slot), say the signal is thin rather
than naming a best time.

### 1.15 "Is anything engaging as me?" — read `effective`, not `policy`

`get_engagement_policy` returns two shapes of the same three actions,
and only one of them answers the question:

| Field       | Meaning                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| `policy`    | The stored `EngagementPolicy` row, or `null` when the project has none. |
| `effective` | What actually happens right now, always present, plus `source`.         |

`policy: null` is the **normal** state — roughly 98% of production
projects have no row — and it is not "unconfigured, so anything might
happen": activation fails closed, so an actor with no policy row is
declined. `effective` reports that as all-OFF with
`source: "no_policy"`, versus `source: "policy"` for stored defaults.
Answer the user from `effective` and never infer from a null.

The distinction is load-bearing. "Never answered" (`no_policy`) can
still be seeded with the entitled defaults when the workspace upgrades
its plan or reconnects LinkedIn; "answered, and said no" (a stored
all-OFF row) may never be promoted back by anything. That is why
`update_engagement_policy` **writes no row** when it disarms a project
that has none: the ask is already true, and the placeholder would
silently remove the project from seeding forever. The proposal says
exactly that in its `effect` field before the user confirms.

One direction only: the write can lower a default to `OFF` or
`REQUEST`, never raise one to `ON`. A project you disarm here cannot be
re-armed here — that is a dashboard action, and the proposal says so.

### 1.16 "Can I still generate posts this week?" — ask before you spend

Two 402s can refuse any AI verb (`generate_post`, `generate_image`,
`generate_carousel`, `add_to_knowledge_base`, `create_source_file`):

| Error                  | Limit                                       | Resets           |
| ---------------------- | ------------------------------------------- | ---------------- |
| `usage_limit_exceeded` | The weekly AI budget — the primary limit    | Monday 00:00 UTC |
| `spend_cap_exceeded`   | The daily API AI-spend cap — an abuse valve | 00:00 UTC        |

`get_usage` reports both before either fires — full field reference on
[Usage and limits](./usage.md).

* **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](./analytics.md#across-every-workspace-you-can-reach)).
* **`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 `projectId`s.
* **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

| Tool                             | Scope                                                 | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `switch_workspace`               | `workspace:read`                                      | Sync. Moves the connection's active workspace (mutates connection state, not data — idempotent). See [MCP § Workspaces](./mcp.md#3-workspaces-and-projects).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `create_note`                    | `notes:write`                                         | Sync. Optional `date` places the note's calendar slot.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `update_note`                    | `notes:write`                                         | Sync. Edit body and/or move between folders (`folderId: null` = root). Calendar placement is not writable here.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `delete_note`                    | `notes:write` (archive) / `notes:destroy` (permanent) | DEFAULT is the reversible archive — no confirmation. `permanent: true` removes the note **and its paired calendar slot** forever; that path is **two-phase** and requires `notes:destroy`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `create_post_draft`              | `posts:write`                                         | Sync. Pass a future `scheduledFor` to schedule, omit for an unsent draft. With `scheduledFor` set, the LinkedIn connection is verified first — a dead connection fails with `conflict` (`details.reason` + `reconnectUrl`) and creates nothing — and a workspace that requires review refuses the create-and-schedule shortcut with `conflict` (§1.3): create the draft, get it approved, then `schedule_post`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `update_post`                    | `posts:write`                                         | Sync. Edit content/title/contentType, set status (`statusCategory`/`statusId`), unschedule via `scheduledFor: null`. Setting a schedule lives on `schedule_post` — a non-null `scheduledFor` here returns a teaching error.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `schedule_post`                  | `posts:write`                                         | **Two-phase.** Proposal + token first; replay executes. The proposal resolves the time (§8, "Schedule") and runs a live LinkedIn pre-flight; execution re-verifies, then queues the post (live \~2 min after the scheduled time). Re-scheduling re-arms configured auto-engagement against the new time. In a workspace that requires review, BOTH phases refuse a post without an approved review with `conflict` (§1.3).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `attach_media_to_post`           | `posts:write` + `media:read`                          | Full-state media write onto a post — §2.4 for the two accepted branches. `kind: "none"` clears.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `create_media_asset`             | `media:write`                                         | Sync. The customer's OWN image into the project's media library, from inline `content_base64` (small files, ≤ \~3 MB decoded; optional `sha256` integrity check) or a two-step `uploadId`. Returns the `img_…` id `attach_media_to_post` takes. §2.5.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `delete_media_asset`             | `media:destroy`                                       | **Two-phase.** Removes an image from the media library — the dashboard's tombstone delete: gone from search and every new attachment, while posts/carousels already carrying it keep it for **30 days** (they hold their own copy of the stored file), after which the retention sweeps erase the stored file, every earlier version of it and the CDN copy, and those references 404. A LinkedIn-synced photo is never re-imported. Re-sending the identical bytes to `create_media_asset` restores it. §2.5.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `delete_post`                    | `posts:destroy`                                       | **Two-phase and irreversible.** The only way to remove a post — posts have no archive. The proposal previews the cascade and whether the LinkedIn copy survives. §2.6.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `publish_post`                   | `posts:publish`                                       | **Two-phase and irreversible.** Publishes an existing post to LinkedIn *now*. Never implied by `posts:write` or the `write` alias. Capped at 5 agent publishes per user per rolling 24 h. In a workspace that requires review, both phases refuse a post without an approved review with `conflict` (§1.3). §2.3.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `create_source_text`             | `sources:write`                                       | Sync. Returns a `Source` envelope.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `create_upload_url`              | `sources:write`                                       | Sync. Returns a presigned S3 PUT URL — `openWorldHint: true`. The `upl_…` handle it returns is consumed by `create_media_asset`, `create_source_file` or `add_to_knowledge_base`; it is never itself a media key. The preferred path for anything over the \~3 MB inline cap (§4).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `create_source_file`             | `sources:write`                                       | **Async.** Returns a `Job` envelope. Accepts a two-step `uploadId` (preferred — a real recording never fits inline) or inline `content_base64` for files under \~3 MB decoded. Streams progress (§5).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `delete_source`                  | `sources:destroy`                                     | **Two-phase and irreversible.** Deletes a source with its transcript, topics + hooks, knowledge-base copy (embeddings included) and stored file, and releases the storage quota. NOT total erasure: posts generated from the source and the usage ledger survive, and the proposal says so. Also on REST as `DELETE /v1/sources/:sourceId`, same two phases. §2.13.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `add_to_knowledge_base`          | `knowledge:write`                                     | **Async.** Accepts text, file (inline or `uploadId`), URL, or YouTube. Omit `projectId` to use the default project; pass `projectId: null` explicitly for a workspace-wide document. Streams progress.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `generate_post`                  | `posts:generate`                                      | **Async.** AI post generation; the worker loads the project's tone, pillars, voice samples, and knowledge base. Sources: text, note, topic, or idea (§8). Streams progress. `scheduledFor` is validated (parseable, in the future) but **schedules nothing** — the generation always lands a draft, so put the returned post on the calendar with two-phase `schedule_post` afterwards. In a workspace that requires review, a non-null `scheduledFor` is refused with `conflict` (§1.3) — generate the draft, get it approved, then `schedule_post`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `generate_image`                 | `media:write`                                         | **Async.** AI image generation into the media library (`kind`: `PHOTO` / `PHOTO_EDIT` / `IMAGE_EDIT`; references and subjects by `img_…` id only). Metered by the AI budget; spend-capped per workspace per day (402 when exhausted). Result carries the new `img_…` id. Streams progress.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `generate_carousel`              | `media:write`                                         | **Async.** AI carousel generation: the model picks a design, storyboards the deck, writes every slide in the project's voice, generates the imagery, stamps the brand identity, renders a PDF. Reserves 45¢ up front, then each image slot's own cost, and reconciles to actuals; a cap that runs out mid-job degrades (stand-in art) rather than fails. Result carries the `img_…` id, a `documentKey` for `attach_media_to_post`'s `document` kind, and `imageSlots` (`{ pending, filled, budgetDeclined, failed }`). Decks run two to three minutes — poll `get_job` rather than sync-waiting.                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `cancel_job`                     | `jobs:cancel`                                         | Sync. Idempotent **on the resource** — a second call leaves the job `CANCELLED` but errors `not_cancellable`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `create_idea`                    | `ideas:write`                                         | Sync. Inserts at the top of the column (board-UI semantics; ordering is never an input). Status accepts only `inbox \| in_production` — `review`/`done` are derived from the linked post. `project` echo on the response.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `update_idea`                    | `ideas:write`                                         | Sync. The board's brief field set; `null` clears a field. Same writable-status restriction.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `schedule_idea`                  | `ideas:write`                                         | Sync. DATE-level board placement (`YYYY-MM-DD`); `null` unplans. Planning only — never creates a post, never schedules to LinkedIn.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `attach_media_to_idea`           | `ideas:write` + `media:read`                          | Full-state media write onto a board idea — library images only (`img_…` ids; one generated carousel may be attached alone). Replaces whatever the idea carried, a reference-mode style pick included. `kind: "none"` clears the media decision, visual format included. §2.4.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `delete_idea`                    | `ideas:destroy`                                       | **Two-phase, destructive.** First call returns a cascade PREVIEW (linked posts kept, open input requests whose public answer links die, note-mirror warning) + token; replay hard-deletes the idea and its child records in one transaction. The preview names the project it resolved to, and the token is bound to it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `delete_knowledge_doc`           | `knowledge:destroy`                                   | **Two-phase, destructive.** Permanently deletes a document plus every chunk/embedding, its visibility assignments, and any stored file — transactionally. Workspace-wide docs additionally need workspace-admin authority. One case refuses: a page of a dashboard sync — §2.11.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `update_tone_of_voice`           | `settings:write`                                      | **Two-phase + admin-only.** Project-level tone-of-voice/context write; the proposal renders the FULL new text next to current values plus a `summary` of the preference changes. Takes words, not magic numbers — §2.8. Org-level tone of voice is `update_global_tone_of_voice`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `update_engagement_policy`       | `settings:write`                                      | **Two-phase + admin-only + direction-restricted.** Sets auto-like/comment/reshare defaults to `OFF` or `REQUEST` only — `ON` returns a teaching error naming the dashboard. All-OFF (disarm) is always allowed and writes no row on a project that has none (§1.15); non-OFF needs an eligible plan. A LinkedIn connection is needed to publish, not to store the policy.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `update_post_engagements`        | `posts:write` + `projects:read`                       | Sync. Add / modify / remove one POST's queued auto-engagements — the editor's panel with its limits enforced server-side: Advanced+ plan (the editor's gate, removal included), actors must be same-workspace, LinkedIn-connected and non-OFF for the action, COMMENT ≤1250 chars, the editor's fixed delays, LIKE/REPOST once per actor, and rows already executed on LinkedIn can be neither edited nor removed (naming one refuses the whole call). Composing is not consent: policies resolve fail-closed at activation, granting a `REQUEST` approval is not available on this API — approvals happen in the Scripe dashboard or the mobile app — and editing an approved row's payload revokes the approval (same rule as the editor). Removing a row permanently deletes the tracked links bound to it, their click history and their redirect-cache entries. `projects:read` is required on top of `posts:write` for the same reason as the read — the response carries the workspace actor inventory, and this verb acts under those identities. |
| `update_posting_schedule`        | `calendar:write`                                      | **Two-phase, full-state.** Replaces the project's RECURRING posting-time template — the weekly slots `get_next_free_slot` suggests from. The proposal renders the resulting week and names every slot that would be REMOVED. §2.7.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `update_positioning`             | `settings:write`                                      | **Admin-only. Empty fields write directly; replacing existing text is two-phase.** Writes the WORKSPACE's company and target-audience documents. §2.10.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `update_global_tone_of_voice`    | `settings:write`                                      | **Admin-only + Business plan.** The ORGANIZATION-wide tone of voice: custom instructions injected into every generation for every brand, the shared footer, and whether brands may add instructions of their own. Same empty-vs-occupied rule. §2.10.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `draft_positioning_from_website` | `settings:write`                                      | **Website sync that PERSISTS NOTHING** (`readOnlyHint: true`, `openWorldHint: true`). Crawls a public site and drafts positioning text for `update_positioning` to write. §2.10.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `update_personal_dna`            | `settings:write`                                      | **Admin-only. Empty fields write directly; replacing existing text is two-phase.** Writes ONE brand's personal DNA (person document: `intro`, `credibility`, `opinions`, `stories`); `projectId` required. §2.10.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `create_content_topic`           | `settings:write`                                      | **Admin-only.** Sync. Adds one content topic — the list under Settings → Organization → Positioning — to the WORKSPACE, optionally assigning it to brands with `profileIds`. §2.14.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `add_profile_to_list`            | `community:write`                                     | Sync. Adds ONE LinkedIn person to a community profile list, so Scripe starts collecting their posts. `profile` is the person's LinkedIn profile address (URL, country-subdomain URL, tracking parameters or bare handle); never a NAME — Scripe cannot look a person up by name (a name is refused, a single word is read as a handle). Optional `note` (≤500 chars). Adding an existing member is reported, not duplicated. Draws from its own tighter rate bucket and is capped per list and per brand per day. Does NOT like, comment or repost. §2.16.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `generate_post_ideas`            | `ideas:write`                                         | Sync, one premium model call. Turns material — pasted `text`, or a `kb_…` `knowledgeDocumentId` — into For You post ideas on the brand's idea pool, 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.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |

Annotations: `readOnlyHint: false` on every write except
`draft_positioning_from_website`, which makes no persistent change and
says so. `destructiveHint: true` on the destroy verbs
(`delete_idea`, `delete_knowledge_doc`, `delete_note`, `delete_post`,
`delete_source`, `delete_media_asset`) and on `update_post_engagements`,
whose `remove` permanently deletes the engagement's tracked links, their
click history and their redirect-cache entries — and which, unlike the
destroy verbs, is 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`, 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`, `schedule_post`,
`delete_post`, `delete_idea`, `delete_knowledge_doc`, `delete_source`,
`delete_media_asset`, permanent `delete_note`, `update_tone_of_voice`,
`update_engagement_policy`, and `update_posting_schedule`, plus the
positioning and personal-DNA writes when they would replace existing
text — 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.
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 every verb except
  `publish_post`, is the subject drifting underneath you. Only
  `publish_post` also binds a hash of the post's content, media, and
  engagement flags, so editing the post between its proposal and its
  confirm invalidates the token. 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.
* Four 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`
  and `update_personal_dna`. Every other verb's token is not
  surface-scoped — `update_tone_of_voice` included, despite writing
  settings text.
* 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 theme={null}
{ "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](./media.md#attaching-to-a-post-patch-v1postspostidmedia).
* 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 theme={null}
{
  "kind": "images",
  "images": [
    { "source": "key", "key": "rrb7bw8pfuc.png", "alt": "the chart" },
    { "source": "library", "assetId": "img_d85211200fcc4f68" }
  ]
}
```

Two branches, and picking the wrong one is refused rather than stored:

| you have                                                         | branch                                                               |
| ---------------------------------------------------------------- | -------------------------------------------------------------------- |
| an `img_…` id from `search_media` or a finished `generate_image` | `{ "source": "library", "assetId": "img_…" }`                        |
| a `key` from the post's own `media` block                        | `{ "source": "key", "key": "…" }`                                    |
| a `displayUrl`, an `upl_…` upload handle                         | neither — turn it into an `img_…` id with `create_media_asset` first |

`key` is a **stored file key**, and the publish step classifies it by
extension to decide whether the asset goes to LinkedIn as an image, a
document or a video. A key it cannot classify — an `img_…` id, a CDN
URL — is rejected with `unprocessable` naming the branch that works.
(It used to be accepted with a 200 and then fail on the publish cron
hours later, with the post looking attached the whole time.)

The response carries the post's **resulting** `media`, because the
resolution is not the identity: a library id comes back as the stored
key you will need next time, and a `.pdf` sent under `kind: "images"`
comes back as `kind: "document"` — which is how LinkedIn will publish
it.

A post the dashboard gave a LinkedIn poll refuses media with `conflict`
and is left unchanged — a post publishes media or a poll, never both; see
[Media § Attaching to a post](./media.md#attaching-to-a-post-patch-v1postspostidmedia).
`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 theme={null}
// 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](./media.md); 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 theme={null}
{
  "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 auto-engagements. What it
keeps: analytics rows, tracked links and notifications — records of
something that really happened — and the idea, its attached source
material and the topic the post was written from, whose pointer to the
post is cleared rather than the row deleted.

To take a post out of the schedule instead, `update_post` with
`scheduledFor: null` is reversible and needs no confirmation.

### 2.7 The posting schedule (`update_posting_schedule`)

Two different asks share the word "schedule", and they are different
tools:

| Ask                                         | Tool                                               |
| ------------------------------------------- | -------------------------------------------------- |
| "Post *this* on Tuesday at 9"               | `schedule_post` — one post, one instant            |
| "Post me *every* Tuesday and Thursday at 8" | `update_posting_schedule` — the recurring template |

The template is what `get_next_free_slot` walks, what the calendar
draws as an open slot, and what the pillar rotation offers next. Most
projects do not have one, which is why that tool commonly answers
`source: "fallback"`.

**Read it from `get_settings`,** where the calendar timezone and week
start already live:

```json theme={null}
"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 theme={null}
{
  "phase": "proposal",
  "current": { "slots": [ … ], "postsPerWeek": 3 },
  "new":     { "slots": [ … ], "postsPerWeek": 2 },
  "summary": [
    "08:00 on Tue, Thu — added",
    "09:00 on Mon, Thu, Fri — REMOVED",
    "Posting times per week: 3 → 2"
  ],
  "readable": "08:00 on Tue, Thu (Europe/Berlin)",
  "confirmationToken": "…"
}
```

**Show the REMOVED lines.** They are the only place a user finds out
that a slot they set months ago is about to disappear, and a
whole-template replace is exactly the shape where that happens by
accident.

The executed call answers with `schedule` (what is stored now),
`previous` (what it displaced — enough to put it back) and `changed`.
Pass `timeZone` (IANA) to change the zone the times are expressed in,
or omit it to keep the project's. Setting a template publishes nothing
and does not move posts that are already scheduled.

### 2.8 Writing style preferences (`update_tone_of_voice`)

Three of the fields are stored as integers whose numbers are **neither
ordinal nor consistent with each other** — `3` is the longest post
length and the *shortest* sentence style. So the tool takes the word,
and `get_settings` returns the word next to the number it read:

| field                                    | words it accepts                   | stored as   |
| ---------------------------------------- | ---------------------------------- | ----------- |
| `postLengthPreference`                   | `short` \| `standard` \| `long`    | 1 \| 2 \| 3 |
| `formattingPreference` (sentence length) | `short` \| `standard` \| `long`    | 3 \| 0 \| 2 |
| `emojiPreference`                        | `none` \| `moderate` \| `frequent` | 1 \| 3 \| 5 |

The legal numbers are still accepted, and a word and its number bind to
the same confirmation token. Any other number is refused: the columns
have no scale between these values, so writing one changes nothing
about generation while telling the user their settings changed.

On the read side, `get_settings`'s `context` carries
`postLengthPreferenceLabel`, `formattingPreferenceLabel` and
`emojiPreferenceLabel`, derived through the same mapping the generators
use — so a project holding a legacy value no picker offers still reads
back as what it will actually produce. What "never chose" reads back as
differs by project type: a company page stores `0` for "never set" and
reads back `moderate`, the band it generates at; a personal-brand project
stores `1` (its column default) and reads back `none`.

### 2.9 Engagement policy writes

`update_engagement_policy` is covered by its table row plus §1.15 —
the read and the write share the same semantics, including the
deliberate no-row disarm.

### 2.10 Positioning and the org-wide voice

Three tools write at WORKSPACE level, where every brand shares the
result:

* **`update_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-brand**, not workspace-wide:

* **`update_personal_dna`** writes one project's personal DNA — the
  person document behind the dashboard's Brand → Positioning → "You"
  tab: 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 brand, 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 theme={null}
{
  "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](./synced-sources.md#post-history).

Sync membership is what a document's **folder** says, not what its
`type` says. `type: "website"` only means the text came from a web
page, which is equally true of a one-off
`add_to_knowledge_base({ type: "url" })` — those have no folder, no
sync behind them, and delete normally.

### 2.12 Generating for a company page

A workspace's projects are not interchangeable. `list_projects` reports
a `type` per project, and `generate_post` treats two of them
differently:

| `type`                         | writes as                       | profile it writes from                                                                      | tone of voice                        |
| ------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------ |
| `PERSONAL_BRAND` / `AMPLIFIER` | the person, in the first person | the connected LinkedIn profile                                                              | the project's personal tone of voice |
| `COMPANY_PAGE`                 | the company, as "we"            | the LinkedIn organisation profile — name, description, industries, specialties, positioning | the page's own tone of voice         |

Pass the company page's `proj_…` id and nothing else changes about the
call. `list_company_pages` is where you find it: only a page with
`status: "activated"` has an `activatedProject.id`; a `discovered` page
has no project to write into yet.

A company page's language, post length, formatting and emoji
preferences are stored on the page itself, not inherited from whichever
personal brand discovered it — read them with
`get_settings({ projectId })` and set them with
`update_tone_of_voice({ projectId })`, the same as any other project.

If the page's LinkedIn organisation details are missing (a partially
connected page), generation fails `unprocessable` with a message saying
to reconnect the page — not `ai_error`. Retrying will not help; fixing
the connection will.

### 2.13 Removing a source (`delete_source`)

`delete_source` is two-phase and irreversible, and — uniquely among the
destroy verbs — it also exists on REST (`DELETE /v1/sources/:sourceId`,
same two phases, token via `?confirmationToken=`). The proposal names
the blast radius: the source row, the full transcript (paragraphs and
sentences), its topics and their hooks, its knowledge-base copy
(document, chunks, embeddings — knowledge search stops returning the
content immediately), and the stored audio/video/file object, whose
storage quota is released.

Two things deliberately survive, and both the proposal and the executed
response say so — do not present this as total erasure:

* **Posts generated from the source are kept.** They are the user's
  content, not the source's child; only their link to the source goes
  stale. The proposal counts them (`kept.derivedPosts`); removing one
  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 (`create_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. Creating is the only
thing the surface does with content topics: there is no read tool, so a
topic cannot be listed back, and assignment changes, renames, deletes
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.

### 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 **For You**
ideas — the recommendations the dashboard shows on Explore → 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 content lane, 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 content lane the user asked for BY NAME ("give me Convert ideas").
Pass it only then: the ideas are then confined to that lane's post
formats (every returned idea's `funnelStage` reads that lane), while a
run without it lets the brand's strategy weights and the model choose.
The lanes 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](./mcp.md)), 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`)

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 theme={null}
// 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"
})
```

#### What `profile` accepts, and what it refuses

**The LinkedIn profile URL is the one thing the caller has to supply.**
A full URL, a country-subdomain URL (`de.linkedin.com/in/…`), a URL
carrying CRM tracking parameters, or a bare vanity handle all resolve to
the same person — a paste straight out of a CRM record needs no
cleaning up.

**Scripe cannot look a person up by NAME, so a name is not an input
this tool can resolve.** LinkedIn publishes no people-search API and
Scripe's own corpus is a corpus of creators, not of people — 14.8% of
its rows share a display name with another row. A best match would put a
stranger on a list the customer then engages from under their own
LinkedIn identity. A name with a space or a non-ASCII character is
refused (`invalid_request`) with what to supply instead; a single bare
word is indistinguishable from a vanity handle and is treated as one,
resolving to whoever owns it — so pass the URL or handle from the CRM
record, never the person's name. Company pages and email addresses are
refused.

#### A person who has never posted is a member like any other

`add_profile_to_list` resolves a profile whether or not they have ever
published, and reports it: `postsCollected: false` on the add,
`postsCollected: 0` on `get_profile_list`. That is an ordinary state,
not a half-failure — most CRM leads read LinkedIn rather than write on
it. They are watched from now on, and posts appear if and when they
publish. Somebody already on the list answers `added: false,
alreadyMember: true` with a note saying nothing changed — before any
limit below is consulted, since nothing would be written.

A profile that does NOT resolve is one of three things, and the answer
says which:

| The provider…                                                                                       | Answer                                          | What to do                                                                                                               |
| --------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| answered positively that there is no such profile                                                   | `not_found`                                     | fix the URL                                                                                                              |
| gave its one soft-failure answer, which it gives for a wrong address AND for a brief overload alike | `unprocessable`, `reason: "profile_unresolved"` | check the URL against the person's LinkedIn page first; if it is right, retry once after a few minutes — never in a loop |
| could not be reached at all, or refused with an error                                               | `service_unavailable`                           | retry in a few minutes                                                                                                   |

The middle row is what a non-existent handle usually produces: probed on
2026-09-17, `/profile/detail` for a made-up username answered HTTP 200
with `{"success":false,"message":"error, try again later"}` — the same
status, shape and message as a throttled request — so Scripe cannot
honestly claim either reading and says so.

#### Limits

Each added profile is a standing obligation — Scripe re-checks them for
new posts until somebody removes them — so this verb is bounded more
tightly than an ordinary write:

| Bound                                              | Answer when hit                                            |
| -------------------------------------------------- | ---------------------------------------------------------- |
| Per-list membership cap                            | `conflict`, naming the cap                                 |
| Per-brand daily additions, across all of its lists | `conflict`, naming the cap and that it resets at 00:00 UTC |
| Its own rate bucket, tighter than `write`          | `rate_limited`                                             |
| Scripe's global profile-lookup budget              | `service_unavailable`                                      |

Every one of them refuses the whole call and adds nothing; 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](./mcp.md)). 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 a **Business-plan** feature; a workspace below it gets
`plan_not_eligible`. Client accounts of an agency workspace cannot reach
any of the three tools, matching the dashboard.

***

## 3. Tool error envelope

Failed tools return `isError: true` with structured content:

```json theme={null}
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "{\"error\":{\"code\":\"plan_not_eligible\",\"message\":\"…\",\"request_id\":\"…\",\"docs_url\":\"…\"}}"
    }
  ],
  "structuredContent": {
    "error": {
      "code": "plan_not_eligible",
      "message": "Your plan does not include the API.",
      "request_id": "mcp-…",
      "docs_url": "https://docs.scripe.io/api/v1/errors#plan_not_eligible"
    }
  }
}
```

The envelope is identical to the [REST error body](./conventions.md#errors),
so one parser handles both surfaces. `code` is stable and shared with
the [error reference](./errors/index.md). 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 theme={null}
{
  "type": "file",
  "content_base64": "JVBERi0xLjQKJ…",
  "mimeType": "application/pdf",
  "filename": "Q3-strategy.pdf"
}
```

| Field            | Required | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `content_base64` | yes      | Raw file bytes, base64-encoded. **No** `data:` URL prefix.                                                                                                                                                                                                                                                                                                                                                                                   |
| `mimeType`       | yes      | e.g. `application/pdf`, `audio/mpeg`, `image/png`, `text/plain`.                                                                                                                                                                                                                                                                                                                                                                             |
| `filename`       | yes      | Display name + extension fallback for content-type detection.                                                                                                                                                                                                                                                                                                                                                                                |
| `sha256`         | no       | Hex SHA-256 of the **decoded** bytes. Strongly recommended when the host can compute it — base64 relayed through model output corrupts silently, and a digest mismatch is rejected naming both digests instead of storing the wrong bytes. Inline path only: passing it beside `uploadId` is refused, and on `add_to_knowledge_base` it is accepted for `type: "file"` with inline bytes only, since the other types send no bytes to check. |

**Limits: \~3 MB decoded per file (\~4 MB on-wire as base64).** The
serving platform rejects request bodies at \~4.5 MB on the wire with a
bare `413 FUNCTION_PAYLOAD_TOO_LARGE` that never reaches the API — no
error envelope, no `docs_url` — so **pre-check the file size** and use
the two-step presigned-PUT path for anything larger rather than
retrying inline. Scripe's own cap sits just under that edge so a
payload slightly over it still gets a teaching error naming the
repair. The per-content-type caps from
[Uploads](./uploads.md#allowed-content-types-and-size-caps) (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 theme={null}
{
  "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 template            | Body                                                                                            | Required scope   |
| ----------------------- | ----------------------------------------------------------------------------------------------- | ---------------- |
| `scripe://workspace/me` | Workspace JSON (includes `defaultProject`)                                                      | `workspace:read` |
| `scripe://project/{id}` | Project JSON                                                                                    | `projects:read`  |
| `scripe://post/{id}`    | Post JSON                                                                                       | `posts:read`     |
| `scripe://note/{id}`    | Note JSON                                                                                       | `notes:read`     |
| `scripe://source/{id}`  | Source JSON (includes `topics[]` once processed — `hooks[]` stays empty on API-created sources) | `sources:read`   |
| `scripe://job/{id}`     | Job JSON                                                                                        | `jobs:read`      |

All resources return `mimeType: "application/json"` with the same
envelope the REST API uses. Resources are **read-only**; mutate via the
matching write tool. They are read-on-demand — the server does not emit
`notifications/resources/updated`, so re-read when you need fresh state.

***

## 7. Prompts

Slash-command-style entry points the host renders in its UI. They
produce a single user-role message that primes the model with
investigate-then-execute steps; they never call tools themselves.

| Prompt            | Arguments                                                                  | Purpose                                                                                                                            |
| ----------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `weekly_planning` | `projectId?`, `weeksOut?`                                                  | Plan the next week(s) of LinkedIn content. Investigates state, proposes a calendar, schedules drafts only after user confirmation. |
| `repurpose`       | `sourceRef` (a `note_*`, `src_*`, or `post_*` id), `format?`, `projectId?` | Turn an existing asset into 1–3 new drafts, staying tonally consistent with recent posts.                                          |
| `inbox_zero`      | `projectId?`, `ageDays?`                                                   | Triage stale notes and failed jobs. Clusters by theme and confirms each batch.                                                     |

All arguments are strings (per MCP `Prompt.arguments` constraints);
numeric ones (`weeksOut`, `ageDays`) 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 theme={null}
{ "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.** Two-phase `schedule_post` (§2.2). The proposal — the only
thing the user sees before consenting — resolves everything:

* `scheduledFor` comes back as an absolute UTC instant, and
  `scheduledForLocal` renders it in the project's calendar timezone
  (`timeZone`) — e.g. `Tue, 18 Aug 2026 09:00 (Europe/Berlin)`.
  **Show `scheduledForLocal`**; a raw instant is not something a user
  can check.
* 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.
* A time that cannot be scheduled is refused **at the proposal**,
  before any token exists: unparseable strings (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), and anything in the past. A proposal you can
  confirm is a proposal that will execute.
* **The LinkedIn connection is checked live at the proposal too**, and
  the verdict comes back as a `linkedIn` block: `preflight`
  (`ok | not_connected | token_invalid | no_company_page_admin`),
  `canSchedule`, `detail`, and `reconnectUrl`. **Branch on
  `canSchedule`** — when it is `false`, confirming will fail with
  `409 conflict` (`details.reason`, `details.reconnectUrl`,
  `details.retryable: false`), so relay `detail` and the link instead
  of asking the user to confirm; only a human can reconnect LinkedIn.
  You still get a `confirmationToken`, because reconnect-then-confirm
  is exactly the flow this enables; phase two re-runs the same check.
  (`no_company_page_admin` carries no `reconnectUrl`: its repair is on
  a *different* project — some admin's own LinkedIn account.)

Execution verifies the token binding, re-runs the LinkedIn check,
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, run
the flow again; to unschedule, `update_post({ scheduledFor: null })`
(reversible — it emits `post.unscheduled`).

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