<!-- Cross-reference: public/mcp.md (tool reference), public/llms.txt (LLM index). -->
<!-- This file is the canonical task-oriented documentation for FeedSquad MCP. -->
<!-- Stable URL: https://feedsquad.com/mcp-cookbook.md -->

# FeedSquad MCP Cookbook

> Task-oriented recipes for FeedSquad MCP. Aimed at LLM agents (loadable as a system prompt) and human developers (readable as a how-to). For per-tool reference, see [mcp.md](https://feedsquad.com/mcp.md).

## How to read this cookbook

Each recipe answers one question: *"I want to do X. What tools do I call, in what order, and what do I do when something goes wrong?"*

Recipe shape:

- **Goal** — one sentence on what the user is trying to do
- **When to use** — the user-facing trigger phrase
- **Tool sequence** — the calls, in order, with explanations
- **Sample calls** — JSON params you can paste-and-adapt
- **Sample response** — what to expect on success (abbreviated)
- **Failure modes** — the errors agents will hit + recovery actions

LLM agents: every error response includes a `recovery_hint` object pointing at the next tool to call. Follow the hint before retrying blindly.

## Conventions

- `tool_name(param: value, ...)` — shorthand for `tools/call` with `name="tool_name"` and `arguments={...}`.
- `→ tool_name` — "agent should call this tool next."
- ISO 8601 datetimes throughout; FeedSquad respects the user's timezone for cadence math (read from `user_profiles.content_preferences.timezone`).
- Sample post IDs are `post_xxxxxxxx`. Sample campaign IDs are `camp_xxxxxxxx`. Substitute real IDs in practice.

---

## Recipe 1 — First post (onboarding)

**Goal**: a new user wants to publish their first post.

**When to use**: user says *"help me write my first post"*, *"I just signed up, what's next?"*, or any first-touch publishing request.

### Tool sequence

1. **Check connection state** — `list_accounts()` returns what the user has connected. If LinkedIn / X / Threads aren't connected, prompt them to authorize.
2. **If not connected** — `connect_platform({ platform: "linkedin" })` returns a one-time OAuth URL. Surface that URL to the user; they click and approve.
3. **Draft the post** — `create_post({ content: "...", platform: "linkedin", dry_run: true })` lets you preview without writing. Iterate with the user on content.
4. **Save the draft**:
   - As a true draft (no date): `create_post({ content: "...", platform: "linkedin" })` → status `'draft'`.
   - As a scheduled post: `create_post({ content: "...", scheduled_date: "...", platform: "linkedin" })` → status `'ready'` (awaiting approval).
5. **Schedule or publish now**:
   - To schedule (if not already set in step 4): `update_post({ post_id, scheduled_date: "..." })` then `confirm_post({ draft_id: post_id, action: "approve" })` — cron picks it up.
   - To approve a scheduled post: `confirm_post({ draft_id: post_id, action: "approve" })` flips `'ready'` → `'scheduled'`.
   - To publish immediately: `publish_post({ post_id })`.

### Sample call

```json
// Step 4 — save the draft
{
  "name": "create_post",
  "arguments": {
    "content": "Three things I learned shipping our MVP in 12 weeks:\n\n1. Distribution > polish\n2. Talk to 5 users a day\n3. The 'almost done' phase is 50% of the work\n\nWhat would you add?",
    "platform": "linkedin",
    "idempotency_key": "first-post-2026-05-13-user-1"
  }
}
```

### Sample success response

```json
{
  "success": true,
  "post_id": "post_a1b2c3d4",
  "post": {
    "id": "post_a1b2c3d4",
    "status": "draft",
    "platform": "linkedin",
    "content": "Three things I learned shipping our MVP in 12 weeks: …"
  },
  "message": "Draft created. Use confirm_post to schedule, or publish_post to publish immediately."
}
```

### Failure modes

| Error code | What it means | Recovery |
|---|---|---|
| `linkedin_required` on `publish_post` | LinkedIn isn't connected | → `connect_platform({ platform: "linkedin" })` |
| `cadence_conflict` on `update_post` (schedule) | Day already at platform cap | → `get_calendar` to find an open slot |
| `feature_required` | User's tier doesn't include this platform | → `get_plans` for upgrade options |

---

## Recipe 2 — Schedule a week of LinkedIn posts

**Goal**: schedule 4-7 LinkedIn posts across a week with proper spacing.

**When to use**: user says *"plan my LinkedIn for next week"*, *"give me 5 posts for the week"*, or asks for a content calendar.

### Tool sequence

1. **Check current cadence** — `get_calendar({ platforms: ["linkedin"], from: "...", to: "..." })` shows what's already scheduled. LinkedIn cap is 2 posts/day.
2. **Build a campaign** — prefer `create_campaign` over N individual `create_post` calls. The campaign tool runs cadence pre-flight across the whole batch in one shot.
3. **Inspect for conflicts** — if the response is `campaign_cadence_conflict`, the response includes `conflicts[]` with every offending post. Redistribute dates and retry.
4. **Approve when satisfied** — `confirm_campaign({ campaign_id, action: "approve_all" })` flips every post in the campaign to `scheduled`. Cron picks them up at their `scheduled_date`.

### Sample call

```json
{
  "name": "create_campaign",
  "arguments": {
    "name": "Week of 2026-05-19 LinkedIn",
    "posts": [
      { "content": "Monday post...", "schedule_for": "2026-05-19T09:00:00Z" },
      { "content": "Tuesday post...", "schedule_for": "2026-05-20T09:00:00Z" },
      { "content": "Thursday post...", "schedule_for": "2026-05-22T09:00:00Z" },
      { "content": "Friday post...", "schedule_for": "2026-05-23T15:00:00Z" }
    ],
    "platform": "linkedin"
  }
}
```

### Sample failure response (cadence conflict)

```json
{
  "success": false,
  "error": "campaign_cadence_conflict",
  "message": "Campaign schedule would violate linkedin cadence rules in 1 place(s).",
  "conflicts": [
    {
      "rule": "spacing",
      "platform": "linkedin",
      "message": "linkedin requires 240-minute spacing between posts...",
      "conflictingPostIds": ["post_x", "post_y"]
    }
  ],
  "recovery_hint": { "tool": "get_calendar" }
}
```

### Recovery from cadence conflict

- The conflict object tells you `rule` (per_day / per_week / spacing), `platform`, and the IDs that triggered it.
- Re-pick the conflicting post's date and retry `create_campaign`. The whole batch revalidates atomically.

---

## Recipe 3 — Multi-platform adaptation

**Goal**: take one piece of content and publish it on LinkedIn, X, and Threads with each platform's voice.

**When to use**: user says *"adapt this for X / Threads too"* or *"post this everywhere"*.

### Tool sequence

1. **Adapt** — `adapt_for_platform({ draft: { content: "..." }, platforms: ["x", "threads"] })`. Returns platform-specific drafts. Doesn't auto-save.
2. **Create each post** — call `create_post` with the adapted content per platform.
3. **Schedule with spacing** — same-platform spacing applies; cross-platform doesn't. LinkedIn at 10am + X at 10am = fine.
4. **Approve** — `confirm_post` per post or `confirm_campaign` if you grouped them.

### Sample call

```json
{
  "name": "adapt_for_platform",
  "arguments": {
    "draft": {
      "content": "Long-form LinkedIn post about distribution being the bottleneck for solo founders..."
    },
    "platforms": ["x", "threads"]
  }
}
```

### Sample response

```json
{
  "success": true,
  "adapted": {
    "x": {
      "content": "Distribution is the bottleneck for solo founders. Three lessons:\n\n1. ...\n2. ...\n3. ...",
      "thread_split": true,
      "char_count": 248
    },
    "threads": {
      "content": "Distribution > polish. Found this out the hard way ↓",
      "char_count": 51
    }
  },
  "message": "Adapted. Call create_post per platform to commit."
}
```

### Failure modes

- If `adapt_for_platform` returns no adaptation for a platform (e.g. content can't be safely shortened), the response lists why. Don't auto-create empty posts.

---

## Recipe 4 — Recover from a publish failure

**Goal**: a scheduled post didn't go live. Figure out why and either republish or escalate.

**When to use**: user says *"my Monday post didn't go up"*, *"why isn't this published?"*, or you get a `publish_in_progress` / `publish_failed` response.

### Tool sequence

1. **Read current state** — `get_post({ post_id })`. The `publishing_status` field is the key signal:
   - `idle` — never attempted yet (cron will pick it up at `scheduled_date`)
   - `publishing` — in-flight (another process is publishing it right now)
   - `published` — done; `last_publish_response.url` has the live link
   - `failed` — last attempt failed; `publication_attempts` shows how many tries; max is 3
2. **Diagnose** — `last_publish_response` (when failed) contains the platform's error message. Common causes:
   - LinkedIn token expired → `connect_platform({ platform: "linkedin" })`
   - Content rejected by platform → `update_post` with revised content
   - Image URL broken → `set_post_image` with a fresh URL or remove
3. **Republish** — `publish_post({ post_id })` re-tries the publish. The CAS-based state machine prevents double-publish (if it actually succeeded on the prior attempt but the response dropped, this returns the cached response).

### Sample diagnosis

```json
// get_post response
{
  "id": "post_a1b2c3d4",
  "status": "failed",
  "publishing_status": "failed",
  "publication_attempts": 3,
  "publish_error": "LinkedIn API: rate-limited by platform (HTTP 429)",
  "last_publish_response": null
}
```

This case: hit max attempts (3). The cron won't auto-retry anymore. Action: wait for platform rate-limit to clear, then call `publish_post({ post_id })` manually. The handler will reset `publication_attempts` on a fresh manual attempt.

### Failure modes

| Diagnostic field | Meaning | Action |
|---|---|---|
| `publishing_status: 'publishing'` AND `publish_started_at` > 5min ago | Stuck — the reaper will flip it to `failed` within 5 minutes | Wait, then retry |
| `publication_attempts: 3` AND status `failed` | Terminal — won't auto-retry | Manual `publish_post` call needed |
| `publish_error` mentions `auth` / `token` / `401` | Platform token expired | → `connect_platform` |

---

## Recipe 5 — Reschedule a post (cadence-aware)

**Goal**: move a scheduled post to a different time without breaching the platform's daily cap.

**When to use**: user says *"move Monday's post to Wednesday"*, *"can we push this to next week?"*, or wants to react to a calendar conflict.

### Tool sequence

1. **Check the target slot** — `get_calendar({ platforms: ["linkedin"], from: "<target-day>", to: "<target-day>" })` shows what's there.
2. **Attempt the reschedule** — `reschedule_post({ post_id, new_scheduled_date: "..." })`. Pre-flight cadence is built in.
3. **Handle conflict** — if `cadence_conflict`, the response includes the conflicting post IDs. Pick a different time.

### Sample call

```json
{
  "name": "reschedule_post",
  "arguments": {
    "post_id": "post_a1b2c3d4",
    "new_scheduled_date": "2026-05-21T10:00:00Z"
  }
}
```

### Sample failure (cadence conflict)

```json
{
  "success": false,
  "error": "cadence_conflict",
  "rule": "per_day",
  "platform": "linkedin",
  "message": "Scheduling another linkedin post for 2026-05-21 would exceed the 2/day cadence cap.",
  "conflicting_post_ids": ["post_other1", "post_other2"],
  "recovery_hint": { "tool": "get_calendar" }
}
```

### Failure modes

| Error | Meaning | Recovery |
|---|---|---|
| `cadence_conflict` rule=`per_day` | Target day already at cap | Pick a different day |
| `cadence_conflict` rule=`spacing` | Target time too close to another post (LinkedIn 4hr, X 30min, Threads 30min) | Pick a time further from the conflicting post |
| `cadence_conflict` rule=`per_week` | Rolling 7-day window at cap | Spread into next week |
| `cannot_update_published` | Post already went live | Can't reschedule a published post — create a new one |

---

## Recipe 6 — Find and fix a problem post

**Goal**: user mentions a post by topic / keyword. Find it, diagnose, fix.

**When to use**: user says *"that post about distribution"*, *"the typo one"*, or any reference by content rather than ID.

### Tool sequence

1. **Search** — `search_posts({ query: "distribution", platform: "linkedin" })`. Returns matching posts ranked by relevance.
2. **Inspect candidates** — `get_post({ post_id, include: ["performance"] })` for the top match. The `include` array can fetch related data (performance metrics, image, scheduling).
3. **Apply the fix** — `update_post({ post_id, content: "..." })` for content fixes. `set_post_image({ post_id, image_url })` for image. `reschedule_post` for date.
4. **Confirm with the user** — show the diff or summarise the change before reporting back.

### Sample search call

```json
{
  "name": "search_posts",
  "arguments": {
    "query": "distribution bottleneck",
    "platform": "linkedin",
    "limit": 5
  }
}
```

### Failure modes

- `search_posts` returns empty — try broader keywords or remove the platform filter. If still empty, the post may have already been deleted; surface `get_calendar` for the user to scan manually.

---

## Recipe 7 — Check account health

**Goal**: user wants a quick read on their content cadence, gaps, stale drafts.

**When to use**: user says *"am I on track this week?"*, *"how's my LinkedIn pacing?"*, *"what should I post today?"*.

### Tool sequence

1. **Get the health snapshot** — `get_calendar_health()`. Returns a structured summary of:
   - Clusters (multiple posts in same day)
   - Gaps (more than 3 days between posts on a platform)
   - Stale drafts (drafts older than 7 days)
   - Cadence drift (current pace vs target)
2. **Pair with usage** — `get_usage()` for monthly cap status.
3. **Recommend** — based on the gaps + drift signals, propose what to draft next.

### Sample response

```json
{
  "success": true,
  "summary": "On pace for the week. 1 gap detected on Threads (5 days since last post).",
  "signals": {
    "clusters": [],
    "gaps": [{
      "platform": "threads",
      "last_post_date": "2026-05-08",
      "days_since": 5,
      "recommendation": "Threads cadence is 2-4/week. Post something today to avoid algorithm penalty."
    }],
    "stale_drafts": [{
      "post_id": "post_x", "title": "Old draft from 2 weeks ago", "age_days": 14
    }],
    "cadence_drift": null
  }
}
```

### Failure modes

- This tool is read-only and rarely fails. Common path is empty results when the user has no scheduled / draft posts.

---

## Recipe 8 — Personal vs LinkedIn company page

**Goal**: post AS the company page instead of the personal profile.

**When to use**: user mentions *"company page"*, *"post as [Company Name]"*, or has linked a LinkedIn company page.

### Tool sequence

1. **List available accounts** — `list_accounts()`. Returns both the personal LinkedIn and any company pages the user has admin access to.
2. **Bind the post to a page** — `create_post({ ..., company_page_id: "uuid" })`. When `company_page_id` is set, the cron + `publish_post` publishes AS the company, not personally.
3. **Cadence is per-account** — the 2 posts/day LinkedIn cap is enforced independently for personal AND each company page. Posting once personally and once on the company page is two separate daily allowances.

### Sample call

```json
{
  "name": "create_post",
  "arguments": {
    "content": "Excited to share our Q2 product roadmap...",
    "platform": "linkedin",
    "company_page_id": "cp_8a3f...",
    "scheduled_date": "2026-05-20T09:00:00Z"
  }
}
```

### Failure modes

| Error | Meaning | Recovery |
|---|---|---|
| `INVALID_PARAMS` "company_page_id ... not found or inactive" | The page UUID doesn't belong to this user OR the page was disabled | → `list_accounts` for valid options |
| `INVALID_PARAMS` "company_page_id only valid for linkedin" | Tried to bind X/Threads to a company page (LinkedIn-only today) | Drop the param |
| `cadence_conflict` | The company page itself has hit 2/day | Reschedule or pick a different date |

> Company page publishing is feature-flagged behind `COMPANY_PAGES_ENABLED`. If `list_accounts` returns no company pages but the user expects them, the feature isn't enabled for this tenant yet.

---

## Error recovery patterns (general)

Every FeedSquad MCP error response follows the same shape:

```json
{
  "success": false,
  "error": "<machine-readable code>",
  "message": "<human-readable explanation>",
  "recovery_hint": {
    "tool": "<which tool to call next>",
    "reason": "<why this recovery path>",
    "args": { "...": "optional pre-filled args" }
  }
}
```

**Rule of thumb for agents**: when a tool fails, read `recovery_hint.tool` first. The hint is hand-curated per error path — following it is more reliable than retrying blindly or guessing the next step.

### Common error codes

| Code | When | Default recovery |
|---|---|---|
| `auth_required` | No FeedSquad MCP token in the request | OAuth flow (`action_url`) |
| `scope_insufficient` | Token doesn't have the scope the tool requires | Re-authorize with broader scope |
| `feature_required` | User's tier doesn't include this capability | `get_plans` for upgrade |
| `rate_limit` | Account-wide rate limit fired (separate from cadence) | Wait `retry_after` seconds; `get_usage` to inspect |
| `cadence_conflict` | Per-platform cadence rule breached (per_day, per_week, spacing) | `get_calendar` to find an open slot |
| `linkedin_required` (or `twitter_required`, `threads_required`) | Platform isn't connected | `connect_platform({ platform })` |
| `not_found` | Post / campaign ID doesn't exist or isn't owned by user | `search_posts` / `get_calendar` |
| `validation_failed` | Health check or schema validation failed | Read `message` for which field; fix and retry |
| `internal_error` | Server-side failure (DB, network, platform 5xx) | Retry once; if persistent, see `recovery_hint` (typically `get_post`) |

### Request IDs

Every MCP response includes an `x-mcp-request-id` header (format: `mcp_<12hex>`). When users report a failure, ask them to share that ID — it correlates the route log line, dispatcher log line, Sentry event, and support ticket. The ID also flows backwards: if the agent passes its own `x-mcp-request-id` request header, FeedSquad uses that instead.

---

## Idempotency

For network-unstable retries on writes, supply `idempotency_key` on `create_post` and `publish_post`. Same key + same user replays the original row instead of creating a duplicate. Different key + same user creates a new post; using a key already bound to a different post returns `INVALID_PARAMS` (so an agent can't accidentally steal a key from another conversation).

```json
// First call — creates the post
{ "name": "create_post", "arguments": { "content": "...", "idempotency_key": "draft-2026-05-13-001" } }

// Network drops; agent retries with the same key
{ "name": "create_post", "arguments": { "content": "...", "idempotency_key": "draft-2026-05-13-001" } }
// → returns { success: true, replay: true, post_id: <same as first call> }
```

Pick keys that are stable for the user's intent (e.g. `<session-id>-<turn-id>`), not random per request.

---

## What this cookbook doesn't cover

- **OAuth setup details** — see [mcp.md § Install in 60 Seconds](https://feedsquad.com/mcp.md).
- **Per-tool parameter reference** — see [mcp.md § Tools (19)](https://feedsquad.com/mcp.md).
- **Pricing and feature gating** — see [feedsquad.com/pricing](https://feedsquad.com/pricing).
- **Brand voice / content style** — handled by FeedSquad's content agents (Ghost / Pulse / Stitch); the MCP layer accepts any string content the agent produces.

---

Part of [FeedSquad](https://feedsquad.com) · Last updated: 2026-05-13.
