---
title: "How do I plan posts on a content calendar from code or an AI agent?"
description: "Plan posts on a content calendar from code or an AI agent with the calendar REST routes or MCP tools: social:read to list, social:publish to write."
updated: 2026-11-04
---

You plan posts on a content calendar by creating calendar entries: from code, send `POST /api/v1/calendar` with an API key that has the `social:publish` scope; from an AI agent, use the `create_calendar_entry` MCP tool. REST and MCP use the same server functions, so each step below gives both the route and the tool.

A plan is not a post. Creating an entry creates no social post, and a plan does not schedule a post by itself. The Content Calendar page combines plans with real social posts, and browser sessions use the same routes as your code.

The steps below take you from the right key to a planned post you can see in a week's list. If the API is new to you, start with the [API overview](/docs/api/overview). To work from an agent, first see how to [use Wireflow from your agent](/docs/use-wireflow-from-your-agent).

## Which key scope do I need to read or edit a calendar?

You need `social:read` to read calendars and their entries, and `social:publish` to change them; `social:publish` also covers calendar reads on REST and MCP. Default API keys have neither permission, and a key without the scope a route needs gets `401`, like every other endpoint. For keys, see [API authentication](/docs/api/authentication).

| Scope | What it allows on calendars |
|---|---|
| `social:read` | List calendars and list entries |
| `social:publish` | List calendars and entries, create and edit calendars, and create, edit, move and delete entries |

Your team role counts too. Team owners, admins and members can edit; viewers can read. Every request checks membership server-side, and the calendar list returns your edit access as `canEdit`. A team calendar can use accounts connected by any current member, but that does not grant permission to publish as another member.

For an agent, both scopes are opt-in permissions on the [MCP connector](/docs/mcp) at `https://www.wireflow.ai/api/mcp`, outside its default grant. Your client can request them by name in its OAuth scope parameter, or you can select them on the consent screen. Existing connections do not gain optional permissions automatically.

Grant the narrower scope when it is enough. Over MCP, `social:publish` also lets an agent post to your connected accounts, publicly under your name, while `social:read` cannot post. The calendar tools themselves spend no credits.

## How do I find or create a calendar?

List your calendars with `GET /api/v1/calendars` (MCP: `list_calendars`), and create one with `POST /api/v1/calendars` (MCP: `create_calendar`) if none fits. A personal workspace or a team can own several calendars, one per client or brand.

```bash
curl https://www.wireflow.ai/api/v1/calendars \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Without `teamId`, you get the active workspace. Pass `teamId=null` in the query, or `"teamId": null` in a JSON body, for personal calendars, or name a team you belong to. The result is `{ data: { calendars, accounts, members, teamId, canEdit } }`. Each calendar includes its `id`, `version`, name, color, timezone, accountIds and briefUrls, and accounts never include credentials. A calendar's `id` is the `calendarId` you send in the entry calls.

To create a calendar, send its settings and an `idempotencyKey`:

```bash
curl -X POST https://www.wireflow.ai/api/v1/calendars \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Client launch",
    "color": "#8B5CF6",
    "timezone": "America/Los_Angeles",
    "accountIds": ["native:cm1yt4k2"],
    "briefUrls": ["https://www.wireflow.ai/flow/brand-board-id"],
    "idempotencyKey": "client-launch-calendar-v1"
  }'
```

| Field | Required | What to send |
|---|---|---|
| `name` | Yes | The calendar's name |
| `color` | Yes | A six-digit hex color |
| `timezone` | Yes | An IANA timezone |
| `idempotencyKey` | Yes | A key of 8 to 200 characters |
| `accountIds` | No | Accounts that belong to the selected workspace; defaults to an empty list |
| `briefUrls` | No | https Wireflow `/flow/` board links; defaults to an empty list |

[Social Accounts](/docs/api/social) lists the accounts connected to your login, each with an id that starts with `native:`.

To change settings later, send `PATCH /api/v1/calendars/:id` (MCP: `update_calendar`) with `expectedVersion`, `idempotencyKey` and the settings to change. Account or board-link arrays you send replace the saved list, so send the full list you want to keep.

## How do I list what is planned for a week?

Send `GET /api/v1/calendar` (MCP: `list_calendar_entries`) with `from` at local midnight on the first day and `to` at local midnight seven days later, both as ISO timestamps with timezone offsets. The range includes `from` and excludes `to`, up to 93 days. Give each midnight its own offset, because daylight saving days can be 23 or 25 hours.

```bash
curl "https://www.wireflow.ai/api/v1/calendar?calendarId=your-calendar-id&from=2026-10-18T00:00:00-07:00&to=2026-10-25T00:00:00-07:00" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```javascript
const params = new URLSearchParams({
  calendarId: "your-calendar-id",
  from: "2026-10-18T00:00:00-07:00",
  to: "2026-10-25T00:00:00-07:00",
});
const res = await fetch(`https://www.wireflow.ai/api/v1/calendar?${params}`, {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});
const { data } = await res.json();
for (const entry of data.entries) {
  console.log(entry.id, entry.version);
}
```

```python
import requests

res = requests.get(
    "https://www.wireflow.ai/api/v1/calendar",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "calendarId": "your-calendar-id",
        "from": "2026-10-18T00:00:00-07:00",
        "to": "2026-10-25T00:00:00-07:00",
    },
)
for entry in res.json()["data"]["entries"]:
    print(entry["id"], entry["version"])
```

The result is `{ data: { entries, calendars, accounts, members, teamId, canEdit } }`, and it lists real posts as well as plans. Entries include `id`, `calendarId`, `version`, timestamp, title, platform, account, status, caption, media and links. Their activity lists who changed which fields, the time, and the API key id or MCP client name when an agent made the change.

Leave out `calendarId` to cover all calendars in the selected workspace. The filters `status`, `platform`, `ownerId` and `accountId` combine, as in this request that filters by platform and status:

```http
GET /api/v1/calendar?calendarId=your-calendar-id&from=2026-10-18T00:00:00-07:00&to=2026-10-25T00:00:00-07:00&platform=instagram&status=scheduled
```

A plan's status is `idea` (the default), `in_production`, `ready`, `scheduled` or `published`. A linked post reports its real delivery status, including `failed`, `publishing`, `published_private`, `sent_to_inbox` and `cancelled` where applicable. `sent_to_inbox` means the creator must finish in the social app; it is not published.

## How do I add, edit or move a planned post?

Add a plan with `POST /api/v1/calendar` (MCP: `create_calendar_entry`), edit it with `PATCH /api/v1/calendar/:id` (MCP: `update_calendar_entry`), and move it with `POST /api/v1/calendar/:id/move` (MCP: `move_calendar_entry`).

**Add the plan.** Send the calendar, a title, a platform, the planned time and an `idempotencyKey`:

```bash
curl -X POST https://www.wireflow.ai/api/v1/calendar \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "calendarId": "your-calendar-id",
    "title": "Behind the scenes",
    "platform": "instagram",
    "plannedAt": "2026-10-20T09:00:00-07:00",
    "briefUrl": "https://www.wireflow.ai/flow/your-board-id",
    "notes": "Film the new setup",
    "status": "in_production",
    "idempotencyKey": "behind-scenes-plan-v1"
  }'
```

```javascript
const res = await fetch("https://www.wireflow.ai/api/v1/calendar", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    calendarId: "your-calendar-id",
    title: "Behind the scenes",
    platform: "instagram",
    plannedAt: "2026-10-20T09:00:00-07:00",
    briefUrl: "https://www.wireflow.ai/flow/your-board-id",
    notes: "Film the new setup",
    status: "in_production",
    idempotencyKey: "behind-scenes-plan-v1",
  }),
});
console.log(res.status, await res.json());
```

```python
import requests

res = requests.post(
    "https://www.wireflow.ai/api/v1/calendar",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "calendarId": "your-calendar-id",
        "title": "Behind the scenes",
        "platform": "instagram",
        "plannedAt": "2026-10-20T09:00:00-07:00",
        "briefUrl": "https://www.wireflow.ai/flow/your-board-id",
        "notes": "Film the new setup",
        "status": "in_production",
        "idempotencyKey": "behind-scenes-plan-v1",
    },
)
print(res.status_code, res.json())
```

`calendarId`, `title`, `platform`, `plannedAt` and `idempotencyKey` are required. The optional fields are `accountId`, `briefUrl`, `notes`, `ownerId`, `status` and `postId`. The owner defaults to you and must belong to the workspace, and an account must be linked to the calendar. `postId` attaches an existing real post returned by `publish_post`, and its account and platform must match the entry.

**Get its id and version.** Run the week's list again. The plan is in `entries` with its `id` and `version`. Edits, moves and deletes need the `expectedVersion` returned by the latest list.

**Edit it.** PATCH changes only the fields you send. Send `expectedVersion` and `idempotencyKey` with them; here `3` stands for the `version` from your latest list:

```bash
curl -X PATCH https://www.wireflow.ai/api/v1/calendar/your-entry-id \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "ready", "expectedVersion": 3, "idempotencyKey": "behind-scenes-ready-v1"}'
```

Sending `null` clears briefUrl or notes, and accountId on an unlinked plan. Once a real post is linked, postId and the delivery fields stay locked unless the post failed with confirmation that nothing was posted and you explicitly clear or replace postId. Otherwise, sending `postId: null` on a linked entry returns `409 linked_post_read_only`.

**Move it.** List again for the current version, then send the destination `calendarId` to the move route, adding `plannedAt` if the time changes too:

```bash
curl -X POST https://www.wireflow.ai/api/v1/calendar/your-entry-id/move \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"calendarId": "destination-id", "expectedVersion": 3, "idempotencyKey": "move-behind-scenes-v1"}'
```

You need edit access to both calendars, and the account and linked post must fit the destination. Moving a plan does not change a real post's delivery time.

**Check it worked.** List the week again with the `calendarId` the plan now sits in. Your plan is in `entries`, and its activity lists who changed which fields.

From an agent, ask for the same steps in plain words. The matching tools are `list_calendars`, `create_calendar_entry` and `list_calendar_entries`:

```text
In my Wireflow content calendars, find the "Client launch" calendar. Plan an entry there titled "Behind the scenes" for 20 October 2026 at 09:00 Los Angeles time, platform instagram, status in_production, with the note "Film the new setup". Only plan it: do not publish anything. Then list that calendar from 18 to 25 October 2026 so I can check the entry is there.
```

When a plan is ready to go out, posting is a separate step: a [Social Publish node](/docs/nodes/action--social_publish) or the MCP `publish_post` tool. Given `plannedEntryId` and `expectedVersion`, `publish_post` publishes to one matching account and links the queued post to the plan in the same transaction, and a version conflict posts nothing. For posting itself, see the [publishing guide](/docs/publish).

## What happens if a request times out or two people edit the same entry?

If a request times out, send the identical request again with the same `idempotencyKey` and you get the original result. If someone else changed the entry since your last list, your write gets `409 version_conflict` and changes nothing.

Every write requires an `idempotencyKey` of 8 to 200 characters in the JSON body. The `Idempotency-Key` header is not the calendar contract, so keep the key in the body. Unknown fields are refused rather than ignored.

| Response | Why you get it | What to do |
|---|---|---|
| `409 idempotency_conflict` | You reused a key with different content | Reuse a key only with the identical request, and give a changed request a new key |
| `409 version_conflict` | Someone else changed the row after your last list, so nothing changed | List again, reconcile the new values, then send the current version and a new `idempotencyKey` |

The version check covers entry updates, moves and deletes, and calendar updates. To see who made the other change, read the entry's activity in the list. The MCP tools follow the same contract: each write tool takes an `idempotencyKey`, and the edit, move and delete tools also take `expectedVersion`.

This Python version of the add request retries after a timeout with the same body and key, so a retry gets the original result:

```python
import requests

body = {
    "calendarId": "your-calendar-id",
    "title": "Behind the scenes",
    "platform": "instagram",
    "plannedAt": "2026-10-20T09:00:00-07:00",
    "briefUrl": "https://www.wireflow.ai/flow/your-board-id",
    "notes": "Film the new setup",
    "status": "in_production",
    "idempotencyKey": "behind-scenes-plan-v1",
}

for _ in range(3):
    try:
        res = requests.post(
            "https://www.wireflow.ai/api/v1/calendar",
            headers={"Authorization": "Bearer YOUR_API_KEY"},
            json=body,
            timeout=30,
        )
    except requests.exceptions.Timeout:
        continue
    print(res.status_code, res.json())
    break
```

## Does deleting a plan cancel the real post?

No: deleting a plan with `DELETE /api/v1/calendar/:id` (MCP: `delete_calendar_entry`) removes it from the calendar, and its linked real post stays scheduled or published.

DELETE takes a JSON body with `expectedVersion` and `idempotencyKey`, and there is no undo through the API:

```bash
curl -X DELETE https://www.wireflow.ai/api/v1/calendar/your-entry-id \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expectedVersion": 3, "idempotencyKey": "delete-behind-scenes-v1"}'
```

The other entry writes leave the real post alone too: an update never edits delivery, and moving a plan does not change a real post's delivery time.

To stop a scheduled post as well, an agent with `social:publish` can call the MCP `cancel_post` tool. It stops a scheduled post before it goes out, and once delivery has started it reports too late, never cancelled. To check the post afterwards, `get_post_status` reports whether posts made with `publish_post` are scheduled, published (with the live URL) or failed.

Last updated: 2026-11-04

---

Documentation index: fetch https://www.wireflow.ai/llms.txt for the full list of Wireflow docs.
