1. MCP install (Claude Code)

One command, then a normal Claude Code prompt. TwinWrite runs as a remote MCP server — no local process to babysit.

bash
claude mcp add twinwrite --url https://twinwrite.com/mcp --auth-token sk_live_…

Replace sk_live_… with a key from /dashboard/settings/api-keys. The same key gates REST and MCP — there is no second handshake.

Try it

Paste this into your Claude Code session:

Use twinwrite to draft 3 LinkedIn posts about my latest product launch and schedule them for this week.

Claude will discover and call the matching tools:

text
# Claude Code's tool trace for the prompt above
1. twinwrite.drafts_generate({"source":{"topic":"my latest product launch"}})
   → {"ok":true,"drafts":[{"id":"…","platform":"linkedin","status":"pending",…}, …]}
2. twinwrite.drafts_schedule({"draft_id":"…","scheduled_at":"2026-05-06T13:00:00Z"})
   → {"ok":true,"draft":{…,"status":"scheduled"},"scheduled_at":"2026-05-06T13:00:00Z"}
(Claude repeats step 2 for each accepted draft.)

The MCP tool list mirrors the REST surface 1:1 — every endpoint below is also reachable as a tool named family_action (e.g. drafts.generate drafts_generate). Protocol version 2025-03-26, server version 1.0.0.

After connecting, run: npx skills add -y https://twinwrite.com

Pro tip: add the skill

Add the TwinWrite skill to teach Claude your best content workflows.

bash
npx skills add -y https://twinwrite.com

The MCP server gives Claude the tools. The skill teaches Claude how to use them well — daily routines, campaign workflows, feed management recipes. Skill files are served from /.well-known/skills/twinwrite/ and refresh whenever you re-run npx skills add.

2. REST quickstart

For non-Claude agents and curl-curious users. POST + JSON, bearer auth, sync responses.

bash
curl -X POST https://twinwrite.com/api/v1/agent/drafts.generate \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"source":{"url":"https://example.com/article"}}'

Expected response

json
{
  "ok": true,
  "drafts": [
    {
      "id": "1d8b…",
      "status": "pending",
      "platform": "linkedin",
      "locale": "en",
      "body": "Three lessons from the article that apply to your team…",
      "thread_items": null,
      "image_url": null,
      "feed_item_id": null,
      "campaign_id": null,
      "scheduled_for": null,
      "created_at": "2026-05-03T12:34:56Z",
      "updated_at": "2026-05-03T12:34:56Z"
    }
  ]
}

The full operation reference is below, plus the OpenAPI 3.1 document at /api/v1/agent/openapi.json for SDK generators.

3. Auth + key management

One key type, used by both REST and MCP. Per-workspace. Scoped to the whole workspace.

  1. Open /dashboard/settings/api-keys and click Create key. The full sk_live_… token is shown once — copy it into your client (or 1Password) before dismissing.
  2. Pass it as a bearer token: Authorization: Bearer sk_live_… on every REST request, or via --auth-token on the MCP install command above.
  3. Revoke from the same settings page. Revocation is immediate — the next request returns 401 key_revoked.

Lost a key? You cannot recover it — revoke it and create a new one. TwinWrite only stores a SHA-256 hash plus the first 8 characters for identification.

Error envelope

Both surfaces return the same AgentApiError shape on failure. Throttling adds a Retry-After header (REST) and a retry_after_seconds field (both).

json
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Per-key request rate exceeded; retry after 30s.",
    "retry_after_seconds": 30
  }
}

REST reference (OpenAPI 1.0.0)

Auto-generated from lib/agent-api/spec.ts. Adding an endpoint there updates this list on the next render — there is no hand-edited content here. Full spec: /api/v1/agent/openapi.json.

POST/api/v1/agent/drafts.generate

Generate drafts from a URL, topic, or campaign.

Synchronous (~10–30s). Returns LinkedIn + X drafts in `pending` status. Honours tenant voice profile and the explicit `language` (or, if omitted, the tenant's primary/secondary language plan). Subject to the per-tenant daily token cap.

Request body schema
json
{
  "type": "object",
  "required": [
    "source"
  ],
  "properties": {
    "source": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "url"
          ],
          "properties": {
            "url": {
              "type": "string",
              "format": "uri"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "topic"
          ],
          "properties": {
            "topic": {
              "type": "string",
              "maxLength": 600
            }
          }
        },
        {
          "type": "object",
          "required": [
            "campaign_id"
          ],
          "properties": {
            "campaign_id": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      ]
    },
    "platforms": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "linkedin",
          "x"
        ]
      }
    },
    "language": {
      "type": "string",
      "minLength": 2,
      "maxLength": 2
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "drafts"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "drafts": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/ApiDraft"
      }
    }
  }
}

POST/api/v1/agent/drafts.list

List drafts (cursor-paginated).

Filter by API status. Returns up to `limit` drafts (default 50, max 100) plus an opaque `next_cursor`; pass it back to fetch the next page.

Request body schema
json
{
  "type": "object",
  "properties": {
    "status": {
      "type": "string",
      "enum": [
        "pending",
        "approved",
        "rejected",
        "scheduled",
        "posted",
        "skipped"
      ]
    },
    "cursor": {
      "type": "string"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "drafts",
    "next_cursor"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "drafts": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/ApiDraft"
      }
    },
    "next_cursor": {
      "type": [
        "string",
        "null"
      ]
    }
  }
}

POST/api/v1/agent/drafts.get

Fetch one draft by id.

Returns 404 if the draft is unknown or owned by another tenant.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "draft"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "draft": {
      "$ref": "#/components/schemas/ApiDraft"
    }
  }
}

POST/api/v1/agent/drafts.approve

Approve a pending draft.

Flips status to `approved`. Optional `reason` is recorded as part of the approval log (no-op for V1).

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "reason": {
      "type": "string"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "draft"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "draft": {
      "$ref": "#/components/schemas/ApiDraft"
    }
  }
}

POST/api/v1/agent/drafts.reject

Reject a pending draft.

Flips status to `rejected`. Subsequent `drafts.schedule` is rejected.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "reason": {
      "type": "string"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "draft"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "draft": {
      "$ref": "#/components/schemas/ApiDraft"
    }
  }
}

POST/api/v1/agent/drafts.update

Edit a draft body (LinkedIn) or thread items (X).

Locked once the draft leaves `pending`. Provide `body` for LinkedIn drafts, `thread_items` for X drafts.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "body": {
      "type": "string"
    },
    "thread_items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "content"
        ],
        "properties": {
          "content": {
            "type": "string"
          }
        }
      }
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "draft"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "draft": {
      "$ref": "#/components/schemas/ApiDraft"
    }
  }
}

POST/api/v1/agent/drafts.schedule

Schedule a draft for publishing via Late.

Auto-approves the draft if it is currently `pending`. Rejected/posted drafts are not eligible.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id",
    "scheduled_at"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "scheduled_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "draft",
    "scheduled_at"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "draft": {
      "$ref": "#/components/schemas/ApiDraft"
    },
    "scheduled_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}

POST/api/v1/agent/feeds.list

List the tenant's feed sources.

Includes both active and inactive sources.

Request body schema
json
{
  "type": "object"
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "feeds"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "feeds": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/ApiFeed"
      }
    }
  }
}

POST/api/v1/agent/feeds.add

Add an RSS feed by URL or discover from a LinkedIn paste.

Pass `{ url }` to add a single feed directly, or `{ linkedin_paste }` to receive AI-generated feed suggestions.

Request body schema
json
{
  "oneOf": [
    {
      "type": "object",
      "required": [
        "url"
      ],
      "properties": {
        "url": {
          "type": "string",
          "format": "uri"
        }
      }
    },
    {
      "type": "object",
      "required": [
        "linkedin_paste"
      ],
      "properties": {
        "linkedin_paste": {
          "type": "string"
        }
      }
    }
  ]
}
Success response schema
json
{
  "oneOf": [
    {
      "type": "object",
      "required": [
        "ok",
        "feed"
      ],
      "properties": {
        "ok": {
          "const": true
        },
        "feed": {
          "type": "object",
          "properties": {
            "url": {
              "type": "string"
            },
            "title": {
              "type": [
                "string",
                "null"
              ]
            },
            "item_count": {
              "type": "integer"
            }
          }
        }
      }
    },
    {
      "type": "object",
      "required": [
        "ok",
        "suggestions"
      ],
      "properties": {
        "ok": {
          "const": true
        },
        "suggestions": {
          "type": "array",
          "items": {
            "type": "object",
            "required": [
              "url",
              "title",
              "rationale"
            ],
            "properties": {
              "url": {
                "type": "string"
              },
              "title": {
                "type": "string"
              },
              "rationale": {
                "type": "string"
              }
            }
          }
        }
      }
    }
  ]
}

POST/api/v1/agent/feeds.remove

Soft-delete a feed source (sets active=false).

Historical feed_items are preserved so draft provenance survives.

Request body schema
json
{
  "type": "object",
  "required": [
    "feed_id"
  ],
  "properties": {
    "feed_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "feed_id"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "feed_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}

POST/api/v1/agent/voice.read

Read the tenant's brand voice profile.

Returns voice_profile + brand_questions verbatim plus the language settings used by the pipeline.

Request body schema
json
{
  "type": "object"
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "voice_profile",
    "brand_questions"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "voice_profile": {
      "type": [
        "object",
        "null"
      ]
    },
    "brand_questions": {
      "type": [
        "object",
        "null"
      ]
    },
    "linkedin_url": {
      "type": [
        "string",
        "null"
      ]
    },
    "primary_language": {
      "type": [
        "string",
        "null"
      ]
    },
    "secondary_language": {
      "type": [
        "string",
        "null"
      ]
    },
    "x_language": {
      "type": [
        "string",
        "null"
      ]
    }
  }
}

POST/api/v1/agent/campaigns.list

List campaigns for the tenant.

Newest first.

Request body schema
json
{
  "type": "object"
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "campaigns"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "campaigns": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/ApiCampaign"
      }
    }
  }
}

POST/api/v1/agent/campaigns.create

Create a campaign.

A tenant may have at most one `active` campaign at a time. The created row defaults to `active`.

Request body schema
json
{
  "type": "object",
  "required": [
    "title",
    "post_count"
  ],
  "properties": {
    "title": {
      "type": "string",
      "maxLength": 200
    },
    "description": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_text": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_urls": {
      "type": "array",
      "items": {
        "type": "string",
        "format": "uri"
      }
    },
    "post_count": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    },
    "start_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "end_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "mix_mode": {
      "type": "string",
      "enum": [
        "rss_reframed",
        "pure",
        "mixed"
      ]
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "campaign"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "campaign": {
      "$ref": "#/components/schemas/ApiCampaign"
    }
  }
}

POST/api/v1/agent/campaigns.update

Partially update a campaign.

Only fields you supply are PATCHed. Setting `status: "active"` enforces the one-active-at-a-time invariant.

Request body schema
json
{
  "type": "object",
  "required": [
    "campaign_id"
  ],
  "properties": {
    "campaign_id": {
      "type": "string",
      "format": "uuid"
    },
    "title": {
      "type": "string"
    },
    "description": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_text": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_urls": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "post_count": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    },
    "start_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "end_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "mix_mode": {
      "type": "string",
      "enum": [
        "rss_reframed",
        "pure",
        "mixed"
      ]
    },
    "status": {
      "type": "string",
      "enum": [
        "active",
        "paused",
        "completed"
      ]
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "campaign"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "campaign": {
      "$ref": "#/components/schemas/ApiCampaign"
    }
  }
}

POST/api/v1/agent/campaigns.deactivate

Deactivate (pause) a campaign.

Maps to internal status `paused`. Use `campaigns.update` with `status` to activate.

Request body schema
json
{
  "type": "object",
  "required": [
    "campaign_id"
  ],
  "properties": {
    "campaign_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}
Success response schema
json
{
  "type": "object",
  "required": [
    "ok",
    "campaign"
  ],
  "properties": {
    "ok": {
      "const": true
    },
    "campaign": {
      "$ref": "#/components/schemas/ApiCampaign"
    }
  }
}

MCP reference

Auto-generated from lib/agent-api/mcp-manifest.ts, which is itself derived from the same ENDPOINTS list as the REST reference. Tool count + input schemas are guaranteed to match by tests/agent-mcp.test.ts.

drafts_generate

Generate drafts from a URL, topic, or campaign.

Synchronous (~10–30s). Returns LinkedIn + X drafts in `pending` status. Honours tenant voice profile and the explicit `language` (or, if omitted, the tenant's primary/secondary language plan). Subject to the per-tenant daily token cap.

Request body schema
json
{
  "type": "object",
  "required": [
    "source"
  ],
  "properties": {
    "source": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "url"
          ],
          "properties": {
            "url": {
              "type": "string",
              "format": "uri"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "topic"
          ],
          "properties": {
            "topic": {
              "type": "string",
              "maxLength": 600
            }
          }
        },
        {
          "type": "object",
          "required": [
            "campaign_id"
          ],
          "properties": {
            "campaign_id": {
              "type": "string",
              "format": "uuid"
            }
          }
        }
      ]
    },
    "platforms": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "linkedin",
          "x"
        ]
      }
    },
    "language": {
      "type": "string",
      "minLength": 2,
      "maxLength": 2
    }
  }
}

drafts_list

List drafts (cursor-paginated).

Filter by API status. Returns up to `limit` drafts (default 50, max 100) plus an opaque `next_cursor`; pass it back to fetch the next page.

Request body schema
json
{
  "type": "object",
  "properties": {
    "status": {
      "type": "string",
      "enum": [
        "pending",
        "approved",
        "rejected",
        "scheduled",
        "posted",
        "skipped"
      ]
    },
    "cursor": {
      "type": "string"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    }
  }
}

drafts_get

Fetch one draft by id.

Returns 404 if the draft is unknown or owned by another tenant.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}

drafts_approve

Approve a pending draft.

Flips status to `approved`. Optional `reason` is recorded as part of the approval log (no-op for V1).

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "reason": {
      "type": "string"
    }
  }
}

drafts_reject

Reject a pending draft.

Flips status to `rejected`. Subsequent `drafts.schedule` is rejected.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "reason": {
      "type": "string"
    }
  }
}

drafts_update

Edit a draft body (LinkedIn) or thread items (X).

Locked once the draft leaves `pending`. Provide `body` for LinkedIn drafts, `thread_items` for X drafts.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "body": {
      "type": "string"
    },
    "thread_items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "content"
        ],
        "properties": {
          "content": {
            "type": "string"
          }
        }
      }
    }
  }
}

drafts_schedule

Schedule a draft for publishing via Late.

Auto-approves the draft if it is currently `pending`. Rejected/posted drafts are not eligible.

Request body schema
json
{
  "type": "object",
  "required": [
    "draft_id",
    "scheduled_at"
  ],
  "properties": {
    "draft_id": {
      "type": "string",
      "format": "uuid"
    },
    "scheduled_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}

feeds_list

List the tenant's feed sources.

Includes both active and inactive sources.

Request body schema
json
{
  "type": "object"
}

feeds_add

Add an RSS feed by URL or discover from a LinkedIn paste.

Pass `{ url }` to add a single feed directly, or `{ linkedin_paste }` to receive AI-generated feed suggestions.

Request body schema
json
{
  "oneOf": [
    {
      "type": "object",
      "required": [
        "url"
      ],
      "properties": {
        "url": {
          "type": "string",
          "format": "uri"
        }
      }
    },
    {
      "type": "object",
      "required": [
        "linkedin_paste"
      ],
      "properties": {
        "linkedin_paste": {
          "type": "string"
        }
      }
    }
  ]
}

feeds_remove

Soft-delete a feed source (sets active=false).

Historical feed_items are preserved so draft provenance survives.

Request body schema
json
{
  "type": "object",
  "required": [
    "feed_id"
  ],
  "properties": {
    "feed_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}

voice_read

Read the tenant's brand voice profile.

Returns voice_profile + brand_questions verbatim plus the language settings used by the pipeline.

Request body schema
json
{
  "type": "object"
}

campaigns_list

List campaigns for the tenant.

Newest first.

Request body schema
json
{
  "type": "object"
}

campaigns_create

Create a campaign.

A tenant may have at most one `active` campaign at a time. The created row defaults to `active`.

Request body schema
json
{
  "type": "object",
  "required": [
    "title",
    "post_count"
  ],
  "properties": {
    "title": {
      "type": "string",
      "maxLength": 200
    },
    "description": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_text": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_urls": {
      "type": "array",
      "items": {
        "type": "string",
        "format": "uri"
      }
    },
    "post_count": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    },
    "start_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "end_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "mix_mode": {
      "type": "string",
      "enum": [
        "rss_reframed",
        "pure",
        "mixed"
      ]
    }
  }
}

campaigns_update

Partially update a campaign.

Only fields you supply are PATCHed. Setting `status: "active"` enforces the one-active-at-a-time invariant.

Request body schema
json
{
  "type": "object",
  "required": [
    "campaign_id"
  ],
  "properties": {
    "campaign_id": {
      "type": "string",
      "format": "uuid"
    },
    "title": {
      "type": "string"
    },
    "description": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_text": {
      "type": [
        "string",
        "null"
      ]
    },
    "context_urls": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "post_count": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    },
    "start_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "end_date": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "mix_mode": {
      "type": "string",
      "enum": [
        "rss_reframed",
        "pure",
        "mixed"
      ]
    },
    "status": {
      "type": "string",
      "enum": [
        "active",
        "paused",
        "completed"
      ]
    }
  }
}

campaigns_deactivate

Deactivate (pause) a campaign.

Maps to internal status `paused`. Use `campaigns.update` with `status` to activate.

Request body schema
json
{
  "type": "object",
  "required": [
    "campaign_id"
  ],
  "properties": {
    "campaign_id": {
      "type": "string",
      "format": "uuid"
    }
  }
}
Agent API · TwinWrite