
PlainpaperVerifiedFeatured
@Plainpaper
About Plainpaper
The AI marketing canvas for Claude and MCP agents. Your agent drafts campaigns as cards on a shared board, briefed by your brand rules, approved by you.
Connection details
https://mcp.plainpaper.io/mcpSetup
claude mcp add plainpaper --transport http https://mcp.plainpaper.io/mcpTools
54List the workspaces this token can reach — each ``{id, name, is_home}``. A token may be connected to several workspaces at once. Pass an ``id`` from this list as the optional ``workspace_id`` argument of a workspace-scoped tool (create_board, list_boards, list_templates, get_template, create_board_from_template, get_guidelines, create_guideline_block, upload_guideline_asset) to work in that one. Omitting ``workspace_id`` always uses the ``is_home`` workspace. Tools that already name a board, card, phase, asset, comment or action need NO workspace argument — the id determines the workspace by itself.
Rehydrate the board: ``view="map"`` is the bounded, body-free rehydrate payload; ``view="full"`` adds a keyset-paginated card page. The map is the clean-session reconstruct path. ``view`` tolerates ``null`` → defaults to ``map``. The payload also carries ``tag_config`` — the board's card-category + status-color vocabulary — so a clean session learns the tag set in the SAME rehydrate call. It also carries ``deliverable_types`` — the structured deliverable types to PREFER when your content matches one (e.g. a marketing email -> email_campaign) — surfaced in the SAME rehydrate call. ``board.url`` is the page the human opens to see this board — share it when you tell them where you are working. Each card row carries ``live_links``: the platform URLs that card has ALREADY been pushed to (add_live_link writes them). READ THEM BEFORE YOU PUSH — a card already carrying a Brevo campaign link has been sent there, and pushing it again creates a duplicate campaign. The board is a GRID of phases. ``board.phase_cols`` describes its LAYOUT: ``>= 1`` is how many phase COLUMNS wide the grid is, and ``0`` means FREE FORMAT — the human arranged this board by hand, so ``arrange_board`` tidies it without restructuring it. The meta reports 0 VERBATIM on such a board (it is never coalesced away to the default 3). Each phase carries ``grid``: ``{col, row}`` — its column (left to right) and its rank within that column (top to bottom), or ``null`` for a phase with no position yet. Read them to describe the pipeline the human actually built ("Proof sits under Position, Create is the next column") and to place new phases sensibly with ``create_phase(below_phase_id=…)``. A phase can EMBED another board (a portal — e.g. a central "strategy"/"research" board): such a phase carries ``embedded_board_id``/``embedded_board_name``, and the payload's ``embedded_boards`` list surfaces each embedded board's body-free contents (board meta + guidance + phases + a card index with summaries) so you pick it up automatically here — no separate get_board needed. It is one level deep; get_card an embedded card by id for its full body. Embedding is a HUMAN action — there is no tool to create or change an embed. WHEN each piece happens is here too. Each card row carries ``timing``: either ``{"kind": "relative", "offset_days": -14}`` (rendered ``T-14``) or ``{"kind": "date", "date": "2026-09-22"}`` (optionally with ``end_date`` for a range), or ``null`` for an untimed card. The board carries ``anchor_date`` — its ``T``, the day every relative timing resolves against, or ``null`` if nobody has set one. And the map carries a derived ``schedule``: this board's timed cards ALREADY IN ORDER, as groups of ``{id, resolved_date}`` — ONE resolved timeline when the anchor is set, otherwise a ``relative`` group followed by a ``dated`` one. Read them: on a clean session this is how you recover not just WHAT exists and HOW it connects but WHEN each piece is meant to happen — the one dimension that otherwise survives only in a chat transcript that scrolls away. A phase can also BE the schedule (``is_schedule``), rendering that timeline on the canvas instead of holding cards.
Create a blank board. ``workspace_id`` picks which of this token's workspaces it lands in (see list_workspaces); omit it for the home workspace. Returns ``{id, name, url}``. GIVE THE HUMAN THE ``url`` in your reply — "I created the board, you can follow along here: <url>". A board they cannot find is a board they cannot steer, and watching it assemble live is the point.
List boards, each with its ``workspace_id`` + ``workspace_name`` + ``url``. ``url`` is the page the human opens to watch that board — hand it over whenever you tell someone which board you are working on. With NO ``workspace_id`` this lists boards across EVERY workspace this token can reach — the fastest way to find the board you want when a token is connected to several. Pass a ``workspace_id`` (from list_workspaces) to narrow it to one. Includes boards the human has ARCHIVED — they carry a non-null ``archived_at`` (0040). An archived board is only shelved off the human's overview gallery, never restricted: reading and writing it by id works exactly as before. Prefer an active board when starting new work.
Write the per-board summary the map surfaces.
Set this board's ANCHOR DATE — its ``T``, the day every relative card timing resolves against. Format ``YYYY-MM-DD`` (e.g. ``"2026-09-22"``). Pass ``null`` (or omit it) to CLEAR the anchor. SET IT WHENEVER YOU KNOW THE LAUNCH DAY. If you write ``T-14`` on a card and never say what ``T`` is, every relative timing you wrote stays unresolvable — the schedule can only group your offsets, not place them on the calendar. With the anchor set, relative and dated cards interleave into ONE real timeline. It is a DISPLAY / RESOLUTION input ONLY. It never rewrites any card's stored ``timing`` — a ``T-14`` card stays ``T-14`` when the anchor moves, which is the whole point of storing an offset — and it never touches any card's status. Moving the launch day does not complete or un-complete anything. A board that only ever uses real calendar dates never needs an anchor; leaving it null is a correct, common state, not a missing setting. This is a direct write rather than a proposal because it is ONE reversible field, the same reasoning that makes ``set_board_summary`` direct. The human can always override it in Board settings. Returns ``{id, anchor_date}``.
Snap the board to its phase GRID and re-slot every phased card inside its phase, in reading order (a deliberate full RESET — unphased cards are left untouched). Use to tidy a board on demand. Every phase KEEPS ITS COLUMN: a tidy straightens each column and packs it top-down, it never moves a phase sideways or re-orders the board the human arranged. On a FREE-FORMAT board (``board.phase_cols == 0``) it only TIDIES — it snaps each phase onto the canvas grid and pushes apart anything that overlaps, and never re-columns or re-rows anything. Running it twice changes nothing the second time. A board not visible under RLS is ``not_found``.
Create a typed card on ``board_id``. Plainpaper has structured DELIVERABLE types (see get_board's ``deliverable_types``, or ``list_deliverable_types``). When the content you're creating MATCHES one — e.g. a marketing email -> ``email_campaign`` — you MUST create it AS that deliverable type: set ``type`` to the deliverable key (e.g. "email_campaign"), use its ``content_kind`` (omit content_kind and it defaults to the deliverable's kind), and populate ``fields`` per its schema (call ``get_deliverable_type(key)`` for the exact contract). Do NOT dump that content into a generic markdown card's body. For non-deliverable content, create a normal typed card. For that non-deliverable path (notes, strategy, planning, research), pick the card ``type`` well: first REUSE one of this board's existing categories (the keys already in ``enabled_card_types`` / ``tag_config.card_types`` from get_board) instead of minting a new one. Only when none fits, use a short, semantic, lowercase snake_case key named for the content's ROLE (good: "strategie", "doelgroep", "tijdlijn"; avoid generic buckets like "note"). The category's canvas label is DERIVED from the key (underscores/hyphens become spaces, title-cased: "open_punten" -> "Open Punten"), so choose a clean key and don't pre-format a label. Generic notes stay a plain markdown card, never a deliverable. ``content_kind`` is optional: for a NON-deliverable type it defaults to ``markdown`` (pass ``"media"`` explicitly for asset cards); for a deliverable type it defaults to that type's kind. ``status`` is optional and defaults to ``draft``. Both tolerate ``null`` (agents often send it to mean "use the default"); a non-null *invalid* value is still rejected at the boundary. Provenance is server-derived from the token, never an argument. ``sources`` is the card's CITATIONS — the provenance list the human sees in the card's Citations panel. Whenever the card makes factual claims drawn from research (web search, articles, data), fill it: a list of ``{"title": "...", "url": "https://..."}`` objects (a bare URL string is also accepted and normalized). Do NOT encode citations inside the body instead — chat-style ``<cite index="...">`` tags mean NOTHING to Plainpaper: they are stripped when the card renders, so attribution written that way is silently lost. Keep body prose clean and put every source in ``sources`` (it can be replaced later via update_card's ``sources``). ``timing`` records WHEN this card happens — the email that goes out on the 22nd, the photo brief that has to land two weeks before launch. There are EXACTLY two shapes and no others: ``{"kind": "relative", "offset_days": -14}`` — a signed whole-DAY offset from the board's anchor date, rendered ``T-14`` / ``T+0`` / ``T+7`` (days only: two weeks before launch is ``-14``, never "2 weeks", and there is no free-text label); or ``{"kind": "date", "date": "2026-09-22"}`` — a fixed calendar date, optionally with ``"end_date": "2026-10-06"`` for something that RUNS over a range. ``end_date`` is valid on a date timing ONLY (never on a relative one) and must be on or after ``date``; ``offset_days`` must be within ±3650. OMIT ``timing`` for an untimed card — that is the default and the common case. To change or CLEAR a timing afterwards call ``set_card_timing``; ``update_card`` cannot express a clear. Stamp the timing on the card ITSELF and do not ALSO restate that date in the body prose — a date written in two places is a date that will disagree with itself. A timing is when the card is MEANT to happen, never a trigger: Plainpaper never executes, nothing sends or fires at a date, and a timing never sets or moves the card's status. Media (images/video/files) must live in Plainpaper — do NOT put an external http(s):// media URL in body/fields. Ingest remote media via upload_asset_from_url(...) then reference it with an asset:// token carrying the EXACT id the upload tool returned; an external media URL is rejected (invalid_argument). NEVER invent o…
Return a single card with its FULL body (the detail read — not the bounded map).
Meaningful edit with optimistic concurrency. A stale ``expected_version`` raises a structured ``version_conflict`` (refetch and retry); the edit snapshots the pre-image into version history. ``type`` re-categorizes the card (a novel category is allowed — not allow-listed); omitted/``null`` leaves it unchanged (the MCP enum-/field-tolerance discipline). ``sources`` REPLACES the card's citations list — the provenance the human sees in the Citations panel. Same shape as create_card: [{"title": "...", "url": "https://..."}] (bare URL strings accepted); omitted/``null`` leaves citations unchanged. Never write chat-style <cite index="..."> tags into the body — Plainpaper strips them at render and the attribution is silently lost; cite via ``sources``. There is deliberately NO ``timing`` here — use ``set_card_timing``. Every field in this tool COALESCES (``null``/omitted means "leave unchanged"), so a clear would be inexpressible: passing ``timing: null`` here could only ever mean "keep the one it has", never "remove it". For a deliverable ``type``, ``fields`` is schema-validated against that type's contract; invalid fields are rejected with the full contract in the error. Media (images/video/files) must live in Plainpaper — do NOT put an external http(s):// media URL in body/fields. Ingest remote media via upload_asset_from_url(...) then reference it with an asset:// token carrying the EXACT id the upload tool returned; an external media URL is rejected (invalid_argument). NEVER invent or template an asset id (asset://[SOME_ID] and any non-existent id are rejected). For an image that does not exist yet, write <img src="placeholder://slot-name" alt="what belongs here"> — the card cannot reach status 'approved' until every placeholder is replaced with a real asset://<id>. For a creative, fields.mockups names the frames the human reviews it in and AT LEAST ONE is required before it can reach status 'approved' (zero fields.mockups is rejected with invalid_argument on the move to 'approved'). Call list_mockups(shape=<your fields.format>) for the ids — e.g. instagram_feed, instagram_reels, tiktok_in_feed, linkedin_feed, pinterest_standard_pin for the AD shapes, whatsapp_message / sms_message for the `message` shape — and use each one's `aspect` to decide what to upload; unknown ids are tolerated and render a generic frame. fields.cta AND fields.destination_url are ALSO required to approve an AD shape but OPTIONAL for a `message` shape (a plain SMS has no link — OMIT both keys rather than sending an empty string). Mockups are presentation only: they never change what you push to the platform.
Move a card to a new status (the fixed platform-wide status set). Moving to ``approved`` is gated: a card whose content still contains a ``placeholder://<slot>`` image token (or an unresolvable ``asset://`` ref) is rejected with ``invalid_argument`` naming the unfilled slot(s) — upload each image (upload_asset / upload_asset_from_url), swap the token for the returned asset://<id> via update_card, then approve.
Record WHERE this card now lives on an external platform, after you pushed it there. Call this immediately after you create the thing on the destination — the Brevo/Klaviyo campaign, the Meta/TikTok ad or ad set, the scheduled post, the dashboard the results came from. Paste the URL a human can open in that platform's own UI. Without this the link exists only in our conversation, and the board — the durable record — cannot say what the card became. NEVER INVENT THE PATH. Most platform APIs hand back an id and no link at all (Brevo returns no console URL anywhere), which makes "compose a plausible URL around the id" a very easy mistake — and a fabricated link is well-formed, so nothing downstream can tell it from a real one. Use a URL you actually saw, or the shape that platform's brief in ``get_board`` -> enabled tools declares, substituting the id the platform returned. Plainpaper knows the real URL shapes for some platforms and will REFUSE a console URL that matches none of them, naming the ones that exist. If you pushed an object kind it does not cover, do not improvise: tell the human what you created and give them the id, and they can paste the URL onto the card themselves. ``label`` is what the human reads ("Brevo campaign", "Meta ad set — Summer sale"); ``platform`` is the destination's short name ("brevo", "meta", "klaviyo"). Both optional; OMIT them rather than sending an empty string. This does NOT change the card's status, deliberately: a campaign you just created may still be a DRAFT in the platform, an ad set may be PAUSED, a template may be awaiting review. If the state also changed, move it yourself with set_status, using one of the statuses THIS BOARD uses (get_board -> tag_config) — never assume "live". A card holds up to 8 links, so several platforms can carry the same creative. Recording the SAME url again updates that entry instead of duplicating it. Only ``http(s)://`` links are accepted. Returns the card's full ``live_links`` list.
Drop one live link from a card by its exact ``url`` — e.g. the campaign was deleted on the platform, or you recorded the wrong link. Like add_live_link this NEVER changes the card's status. Removing a url the card does not carry succeeds as a no-op (``removed: false``).
Record WHEN this card happens — or REMOVE the timing it already carries. Stamp a timing on every card that has a moment: the email that goes out on the 22nd, the photo brief that has to land two weeks before launch, the offer freeze, the post-launch readout. Cards that genuinely have no moment stay untimed, which is the default. EXACTLY two shapes, and nothing else is accepted: ``{"kind": "relative", "offset_days": -14}`` A signed whole-DAY offset from the board's anchor date, rendered ``T-14`` / ``T+0`` / ``T+7``. Days ONLY — two weeks before launch is ``-14``, never "2 weeks"; there is no weeks unit and no free-text label, because an unsortable label gives the schedule no order. ``offset_days`` must be a signed integer within ±3650 (±10 years). ``{"kind": "date", "date": "2026-09-22"}`` A fixed calendar date. Add ``"end_date": "2026-10-06"`` for something that RUNS over a range (a creative flighted for two weeks). ``end_date`` belongs to a DATE timing only — never to a relative one — and must be on or after ``date``. Omit ``timing`` (or pass ``null``) to REMOVE the card's timing — the card becomes untimed. This is a full REPLACE, never a merge: whatever you pass IS the card's timing afterwards. A timing is when the card is MEANT to happen, never a trigger. Plainpaper never executes: NOTHING sends, publishes, queues or fires at a date, and this tool does NOT change the card's status. A card whose date has passed is not thereby done. If the state also changed, move it yourself with ``set_status``, using one of the statuses THIS BOARD uses (``get_board`` -> ``tag_config``). Setting a timing is not a "meaningful edit": no version bump, no version-history snapshot, and no ``expected_version`` round-trip — so stamping dates across a dozen cards is a dozen plain calls. Returns ``{id, timing}``.
Version history for a card, newest-first. Empty list for an unknown/scoped-away card.
Keyset-paginated, body-free card list on ``board_id`` (the bounded read primitive). Pass the previous page's ``next_cursor`` as ``cursor`` to fetch the next page. ``limit`` tolerates ``null`` → defaults to ``50``.
Create a typed directed edge ``from_card_id --type--> to_card_id`` on ``board_id``. ``type`` is optional and tolerates ``null``; it defaults to ``informs`` (the generic "A feeds into B") — pass ``produces``/``targets``/``measured_by``/``variant_of`` for precise semantics. Use ``precedes`` for an ORDERED time/send-sequence — when A must go out BEFORE B in a multi-step flow such as a drip or welcome email sequence (Mail 1 ``precedes`` Mail 2 ``precedes`` Mail 3); reach for it over the generic ``informs`` whenever the edge expresses send-order rather than lineage. A duplicate edge is a ``conflict``; a self-loop is ``invalid_argument``. ``source_handle`` / ``target_handle`` / ``label`` / ``line_style`` are OPTIONAL canvas-display hints only (which node side the edge attaches to, a display-name override, and ``subtle`` vs ``arrow``); omit them for the normal contract — they never change the ``type`` semantics.
Remove the matching edge by ``(from, to, type)``. A missing edge is ``not_found``.
List every edge on ``board_id`` (the lineage graph the agent reasons over).
Crown the winning variant of an A/B test — a plain status write (no schema). ``winner_status``/``loser_status`` both tolerate ``null`` and default to ``live``/``archived`` respectively. When a ``loser_card_id`` is given the pair MUST be ``variant_of``-linked or it is ``not_found``.
Create a phase (lane) on ``board_id``. ``position`` is optional and tolerates ``null`` → the phase is APPENDED after the board's existing phases and auto-placed in the next free slot on the canvas grid. The agent organizes by phase, not by canvas coordinates. To SPLIT a phase, or add a stage UNDER phase X, pass ``below_phase_id=X``: the new phase lands directly beneath X in X's column, the phases below it in that column are pushed down automatically (with their cards), and it takes the order slot right after X. This is the relative placement to reach for whenever the new stage belongs to an existing part of the pipeline — get_board's per- phase ``grid`` ({col, row}) tells you what is where. An id that is not a phase on this board is an ``invalid_argument``. ``icon`` is the lucide icon shown next to the phase title on the canvas (optional, tolerates ``null`` → defaults to ``pin``). Pick the most fitting one from the schema's enum — e.g. ``search`` for research, ``users`` for audiences, ``calendar`` for planning, ``pen-line`` for drafting, ``mail`` for email, ``rocket`` for launch, ``bar-chart-3`` for results. ``color_scheme`` is the phase's color theme (optional, tolerates ``null``). It defaults to ``default`` and you should ALMOST ALWAYS leave it ``default``. Only choose ``ink`` (navy region) or ``rouge`` (pink region) to make ONE single phase deliberately stand out — never decorative, never more than one phase per board.
List the phases on ``board_id`` in lane order. Each phase carries ``grid``: ``{col, row}`` — which COLUMN of the board's phase grid it sits in (left to right) and its rank within that column (top to bottom), or ``null`` for a phase with no position yet. That is the board's SHAPE; use it when you place a new phase relative to an existing one (``create_phase(below_phase_id=…)``).
Update a phase — rename it and/or restyle it in ONE reversible/direct UPDATE (no snapshot). Pass ONLY the fields you want to change; every field is optional and OMITTED FIELDS ARE LEFT UNCHANGED (this is a partial update, never a replace). At least one changeable field is required. - ``name``: the phase's title. RENAMING IS HOW YOU REPURPOSE A STAGE. When asked to "split" a phase (e.g. Create → Email + Meta Ads), rename the ORIGINAL to one of the new stages and ``create_phase`` the other(s), then ``set_card_phase`` each card — that leaves no empty leftover lane. If a stage genuinely has to GO, you cannot delete it yourself: propose ``board_structure.delete_phase`` (see ``propose_action``) and a human approves it. - ``icon``: the lucide icon shown next to the phase title (e.g. ``search`` for research, ``users`` for audiences, ``mail`` for email, ``rocket`` for launch, ``bar-chart-3`` for results; ``pin`` is the neutral default). - ``color_scheme``: one of ``default`` / ``ink`` / ``rouge``. Leave it ``default`` almost always; pick ``ink`` (navy region) or ``rouge`` (pink region) ONLY to make ONE single phase deliberately stand out — never decorative, never more than one phase per board. - geometry ``x`` / ``y`` / ``w`` / ``h``: the phase's canvas rectangle (BOARD-08). Geometry is all-or-nothing — pass all four together, or omit all four. Optional enum/geometry params tolerate ``null``/omitted (MCP enum null-tolerance). A phase not visible under RLS is ``not_found``. ALL OF IT OR NONE OF IT: icon, colour and geometry are written by ONE statement in ONE transaction, so a failure can no longer leave the icon changed while this tool reports an error — which used to leave the agent's model of the board and the board itself silently disagreeing.
Turn an EMPTY phase into a SCHEDULE, or turn it back into a normal card lane. A schedule phase holds no cards of its own. Instead it RENDERS the board's timeline: every card on the BOARD that carries a ``timing``, in resolved chronological order, read-only — not just the cards of this phase, and not the cards of any phase linked to it. It is a view, not a container. ``is_schedule=true`` converts the phase; ``is_schedule=false`` turns it back into an ordinary card lane. Both directions are this one tool, and the argument is REQUIRED — say which way you mean it. THE PHASE MUST BE EMPTY. Converting a phase that still holds cards is REFUSED, naming how many it holds; move them to another phase (``set_card_phase``) or delete them first. Nothing is ever deleted by this call, and the conversion is reversible from this same tool — which is exactly why it refuses instead of clearing the lane for you. A phase is a card lane, a portal, OR a schedule — never two. Converting a phase that already shows an embedded board is likewise REFUSED; both render centered content in the same region. Both refusals come back as ``invalid_argument`` with a message naming the fix. Read the message and DO the fix — retrying the same call will fail the same way. THE DIVISION OF LABOUR, which is the part agents get wrong: time the CARDS themselves with ``set_card_timing`` (or ``timing`` on ``create_card``); build a schedule phase to DISPLAY them. Never restate in prose a date that is already in a card's ``timing`` — the board is the durable record and a date written twice will disagree with itself. And a schedule is only ever a view: nothing here executes, sends or fires, and no card's status moves. A phase not visible under RLS is ``not_found``.
Set (or clear) a card's phase membership (BOARD-10); ``phase_id=None`` unphases. A card not visible under RLS is ``not_found``. Cards may only be LINKED within one phase, and that holds across moves: a move that would leave this card connected to card(s) in a different phase is rejected (``invalid_argument``). Either unlink_cards first, or move the connected cards into the same phase. A relationship BETWEEN phases belongs on a phase connection (``link_phases``), not on a card edge.
Create a typed directed phase edge ``from_phase_id --type--> to_phase_id`` on ``board_id`` (GRAPH-04). ``type`` is optional and tolerates ``null``; it defaults to ``precedes`` (the sequential workflow link) — pass ``informs``/``feeds`` for other semantics. A duplicate edge is a ``conflict``; a self-loop is ``invalid_argument``.
Remove the matching phase edge by ``(from, to, type)`` (GRAPH-04). A missing edge is ``not_found``.
List every phase edge on ``board_id`` (the phase-graph the agent reasons over; GRAPH-04).
List templates available to a workspace: all official/* plus that workspace's private/*. ``workspace_id`` picks which of this token's workspaces (see list_workspaces); omit for home.
Return a template's full structure + guidance (``official/<slug>`` or ``private/<ws>/<slug>``). A template not visible to the named workspace is ``not_found`` — pass the ``workspace_id`` whose private/* templates you mean; omit for home.
Create a board pre-populated from a template (phases, seed cards, connections, pinned guidance) — atomically. ``workspace_id`` picks which of this token's workspaces the board lands in (omit for home). A template not visible to that workspace is ``not_found``; a malformed definition / invalid seed is ``invalid_argument``. The payload carries a ``url`` — GIVE IT TO THE HUMAN in your reply ("board's ready, follow along here: <url>"), same as create_board.
List the deliverable types this Plainpaper supports — each {key, label, content_kind}. Call get_deliverable_type(key) next to read a type's exact field schema, asset rules, and authoring guidance before creating that deliverable on a clean session.
Return one deliverable type's full authoring contract: {key, label, content_kind, fields_schema, asset_rules, agent_guidance}. Use fields_schema to shape the card's `fields` and agent_guidance for how to author the body. An unknown key is not_found.
List the mockups a creative can be previewed in — each {id, label, platform, brand, surface, aspect, min_res, accepts, guidance, media_guidance, safe_zones}. `media_guidance` is the surface's ART DIRECTION — read it BEFORE generating or sourcing the media, together with `safe_zones` (the regions the platform's own UI covers, as {label, edge, pct}). It is the authority on what the image has to do on that surface, INCLUDING how much text belongs in it, and surfaces genuinely disagree: a LinkedIn carousel slide is meant to carry legible text, an Instagram feed image is not. Pass shape (single_image | video | carousel | vertical_video | message) to see only the surfaces that can stage that creative; omit it for the whole catalog. The ad shapes stage in the ad surfaces (Instagram, TikTok, LinkedIn, Pinterest); the `message` shape stages in the messaging threads (whatsapp_message, sms_message). `ad_format` is a TOLERATED LEGACY ALIAS for `shape` (this used to be an ads-only catalog) — pass either. An unrecognized value returns the whole catalog rather than nothing. Put the ids you choose in the creative's `fields.mockups` — that is what decides the frame the human approves it in, and `aspect` tells you what to upload. Presentation only: a mockup never changes what you push to the platform.
Hand the agent the right SHAPE of an asset it OWNS (RLS-scoped). ``mode`` tolerates ``null`` → defaults to ``url``; ``variant`` tolerates ``null`` → defaults to ``original``. ``mode='url'`` (default) → a short-lived presigned GET (in-app preview; Meta video ``file_url``). ``mode='bytes'`` → a base64 carrier dict (``{encoding:'base64', data, size_bytes}``) for Mailchimp ``file_data`` + Meta chunked uploads — NEVER bare bytes. Over the bytes cap, the tool returns a use-``url`` instruction. ``mode='download_url'`` → a same-origin single-use GET URL (the SAME domain as this MCP server) that streams the ORIGINAL bytes with a bare GET (``curl -O <download_url>`` / ``requests.get(download_url)``). USE THIS IN A SANDBOXED EXECUTION ENVIRONMENT: ``mode='bytes'`` base64 CANNOT be transcribed into a code sandbox (>a few KB corrupts — the same failure class as inline upload). It is single-use — fetch it ONCE and save to disk. ``mode='url'`` needs the Spaces host reachable; ``mode='download_url'`` does not (same domain as /mcp). Ready-gated like ``url``. A non-ready asset returns ``{status, url:None}`` (no presign/token for a half-written object). ``variant`` selects original|thumbnail|poster|transcoded (``download_url`` serves the original). Plainpaper NEVER calls the destination API — the agent does, through the platform's own integration.
Attach an asset to a card from base64-inline bytes (TINY payloads only — else use a URL). The bytes arrive base64-encoded (``data_base64``); the server decodes them, validates the content-type/size BEFORE any Spaces put (``invalid_argument`` on a disallowed/oversize upload), streams them PRIVATELY to Spaces, INSERTs the ``pending`` row (board/workspace derived server-side from the card; a cross-tenant ``card_id`` is ``not_found``), and enqueues the processing job per the kind map (``image/*`` → thumbnail, ``video/*`` → transcode; allowed non-media goes straight to ``ready``). Returns the asset row. CARRIER LIMIT: the inline base64 string is capped (~256 KiB decoded). A larger payload is rejected with ``invalid_argument`` because the agent has to GENERATE the whole base64 blob token-by-token (the call never completes) — use ``upload_asset_from_url(url=...)`` for anything bigger, where the SERVER fetches the bytes. The base64 decode runs OFF the event loop. NOTE: inline base64 is ONLY for tiny REAL bytes you already possess — NEVER synthesize, "draw", or generate an image into it. An LLM cannot produce valid image bytes inline; such bytes are rejected as truncated/incomplete (a full Pillow decode runs before the upload is stored). To add generated marketing imagery, obtain REAL image bytes (an image-generation MCP, or a real hosted URL) and call ``upload_asset_from_url(card_id, url=...)``. For local files, prefer create_asset_upload + a PUT to its ingest_url (no base64). This tool is NEVER the recovery path for a blocked ingest PUT — if the sandbox's egress proxy killed that request, transcribing the same file as base64 here fails too; tell the human instead.
Attach an asset to a card from a FETCHABLE URL — the no-base64 upload path (PREFER this). Instead of emitting the bytes inline (``upload_asset``), the agent passes a fetchable ``http``/``https`` ``url`` and Plainpaper fetches the bytes SERVER-SIDE behind an SSRF guard: private/loopback/link-local/reserved addresses and non-http schemes are ``invalid_argument``, redirects are NOT followed, and the download is capped at the upload limit (an oversize/hung fetch is aborted). The fetched bytes are validated for content-type/size BEFORE any Spaces put, streamed PRIVATELY to Spaces, INSERTed as the ``pending`` row (board/workspace derived server-side from the card; a cross-tenant ``card_id`` is ``not_found``), and the processing job is enqueued per the kind map (``image/*`` → thumbnail, ``video/*`` → transcode; allowed non-media → ``ready``). Returns the asset row. Prefer this over ``upload_asset`` for anything but tiny inline payloads — the agent never has to generate a megabytes-long base64 string. ``filename`` is optional and tolerates ``null`` (accepted for parity/forward-compat; the storage key is asset_id-derived like the other upload paths). Plainpaper NEVER calls the destination platform — it only stores the asset.
Start a direct file upload for a LOCAL file you hold on disk. Returns an ingest_url + upload_url. PREFER ingest_url — a bare PUT from your execution environment with NO auth header, on the SAME domain as this MCP server (no separate Spaces host to allowlist). Run it so failures are VISIBLE: curl -sS --fail-with-body -T /path/to/file "<ingest_url>" # or: requests.put(ingest_url, data=open(path, "rb")) Never use a bare ``-s``: it suppresses curl's error text and leaves you an exit code with no reason. The ingest PUT AUTO-FINALIZES (validates size + full-decodes images, stores the bytes privately, normalizes the stored Content-Type, and enqueues the thumbnail) — you do NOT need to call finalize_asset_upload after it. IF THE PUT GETS NO HTTP RESPONSE (curl exit 56, usually "Received HTTP code 403 from proxy after CONNECT"; requests raises ProxyError/SSLError) your sandbox's egress proxy blocked this domain before the request ever left. That is the CLIENT's network policy, not a Plainpaper failure — the upload token is still unused and nothing is wrong on the server. Do NOT retry the same way, do NOT switch to the presigned upload_url expecting better (a domain allowlist usually blocks that host too), and above all do NOT fall back to inline base64 (upload_asset): a real file is far past what an LLM can transcribe, so that attempt fails as "truncated or malformed" and wastes the turn. Instead STOP and tell the human, in plain words: their code-execution sandbox blocked network access to this server's domain, so they can either (a) allowlist that domain in their client's code-execution / network-egress settings and ask you to retry, or (b) drag the file onto the card in Plainpaper themselves — their browser is not sandboxed, and you continue from the asset id. NOTE: media produced by a GENERATION tool never needs sandbox egress — hand its URL to upload_asset_from_url(card_id, url=...) and Plainpaper fetches the bytes SERVER-SIDE. upload_url is the FALLBACK: a presigned Spaces URL on a DIFFERENT host — PUT the file to it, then call finalize_asset_upload(asset_id) yourself. Use it only when you specifically need the object store directly. This is the RELIABLE path for any local image/file — an LLM cannot transcribe more than a few KB of base64 inline. The pending asset row is created here (board/workspace derived server-side from ``card_id``; a cross-tenant card is ``not_found``). Both the ingest token and the presigned URL are short-lived and single-use/single-object. Reference the finalized asset in card content as asset://<asset_id>. ``filename`` is optional (accepted for parity; the storage key is asset_id-derived like the other upload paths).
Complete the presigned upload_url FALLBACK after the file has been PUT to it. ONLY needed for the presigned upload_url path — the same-origin ingest_url PUT AUTO-FINALIZES, so after an ingest upload you do NOT call this. Verifies the bytes exist, validates size (and, for images, a full decode — a truncated/stub image is rejected), normalizes the stored Content-Type + re-asserts private ACL, and enqueues thumbnail/transcode processing (non-media goes straight to ready). Call once, after the presigned PUT. Calling it after an ingest auto-finalize is HARMLESS — it returns an idempotent already_finalized success, never an error.
Connection test (AGENT-02): confirms the credential authenticates and reports the workspaces it is bound to. ``workspace_id`` is the HOME workspace (the default scope when a tool names none) and ``workspace_ids`` is every workspace this token can reach — call list_workspaces for their names. Returns ONLY non-secret identity, and nothing the verifier would consider sensitive (the bearer credential is never echoed back to the caller).
Propose an action for human approval (lands at ``awaiting_approval``). Provenance + workspace are token-derived (never an argument); a cross-tenant ``board_id`` is ``not_found``. Two families of ``action_kind`` go through here. EXTERNAL kinds — something YOU will do on another platform once a human approves, e.g. ``mailchimp.send_campaign``, ``meta.publish_ad``, ``meta.boost_post``. Plainpaper only gates and records these: after approval you perform the send through that platform's own integration and call ``mark_executed``. ``target`` is free-form — describe the send. INTERNAL ``board_structure.*`` kinds — destructive edits to THIS board that PLAINPAPER applies for you the moment a human approves (no ``mark_executed``; approval lands them at ``executed``). These are the ops deliberately absent from your direct tool surface, and proposing one is how you reach them. ``target`` must carry exactly these keys: - ``board_structure.delete_phase`` — ``{"phase_id": "<uuid>"}``. Removes a phase. Its cards are NOT deleted, they become unphased. This is how you retire a stage you have emptied. Prefer ``update_phase(name=...)`` when you are really REPURPOSING a stage rather than removing one: renaming is yours to do directly and needs no approval. - ``board_structure.reorder_phases`` — ``{"ordered_ids": ["<uuid>", ...]}``. Must list EVERY phase on the board exactly once, in the new order (refetch ``list_phases`` first). - ``board_structure.bulk_move_cards`` — ``{"card_ids": ["<uuid>", ...], "phase_id": "<uuid>"}``. Moves many cards at once; ``phase_id`` may be ``null`` to unphase them, but the KEY MUST BE PRESENT. Include every card of a connected group in one call — cards may only be linked within a phase, so moving half a group is refused. A malformed ``board_structure.*`` target is rejected HERE with ``invalid_argument`` rather than at approval time, so you can fix it while you are still in the loop. You cannot approve any of these yourself — they are human-click-only (``approve_action`` answers ``forbidden``).
Agent approval — REJECTED server-side (``forbidden``/403) for money/irreversible/structural kinds (APPR-05); a reversible kind succeeds (APPR-04).
Reject a pending action (chat_auth). A non-pending action is a ``conflict``; a missing/scoped-away id is ``not_found``.
Record that the agent executed an APPROVED action externally (``approved`` → ``executed``). Plainpaper NEVER executes — this only records the transition (APPR-07); a 2nd call is a ``conflict`` (replay/double-execute guard).
Read an action + its resolved lineage (source cards + typed-edge neighbors) + current state — the agent reads state back to decide whether to proceed (APPR-03/07). A missing/scoped-away id is ``not_found``.
List the board's actions awaiting approval so the agent can poll state (APPR-07). Read-only, RLS-scoped — no platform call.
List the human's comments on ``board_id`` — the anchored instructions to act on. Each comment is anchored to a ``card_id`` OR a ``phase_id`` and carries ``body`` (what the human is asking for), ``status`` (``open``/``resolved``), and author/timestamp. Filter by ``status`` (tolerates ``null``) to fetch only the ``open`` work, or by ``card_id``/``phase_id`` to scope to one target. Resolve each with ``resolve_comment`` as you complete it (or ``resolve_all_comments`` to clear the board). Read-only, RLS-scoped — a board not visible yields an empty list.
Mark a single comment ``resolved`` once you have acted on it (records you as the resolver). Idempotent — resolving an already-resolved comment just re-stamps the resolver. A missing or scoped-away ``comment_id`` is ``not_found``. Use after completing the work the comment asked for; the canvas badge clears and the human sees it move to the Resolved list.
Resolve EVERY open comment on ``board_id`` in one sweep (records you as the resolver on each). Returns ``{board_id, resolved_count, ids}``. Use when you have worked through all of the board's open comments at once; for selective resolution call ``resolve_comment`` per id instead. A board with no open comments resolves zero — never an error.
Return a workspace's Guidelines library — the reusable brand/context building blocks (tone-of-voice text, brand file assets, source links, color palettes, and voice/"Prevent AI language" writing rules). When a board has use_guidelines enabled (see get_board), CALL THIS FIRST and apply the rules before authoring content for that board. File blocks carry an asset_id — reference it as asset://{asset_id}. A voice block's payload carries a `rules` list — follow EVERY string in it verbatim when you write copy (they strip the tell-tale signs of AI writing). Guidelines are per-workspace, and brands differ between them: pass the ``workspace_id`` of the board you are authoring for (see list_workspaces / list_boards), or omit it for home.
Add a guideline block to the workspace library. `kind` picks the shape: text → body; link → url (+ note); color_palette → colors [{label,hex}]; file → asset_id (upload the file first with upload_guideline_asset, then pass its id); voice → style, a dict of boolean toggles (no_ai_punctuation, cut_fluff, no_ai_hype, no_formula, no_emoji) — at least one true. ``workspace_id`` picks which of this token's workspaces the block belongs to (see list_workspaces); omit it for home.
Edit a guideline block. Omitted fields are left unchanged; the block's kind is fixed (delete + recreate to change kind). For a voice block, pass `style` to change the toggles or `note` to change the free-text note — omitting one keeps it. An unknown block_id is not_found.
Remove a guideline block from the workspace library. Unknown id is not_found.
Ingest a brand file (logo, example HTML, fonts) into a workspace from a FETCHABLE http(s) URL — Plainpaper fetches the bytes SERVER-SIDE behind an SSRF guard (private/loopback/reserved addresses and non-http schemes are rejected; redirects are not followed; size-capped). Returns the asset row; pass its `id` as asset_id to create_guideline_block(kind='file'). No card is involved — the asset is workspace-scoped, so ``workspace_id`` picks which of this token's workspaces owns it (see list_workspaces); omit it for home.
Overview
What is Plainpaper
Plainpaper is an infinite canvas for marketing campaigns, shared between a marketer and their AI agent.
If you have used Miro or Milanote, the surface is familiar: an infinite board, cards you drag, group and comment on, phases you can lay a campaign out across. The difference is who fills it in. Plainpaper is AI-agent first. Connect Claude, ChatGPT, Cursor or any MCP-compatible client, and the agent reads the board's current state, produces or revises work, and writes it back as typed cards: research, segments, insights, plans, emails, ad creative and results.
Why connect it to your agent
The agent arrives briefed. A board is not an empty document. It carries the campaign process, the phases and the field structure of each artifact, so the agent knows what a finished marketing email looks like before it starts one. Above that sits a guidelines library holding your brand, voice and do-and-don't rules, which the agent must read before authoring anything. One built-in block is a "Prevent AI language" preset with toggles for AI punctuation, hype, filler, formulaic structure and emoji, passed to the agent verbatim.
You hold the gate. Work lands as typed cards on a shared canvas rather than scrolling past in a chat window. You watch the campaign assemble, edit cards, and leave comments the agent picks up next session. Reversible work the agent does directly. Anything that spends money or cannot be undone is human-only, classified in server code and failing closed.
Memory that does not scroll away. Because campaign state lives on the board rather than in a chat transcript, the agent can open a clean context session and reconstruct an entire campaign in a single call.
What the server exposes
Boards and structure: list_boards, get_board, create_board, create_board_from_template, list_templates, arrange_board, create_phase, set_phase_schedule
Cards and content: create_card, update_card, get_card, query_cards, link_cards, set_status, list_card_versions
Brand and guidelines: get_guidelines, create_guideline_block, update_guideline_block
Approvals and safety: propose_action, approve_action, reject_action, list_pending_actions, mark_executed
Assets: upload_asset, upload_asset_from_url, create_asset_upload, get_asset
Connect
Endpoint: https://mcp.plainpaper.io/mcp
Authentication is OAuth 2.0 with dynamic client registration, so most clients need nothing but the URL. Add it as a custom connector in Claude, or put it in any MCP client config:
{
"mcpServers": {
"plainpaper": {
"type": "http",
"url": "https://mcp.plainpaper.io/mcp"
}
}
}
Step by step setup lives at plainpaper.io/docs/connect-claude.
Pricing
A 7 day free trial with every feature at Starter limits and no credit card, then a limited free tier if the trial lapses. Starter $29 per month, Scale $79, Business $149, all excluding VAT. Every tier has identical features, the tiers differ only in how much you may create.
Two deliberate boundaries
Plainpaper brings no model of its own, so there is no model lock-in and no token resale. And it is not the execution layer: it never holds your email or ad platform credentials, never sends an email, and never publishes a post.
Frequently asked questions
What is the Plainpaper remote MCP server?
The Plainpaper remote MCP server is a hosted Model Context Protocol endpoint at https://mcp.plainpaper.io/mcp, so AI assistants can connect to it without installing or running anything locally.
How do I connect to the Plainpaper MCP server?
Add the endpoint https://mcp.plainpaper.io/mcp to any MCP-compatible client such as Claude Code, Cursor, or VS Code. The setup snippets on this page configure each client in one step.
Does the Plainpaper MCP server require authentication?
Yes. Plainpaper uses OAuth: the first time you connect, your MCP client opens a browser window to sign in and authorize access, then reuses the credentials for future sessions.
Which transport does the Plainpaper MCP server use?
Plainpaper exposes a Streamable HTTP endpoint, the transport used by remote MCP servers and supported by all major MCP clients.
Basic information
Transport
Streamable HTTP
Authentication
OAuth
Category
AI & Agents
Author
Plainpaper
Submitted by
Thierry de Reus
More remote MCP servers
Adobe for creativity
Ideate, create, and deliver with Adobe pro tools
Airtable
Bring your structured data to Claude
Airwallex
Integrate with the Airwallex Platform using Claude
AgentCouch
Messaging rooms for AI agents: hand off context across tools, worktrees, machines, and teammates. Agents meet in shared rooms over MCP, hand off directly, and leave a replayable transcript; live sessions wake on reply, b
1cent
Production remote MCP server with 35 safe web-intelligence tools: 32 paid x402 URL operations plus 3 free discovery/demo tools. Base Mainnet USDC, no account or API key; SSRF protection, strict schemas and idempotent set
Comments