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

# Scripe API — Remote MCP Server (v1)

> Status: **Public preview.** The MCP transport ships with Phase 5b.
> Tools, resources, and prompts are stable for v1; new entries will land
> in additive minor releases. Breaking changes only ship in v2 with a
> deprecation window of at least 90 days.

The Scripe MCP (Model Context Protocol) server lets MCP-compatible
hosts — Claude.ai, Claude Desktop, ChatGPT, Cursor, agent frameworks —
read and write Scripe workspace data on behalf of an authenticated
user. The transport runs on a remote URL and authenticates via the same
[OAuth 2.1 server](./oauth.md) that powers the REST API; there is no
separate credentialing surface to manage.

If you're integrating MCP for **your own** Scripe data and want a
single-tenant credential, point your host at the same URL and use a
Personal Access Token minted from the dashboard (Settings → Developer →
PAT). For multi-tenant integrations (e.g. shipping a Scripe MCP host as
part of your product), use the OAuth flow described below.

***

## TL;DR

```text theme={null}
Streamable HTTP endpoint:  https://mcp.scripe.io/mcp
Legacy SSE endpoint:       https://mcp.scripe.io/sse  (companion: /message)
OAuth issuer:              https://api.scripe.io
Discovery:                 https://mcp.scripe.io/.well-known/oauth-protected-resource
```

In Claude Desktop, add a remote MCP server with that URL and complete
the consent flow. In Claude.ai or ChatGPT, paste the URL into the MCP
connector dialog. The MCP host does the OAuth dance for you.

***

## 1. Hosts and discovery

The remote MCP server lives on `mcp.scripe.io`:

```
https://mcp.scripe.io/mcp        # Streamable HTTP (preferred)
https://mcp.scripe.io/sse        # Legacy SSE GET — Cursor + early hosts
https://mcp.scripe.io/message    # Legacy SSE POST companion (don't call directly)
```

Both transports serve the same `McpServer` instance — same tools, same
resources, same prompts. Pick whichever your host supports. New
integrations should prefer Streamable HTTP (`/mcp`); SSE (`/sse` +
`/message`) will be deprecated when the major hosts have all migrated
(track [status.scripe.io] for timing). Internally these route to
`/api/mcp/{mcp,sse,message}` via Vercel rewrites; either URL works,
but the apex form on `mcp.scripe.io` is the supported integration
contract.

Discovery is RFC 9728 protected-resource metadata:

```http theme={null}
GET https://mcp.scripe.io/.well-known/oauth-protected-resource
```

```json theme={null}
{
  "resource": "https://mcp.scripe.io",
  "authorization_servers": ["https://api.scripe.io"],
  "scopes_supported": [
    "workspace:read",
    "projects:read",
    "notes:read", "notes:write",
    "posts:read", "posts:write", "posts:generate",
    "sources:read", "sources:write",
    "knowledge:read", "knowledge:write",
    "jobs:read", "jobs:cancel",
    "offline_access"
  ],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://docs.scripe.io/api/v1/mcp"
}
```

Hosts that follow the spec will discover the authorization server
automatically. For older hosts (some early MCP betas), copy/paste the
URLs into the host's UI manually.

***

## 2. Authentication

Two paths, same backing tokens:

### 2.1 OAuth 2.1 with Dynamic Client Registration

Recommended for any **multi-user** integration. The MCP host
auto-registers itself via [DCR](./oauth.md#2-dynamic-client-registration-rfc-7591),
sends the user through the consent screen, and stores the resulting
access + refresh tokens locally. Refreshes are rotated per
[reuse-detection rules](./oauth.md#41-reuse-detection); a leaked token
revokes the entire family and forces re-consent.

The token must be presented as a Bearer credential on every MCP
request:

```http theme={null}
POST /mcp HTTP/1.1
Host: mcp.scripe.io
Authorization: Bearer scripe_oat_…
Content-Type: application/json
…
```

A `401 Unauthorized` with `WWW-Authenticate: Bearer realm=…,
resource_metadata=…` triggers the host to (re)run the OAuth flow.

### 2.2 Personal Access Token (single-user)

Mint a PAT in the dashboard (Settings → Developer → API keys). Paste
it into your host's "static token" slot. Same Bearer wire format. The
PAT inherits the workspace's plan + scope set; you cannot narrow it
to a subset of scopes — for that, use OAuth.

### 2.3 Session-resumable but token-bound

The Scripe MCP server stores session state in Redis keyed off the
sha256 of the access token. If the token rotates (refresh), the old
session is automatically retired and a new one is created on the next
request. Hosts do not need to do anything special: just keep
presenting the freshest access token they have.

### 2.4 Default project (set once at consent)

The OAuth consent screen prompts the user to pick a **default project**
for the grant. The picker is shown whenever the workspace has more
than one project; single-project workspaces auto-select and skip the
prompt. The default is persisted alongside the consent row and can be
edited later from **Settings → Developer → Connected Apps**.

Why it matters: most write tools (`create_note`, `create_post_draft`,
`generate_post`, …) need a `projectId`. With the default in place,
the model can omit the field and the server fills it in — letting
prompts like *"create a LinkedIn post about personal branding with AI"*
just work without a discovery round trip.

* The `projectId` argument on each write tool is **optional** when a
  default is set. Pass it explicitly to override.
* If the default is unset *and* the argument is omitted, the tool
  returns `invalid_request` with a hint to either pass `projectId` or
  set a default at consent time.
* The active default is surfaced on `get_workspace` /
  `scripe://workspace/me` as `defaultProject: { id, name } | null`.
  Models are instructed to echo the project name when they act on it
  ("Using the *Marketing* project — pass `projectId` to override.").

> **PAT tokens** don't have a consent screen, so they never carry a
> default project. PAT integrations must pass `projectId` on every
> write tool.

### 2.5 Multi-workspace access (`Scripe-Workspace-Id`)

One OAuth token can reach **every workspace the consenting user belongs
to in Scripe** — not just the workspace they picked at consent time.
This matters for agencies, where one operator manages many client
workspaces.

* **Default workspace.** The workspace pinned at consent is the
  *default*: every request without a workspace header runs against it.

* **Targeting another workspace.** Send a `Scripe-Workspace-Id` header
  whose value is the workspace's id (the `org_*` id returned by
  `get_workspace` / `list_workspaces`):

  ```http theme={null}
  POST /mcp HTTP/1.1
  Host: mcp.scripe.io
  Authorization: Bearer scripe_oat_…
  Scripe-Workspace-Id: org_2ab…
  ```

* **Live membership.** Access is checked against the user's *current*
  Clerk membership on each request (60 s cache). When an agency is added
  to a new client workspace it becomes reachable immediately — no
  re-consent. When membership is revoked, access stops within \~60 s.

* **Non-member workspace** → the request fails the auth challenge
  (`workspace_unavailable`), so a forged id can't reach data the user
  can't see in the app.

* **Plan.** Gating inherits the **default** workspace's plan, so client
  workspaces on cheaper plans still work under an agency's
  API-enabled token.

* **Default project does not cross workspaces.** The consent-pinned
  default project belongs to the default workspace, so when you target
  another workspace you must pass `projectId` explicitly on write tools.

**Host support.** The header is set by the MCP host per *connection*, so
the model cannot switch workspaces mid-conversation. The agency pattern
in hosts like **Cursor** is one MCP connection per workspace — the same
login (same token) with a different `Scripe-Workspace-Id` on each entry.
Hosts that can't set custom headers (e.g. Claude.ai connectors) are
limited to the default workspace. Use `list_workspaces` to discover the
ids to pin.

> **API keys / PATs stay single-workspace.** A `Scripe-Workspace-Id`
> header that disagrees with the key's workspace is rejected with
> `workspace_unavailable`. Use an OAuth token for multi-workspace access.

***

## 3. Tool surface

Tools mirror the REST resources. The semantics are 1:1 — the MCP tool
is a thin adapter that calls the same handler the REST endpoint does,
so any contract you've coded against `/v1/*` is preserved.

### 3.1 Read tools

| Tool name                | Scope            | Returns                                                                                                                      |
| ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `get_workspace`          | (any token)      | The active workspace + plan + principal info.                                                                                |
| `list_workspaces`        | `workspace:read` | Every workspace the user can reach (`id`, `name`, `isDefault`). Use an `id` as the `Scripe-Workspace-Id` header value.       |
| `list_projects`          | `projects:read`  | Paginated list of projects.                                                                                                  |
| `get_project`            | `projects:read`  | Single project read.                                                                                                         |
| `list_notes`             | `notes:read`     | List notes for a project (date / cursor support).                                                                            |
| `get_note`               | `notes:read`     | Single note read.                                                                                                            |
| `list_posts`             | `posts:read`     | List posts for a project (status CSV support).                                                                               |
| `get_post`               | `posts:read`     | Single post read.                                                                                                            |
| `list_post_statuses`     | `posts:read`     | Workspace custom statuses (kanban columns) — discover a `statusId` + `category` for `update_post`.                           |
| `get_source`             | `sources:read`   | Single source (transcription) read. Includes `topics[]` + `hooks[]` once `status: "Success"`.                                |
| `get_analytics_overview` | `analytics:read` | Current vs previous-period rollup (posts, impressions, engagement rate, follower growth, activity) for one or more projects. |
| `list_post_analytics`    | `analytics:read` | Per-post LinkedIn metrics (views, likes, comments, shares, engagement rate), newest first. Offset-paginated.                 |
| `search_viral_posts`     | `analytics:read` | Semantic search over the inspiration feed for high-performing posts on a topic (excludes your own author).                   |
| `list_jobs`              | `jobs:read`      | List async jobs (paginated).                                                                                                 |
| `get_job`                | `jobs:read`      | Single job read with progress snapshot.                                                                                      |

All read tools advertise `readOnlyHint: true, openWorldHint: false`.

### 3.2 Write tools

| Tool name               | Scope             | Notes                                                                                                                                                                           |
| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_note`           | `notes:write`     | Sync. Idempotency hint **off** — pass a stable `clientId` to dedup.                                                                                                             |
| `create_post_draft`     | `posts:write`     | Sync. Pass a future `scheduledFor` to schedule, omit for an unsent draft.                                                                                                       |
| `update_post`           | `posts:write`     | Sync. Edit content/title/contentType, set status (`statusCategory`/`statusId`), and/or schedule via `scheduledFor` (`null` to unschedule). Verifies LinkedIn before scheduling. |
| `schedule_post`         | `posts:write`     | Sync. Thin wrapper over `update_post` — `postId` + `scheduledFor`. Verifies the LinkedIn connection, then queues the post to go live (\~2 min after the scheduled time).        |
| `create_source_text`    | `sources:write`   | Sync. Returns a `Source` envelope.                                                                                                                                              |
| `create_upload_url`     | `sources:write`   | Sync. Returns a presigned S3 PUT URL — `openWorldHint: true`.                                                                                                                   |
| `create_source_file`    | `sources:write`   | **Async.** Returns a `Job` envelope. Accepts inline `content_base64` (preferred for MCP) or a two-step `uploadId`. Streams progress (see §4).                                   |
| `add_to_knowledge_base` | `knowledge:write` | **Async.** Accepts text, file (inline `content_base64` **or** two-step `uploadId`), URL, or YouTube. Streams progress.                                                          |
| `cancel_job`            | `jobs:cancel`     | Sync. Idempotent — calling twice on the same job is safe.                                                                                                                       |
| `generate_post`         | `posts:generate`  | **Async.** AI post generation. Streams progress when the host requests it.                                                                                                      |

Annotations on every write tool: `readOnlyHint: false, destructiveHint:
false`. Only `cancel_job` advertises `idempotentHint: true`.

> **Why no `delete_*` tools at v1?** v1 deliberately omits the
> destructive surface — deletes happen in the dashboard so the user
> always has a confirmation step. We'll revisit when destructive
> scopes (`*:destroy`) ship in v1.x.

### 3.3 Tool error envelope

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

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

`code` is stable across versions and identical to the REST [error
catalog](./errors/). Models react well to the structured form; humans
get the JSON-stringified text fallback.

### 3.4 Inline file content (`content_base64`)

`create_source_file` and `add_to_knowledge_base` accept file content
**inline as base64** in addition to the legacy two-step
`create_upload_url` + signed-PUT flow. The inline form is strongly
preferred for MCP hosts because most hosts (Claude Desktop, Cursor,
ChatGPT) cannot reliably drive a separate signed PUT from a tool
call.

**Schema** (the same fields apply to both tools):

```json theme={null}
{
  "type": "file",
  "content_base64": "JVBERi0xLjQKJ…",
  "mimeType": "application/pdf",
  "filename": "Q3-strategy.pdf"
}
```

| Field            | Type   | Required | Notes                                                                 |
| ---------------- | ------ | -------- | --------------------------------------------------------------------- |
| `content_base64` | string | yes      | Raw file bytes, base64-encoded. **Do not** add a `data:` URL prefix.  |
| `mimeType`       | string | yes      | Examples: `application/pdf`, `audio/mpeg`, `image/png`, `text/plain`. |
| `filename`       | string | yes      | Display name + extension fallback for content-type detection.         |

**Limits**

* **25 MB decoded** per file (\~33 MB on-wire as base64). The cap
  stays under Vercel's function body limit with headroom for the
  other JSON fields. Larger media must fall back to the two-step
  presigned-PUT path.
* Per-content-type caps from `util/uploadCaps` are also enforced
  (see [conventions](./conventions.md#5-file-uploads-and-mime-types)
  for the allow-list).

**Idempotency**

The inline path derives the S3 key from
`sha256(content_base64).slice(0, 16)`, so re-tries with an identical
payload land at the same upload handle. Workers (`SOURCE_INGEST_FILE`,
`KB_INGEST_FILE`) de-dupe by `uploadId`, so a tool retry never creates
a duplicate source or KB row.

**Two-step path (still supported)**

```json theme={null}
{
  "type": "file",
  "uploadId": "upl_…"
}
```

Use the two-step form only when (a) the file exceeds the 25 MB inline
cap or (b) you're building a non-MCP integration (dashboard, SDK)
that can drive the signed PUT itself. For MCP, always prefer inline.

***

## 4. Progress streaming

Async write tools (`create_source_file`, `add_to_knowledge_base`,
`generate_post`) stream progress via MCP `notifications/progress` when
the host opts in.

### 4.1 Opting in

Pass a `progressToken` in the request `_meta`:

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "generate_post",
    "arguments": { "projectId": "proj_…", "source": { "type": "note", "noteId": "note_…" } },
    "_meta": { "progressToken": "client-generated-12345" }
  }
}
```

### 4.2 What you receive

* **Initial 0% progress** as soon as the tool sees a non-terminal job.
* **Periodic updates** every \~750 ms while the worker emits to
  `job:progress:<jobId>` in Redis. The `progress` field is `[0, 1]`
  with optional `total` (we always set `total: 1` so older clients
  render percentages correctly).
* **Final 1.0 progress** when the job reaches a terminal state
  (DONE / FAILED / CANCELLED).
* The tool's `result.content` is returned **after** the final
  notification, with the latest job state.

```json theme={null}
{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "client-generated-12345",
    "progress": 0.42,
    "total": 1,
    "message": "Generating draft… 3/7 sections"
  }
}
```

### 4.3 Cancellation

If the host sends a `notifications/cancelled` for the originating
request, the tool stops streaming and returns the current job state.
We do **not** automatically `cancel_job` — call that explicitly if you
want to stop the worker. (The motivation is that hosts often cancel
because they want to start a follow-up call, not because the user
wants the underlying job killed.)

### 4.4 Hard timeout

Streaming caps at **25 seconds** to stay inside Vercel's function
budget. After the cap we return whatever job state we have. The job
itself continues in the background; poll `get_job` to follow up.

***

## 5. Resources

`scripe://` resources let the host attach a "watch" to a record:

| URI template            | Body                                                                   | Required scope  |
| ----------------------- | ---------------------------------------------------------------------- | --------------- |
| `scripe://workspace/me` | Workspace JSON (includes `defaultProject`)                             | (any token)     |
| `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[]` + `hooks[]` once `status: "Success"`) | `sources:read`  |
| `scripe://job/{id}`     | Job JSON                                                               | `jobs:read`     |

All resources return `mimeType: "application/json"` with the same
envelope the REST API does. Resources are **read-only**; mutate via
the matching write tool.

Hosts that support resource subscriptions (Claude Desktop, Cursor) get
a `notifications/resources/updated` whenever the underlying record
changes, debounced at 1 s. SSE-only hosts (legacy Cursor) poll
manually — the resource read is cheap.

***

## 6. Prompts

Prompts are slash-command-style entry points the host renders in its
UI. They produce a single user-role message that primes the model with
investigative + execution steps. The prompts never call tools
themselves; they instruct the model to do so.

| Prompt name       | Required arguments                                         | Purpose                                                                                                                        |
| ----------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `weekly_planning` | `projectId?`, `weeksOut?`                                  | Plan the next week(s) of LinkedIn content. Investigates state, proposes a calendar, schedules drafts only after user confirms. |
| `repurpose`       | `sourceRef` (note/post/source id), `format?`, `projectId?` | Turn an existing asset into 1-3 new drafts. Reads recent posts to stay tonally consistent.                                     |
| `inbox_zero`      | `projectId?`, `ageDays?`                                   | Triage stale notes and failed jobs. Clusters by theme and asks before each batch.                                              |

All arguments are strings (per MCP `Prompt.arguments` constraints). For
numeric args (`weeksOut`, `ageDays`) we do the parse server-side and
default sensibly when omitted.

***

## 7. Worked example (Claude Desktop)

1. **Settings → Developer → MCP Servers → Add server** in Claude
   Desktop.
2. Pick "Remote (HTTPS)".
3. URL: `https://mcp.scripe.io/mcp`.
4. Click **Connect**. Claude opens a browser tab to
   `https://api.scripe.io/oauth/authorize?…`. Sign in with Clerk and
   approve the consent screen.
5. The browser hands the `code` back to Claude Desktop's loopback
   listener; Claude exchanges it at `/oauth/token` and stores the
   resulting refresh token in the OS keychain.
6. The connector turns green. Claude lists the Scripe tools, resources
   and prompts in its sidebar.
7. Try `/scripe weekly_planning` to invoke the planning prompt — Claude
   will call `get_workspace`, `list_projects`, etc. before proposing
   anything.

The same flow works in **Claude.ai** (web) — the consent redirect
returns to Claude's web origin instead of localhost. **Cursor** uses
the SSE endpoint by default; **ChatGPT** uses Streamable HTTP. We
re-test all four hosts before each release; the verification matrix
lives in \[`packages/api-public/docs/runbook.md`].

***

## 8. Scenario walkthroughs

These four flows are the canonical user journeys the MCP server is
designed for. The server-level `instructions` string (injected by
every host into its system prompt) tells the model the exact tool
sequence for each; this section documents the same flows from the
integrator's perspective so you can validate your host renders them
correctly.

### 8.1 Drafting a LinkedIn post (S1)

> *"Create a LinkedIn post about personal branding with AI."*

The model calls `generate_post` directly. With a default project set
at consent time it omits `projectId`; the server fills it in from the
grant.

```json theme={null}
{
  "name": "generate_post",
  "arguments": {
    "source": {
      "type": "text",
      "text": "Create a LinkedIn post about personal branding with AI. Focus on practical tips for solopreneurs."
    }
  },
  "_meta": { "progressToken": "client-12345" }
}
```

What happens server-side:

1. Resolve `projectId` from `ctx.defaultProjectId`.
2. Enqueue a `POST_GENERATION` job with the project's tone, voice,
   and sample posts baked in (same code path as the dashboard's
   *Post Generator* tool).
3. Stream `notifications/progress` until the job reaches
   `DONE`/`FAILED`.
4. Return a `Post` envelope with the generated draft.

The model then summarises the draft and asks the user before calling
`create_post_draft({ scheduledFor })` to put it on the calendar.

### 8.2 Capturing a calendar note (S2)

> *"Add a note that I want to post about personal branding next Wednesday."*

The model resolves *"next Wednesday"* against today's date, then
calls `create_note` synchronously.

```json theme={null}
{
  "name": "create_note",
  "arguments": {
    "content": "Post about personal branding — angle: ‘what AI taught me about my own voice’.",
    "date": "2026-06-03"
  }
}
```

The `date` field stamps `tQueueSlot.date` on the underlying record so
the note appears on the correct day in the dashboard calendar. The
tool returns the created `Note` envelope; the model echoes the id
(`note_…`) so the user can correlate.

### 8.3 Sending a file to the knowledge base (S3)

> *"\[attaches a PDF] send this to my org's knowledge base."*

The model attaches the host-provided bytes inline. **No** `projectId`
is passed — knowledge bases are workspace-scoped ("my org's KB"), so
omitting the field is intentional.

```json theme={null}
{
  "name": "add_to_knowledge_base",
  "arguments": {
    "type": "file",
    "content_base64": "JVBERi0xLjQK…",
    "mimeType": "application/pdf",
    "filename": "Q3-strategy.pdf"
  },
  "_meta": { "progressToken": "client-12346" }
}
```

Server-side this:

1. Runs the inline-upload helper (§3.4) — decode, validate, S3 PUT,
   mint a deterministic `uploadId`.
2. Enqueues `KB_INGEST_FILE` (same worker the dashboard's *Knowledge
   Base → Upload* flow uses).
3. Streams progress while the worker extracts text + embeds it.
4. Returns a `Job` envelope that, once `DONE`, points at the KB
   record id.

For host-extracted plain text (e.g. a note pasted into chat), use
`type: "text"` with a `text` field and skip the upload entirely.

### 8.4 Source ingest + draft (S4)

> *"\[attaches a voicememo.m4a] send this to scripe sources, then draft me a post."*

This is the only multi-step flow. The model is instructed **not** to
auto-chain — it ingests, asks the user which hook to use, then
drafts. This mirrors the WhatsApp + `/uploads` UX in the dashboard.

```json theme={null}
{
  "name": "create_source_file",
  "arguments": {
    "content_base64": "AAAAGGZ0eXBNNEEgAA…",
    "mimeType": "audio/mp4",
    "filename": "voicememo.m4a"
  },
  "_meta": { "progressToken": "client-12347" }
}
```

After the source job reaches `DONE`, the model reads the source
record:

```json theme={null}
{
  "name": "get_source",
  "arguments": { "sourceId": "src_…" }
}
```

Once `status` is `"Success"`, the response includes a `topics` array
with hooks per topic:

```json theme={null}
{
  "id": "src_…",
  "status": "Success",
  "textPreview": "…transcript first 1024 chars…",
  "topics": [
    {
      "id": "tpc_…",
      "title": "Personal branding starts before the brand",
      "ranking": 1,
      "hooks": [
        {
          "id": "hk_…",
          "content": "I spent two years building a personal brand. Here's what nobody told me.",
          "postType": "story",
          "contentType": "text"
        },
        { "id": "hk_…", "content": "…", "postType": "tip", "contentType": "text" }
      ]
    }
  ]
}
```

The model presents the topics and hooks ("I see 4 topics. Want me
to draft a post on *Personal branding starts before the brand* using
hook 1?"), waits for the user's pick, then drafts:

```json theme={null}
{
  "name": "generate_post",
  "arguments": {
    "source": {
      "type": "text",
      "text": "Hook: I spent two years building a personal brand. Here's what nobody told me.\n\nTranscript excerpt: …relevant chunk for the chosen topic…"
    }
  }
}
```

Same `POST_GENERATION` worker as S1 — the project's tone, voice, and
samples are applied automatically.

> **Why no auto-chain?** Source ingest can surface 1-5 distinct
> topics with 3-6 hooks each. Auto-drafting the first one is rarely
> what the user actually wants. The WhatsApp ingester (which inspired
> this flow) takes the same model-asks-first approach for the same
> reason.

### 8.5 Scheduling a post to go live (S5)

> *"Schedule the post we just created for tomorrow 9 AM."*

The model resolves *"tomorrow 9 AM"* against today's date (in the
user's timezone) to an ISO timestamp, then calls `schedule_post` with
the `postId` returned by the earlier `create_post_draft` /
`generate_post` step:

```json theme={null}
{
  "name": "schedule_post",
  "arguments": {
    "postId": "post_01HX…",
    "scheduledFor": "2026-06-03T09:00:00+02:00"
  }
}
```

What happens server-side:

1. Load the post, resolve its `projectId`, and authorize the grant.
2. **Verify the LinkedIn connection** — a live `/me` ping with
   token refresh on 401 for personal brands, or a non-empty admin set
   for company pages (identical to the in-app check). If LinkedIn
   isn't connected, the tool returns a `conflict` error envelope and
   the post is **not** scheduled.
3. Set `scheduledAt`, stamp the workspace's `scheduled`-category
   custom status, and upsert the calendar slot.
4. Emit a `post.scheduled` webhook.
5. The publish cron picks it up and posts to LinkedIn within \~2 min
   of the scheduled time.

To **reschedule**, call `schedule_post` again with a new
`scheduledFor`. To **unschedule** (revert to an unsent draft), call
`update_post({ postId, scheduledFor: null })` — this emits
`post.unscheduled`.

> **Why verify before scheduling?** A post stamped `scheduled` but
> backed by a disconnected LinkedIn account would silently fail at
> publish time. Verifying up front surfaces the problem while the
> user is still in the loop, exactly like the dashboard's schedule
> button.

***

## 9. Limits and quotas

* **Sessions per workspace:** 100 active. LRU-evicted when exceeded.
* **Tool calls per minute:** shared with the `/v1/*` rate limit
  bucket (120 req/min for reads, separate writes bucket per resource).
* **Progress notifications per request:** capped at 60. We rate-limit
  internally to one notification per 750 ms regardless of how often
  the worker emits to Redis.
* **Streamed body cap:** 8 MB per response. Larger results return a
  `payload_too_large` error envelope; use the `list_*` paginated tools
  instead of dumping everything at once.
* **OAuth granted scopes:** the access token's scopes gate every tool
  call. Calling a tool whose required scope isn't granted returns
  `scope_missing` immediately, before any handler runs.

***

## 10. Operational notes

* **Audit log** — every tool call appears in the workspace audit log
  with the `client_id` of the OAuth grant, the tool name, and a
  redacted argument hash.
* **Email notifications** — the user receives an email when a new MCP
  consent is granted (listing the tools the host will be able to use).
* **Status page** — [status.scripe.io](https://status.scripe.io)
  surfaces `mcp.scripe.io` and `oauth.api.scripe.io` as separate
  components. Subscribe for incident updates.

***

## 11. Need help?

* Email **[support@scripe.io](mailto:support@scripe.io)** for
  integration help.
* Open an issue or PR on the
  [`scripe/scripe`](https://github.com/scripe/scripe) repo for tool /
  resource / prompt suggestions.
* Email **[security@scripe.io](mailto:security@scripe.io)** for
  suspected token leaks — we treat MCP and REST tokens identically.

[status.scripe.io]: https://status.scripe.io
