bash Copy
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 Copy
# 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
bash Copy
npx skills add -y https://twinwrite.comThe 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.
bash Copy
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 Copy
{
"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.
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. Pass it as a bearer token: Authorization: Bearer sk_live_… on every REST request, or via --auth-token on the MCP install command above. 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.
json Copy
{
"error": {
"code": "rate_limit_exceeded",
"message": "Per-key request rate exceeded; retry after 30s.",
"retry_after_seconds": 30
}
}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 Copy
{
"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 Copy
{
"type": "object",
"required": [
"ok",
"drafts"
],
"properties": {
"ok": {
"const": true
},
"drafts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiDraft"
}
}
}
}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 Copy
{
"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 Copy
{
"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.getFetch one draft by id.
Returns 404 if the draft is unknown or owned by another tenant.
Request body schema json Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
}
}
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"draft"
],
"properties": {
"ok": {
"const": true
},
"draft": {
"$ref": "#/components/schemas/ApiDraft"
}
}
}POST /api/v1/agent/drafts.approveApprove 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 Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": "string"
}
}
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"draft"
],
"properties": {
"ok": {
"const": true
},
"draft": {
"$ref": "#/components/schemas/ApiDraft"
}
}
}POST /api/v1/agent/drafts.rejectReject a pending draft.
Flips status to `rejected`. Subsequent `drafts.schedule` is rejected.
Request body schema json Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": "string"
}
}
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"draft"
],
"properties": {
"ok": {
"const": true
},
"draft": {
"$ref": "#/components/schemas/ApiDraft"
}
}
}Locked once the draft leaves `pending`. Provide `body` for LinkedIn drafts, `thread_items` for X drafts.
Request body schema json Copy
{
"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 Copy
{
"type": "object",
"required": [
"ok",
"draft"
],
"properties": {
"ok": {
"const": true
},
"draft": {
"$ref": "#/components/schemas/ApiDraft"
}
}
}Auto-approves the draft if it is currently `pending`. Rejected/posted drafts are not eligible.
Request body schema json Copy
{
"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 Copy
{
"type": "object",
"required": [
"ok",
"draft",
"scheduled_at"
],
"properties": {
"ok": {
"const": true
},
"draft": {
"$ref": "#/components/schemas/ApiDraft"
},
"scheduled_at": {
"type": "string",
"format": "date-time"
}
}
}Includes both active and inactive sources.
Request body schema json Copy
{
"type": "object"
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"feeds"
],
"properties": {
"ok": {
"const": true
},
"feeds": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiFeed"
}
}
}
}Pass `{ url }` to add a single feed directly, or `{ linkedin_paste }` to receive AI-generated feed suggestions.
Request body schema json Copy
{
"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 Copy
{
"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"
}
}
}
}
}
}
]
}Historical feed_items are preserved so draft provenance survives.
Request body schema json Copy
{
"type": "object",
"required": [
"feed_id"
],
"properties": {
"feed_id": {
"type": "string",
"format": "uuid"
}
}
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"feed_id"
],
"properties": {
"ok": {
"const": true
},
"feed_id": {
"type": "string",
"format": "uuid"
}
}
}Returns voice_profile + brand_questions verbatim plus the language settings used by the pipeline.
Request body schema json Copy
{
"type": "object"
}Success response schema json Copy
{
"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"
]
}
}
}Newest first.
Request body schema json Copy
{
"type": "object"
}Success response schema json Copy
{
"type": "object",
"required": [
"ok",
"campaigns"
],
"properties": {
"ok": {
"const": true
},
"campaigns": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiCampaign"
}
}
}
}POST /api/v1/agent/campaigns.createCreate a campaign.
A tenant may have at most one `active` campaign at a time. The created row defaults to `active`.
Request body schema json Copy
{
"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 Copy
{
"type": "object",
"required": [
"ok",
"campaign"
],
"properties": {
"ok": {
"const": true
},
"campaign": {
"$ref": "#/components/schemas/ApiCampaign"
}
}
}Only fields you supply are PATCHed. Setting `status: "active"` enforces the one-active-at-a-time invariant.
Request body schema json Copy
{
"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 Copy
{
"type": "object",
"required": [
"ok",
"campaign"
],
"properties": {
"ok": {
"const": true
},
"campaign": {
"$ref": "#/components/schemas/ApiCampaign"
}
}
}Maps to internal status `paused`. Use `campaigns.update` with `status` to activate.
Request body schema json Copy
{
"type": "object",
"required": [
"campaign_id"
],
"properties": {
"campaign_id": {
"type": "string",
"format": "uuid"
}
}
}Success response schema json Copy
{
"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.
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 Copy
{
"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
}
}
}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 Copy
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"pending",
"approved",
"rejected",
"scheduled",
"posted",
"skipped"
]
},
"cursor": {
"type": "string"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
}
}drafts_getFetch one draft by id.
Returns 404 if the draft is unknown or owned by another tenant.
Request body schema json Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
}
}
}drafts_approveApprove 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 Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": "string"
}
}
}drafts_rejectReject a pending draft.
Flips status to `rejected`. Subsequent `drafts.schedule` is rejected.
Request body schema json Copy
{
"type": "object",
"required": [
"draft_id"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
},
"reason": {
"type": "string"
}
}
}Locked once the draft leaves `pending`. Provide `body` for LinkedIn drafts, `thread_items` for X drafts.
Request body schema json Copy
{
"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"
}
}
}
}
}
}Auto-approves the draft if it is currently `pending`. Rejected/posted drafts are not eligible.
Request body schema json Copy
{
"type": "object",
"required": [
"draft_id",
"scheduled_at"
],
"properties": {
"draft_id": {
"type": "string",
"format": "uuid"
},
"scheduled_at": {
"type": "string",
"format": "date-time"
}
}
}Includes both active and inactive sources.
Request body schema json Copy
{
"type": "object"
}Pass `{ url }` to add a single feed directly, or `{ linkedin_paste }` to receive AI-generated feed suggestions.
Request body schema json Copy
{
"oneOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"format": "uri"
}
}
},
{
"type": "object",
"required": [
"linkedin_paste"
],
"properties": {
"linkedin_paste": {
"type": "string"
}
}
}
]
}Historical feed_items are preserved so draft provenance survives.
Request body schema json Copy
{
"type": "object",
"required": [
"feed_id"
],
"properties": {
"feed_id": {
"type": "string",
"format": "uuid"
}
}
}Returns voice_profile + brand_questions verbatim plus the language settings used by the pipeline.
Request body schema json Copy
{
"type": "object"
}Newest first.
Request body schema json Copy
{
"type": "object"
}campaigns_createCreate a campaign.
A tenant may have at most one `active` campaign at a time. The created row defaults to `active`.
Request body schema json Copy
{
"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"
]
}
}
}Only fields you supply are PATCHed. Setting `status: "active"` enforces the one-active-at-a-time invariant.
Request body schema json Copy
{
"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"
]
}
}
}Maps to internal status `paused`. Use `campaigns.update` with `status` to activate.
Request body schema json Copy
{
"type": "object",
"required": [
"campaign_id"
],
"properties": {
"campaign_id": {
"type": "string",
"format": "uuid"
}
}
}