Plainpaper

by Plainpaper

Recommended SSE
492 downloads
Not rated
Website

About

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.

Details

Author
Plainpaper
Downloads
492
Transport
SSE
Categories
AI

Setting up with Highlight

This MCP is not yet compatible with Highlight’s one-click setup. However, you can still use it with Highlight by following these steps:

  1. Download and install Highlight from highlightai.com/download
  2. Navigate to the plugins tab and select "Add Custom Plugin"
  3. Configure the plugin with the settings below
    Plugin Name Plainpaper
    Command (node, npx, python, etc.)

    Please refer to the README for specific instructions on how to obtain API keys or other required environment variables.

  4. Enable "Start Automatically" if you want the plugin to start when Highlight launches

list_workspaces

List 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.

get_board

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_board

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

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.

set_board_summary

Write the per-board summary the map surfaces.

set_board_anchor_date

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}``.

arrange_board

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_card

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…

get_card

Return a single card with its FULL body (the detail read — not the bounded map).

update_card

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.

set_status

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.

add_live_link

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.

remove_live_link

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``).

set_card_timing

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}``.

list_card_versions

Version history for a card, newest-first. Empty list for an unknown/scoped-away card.

query_cards

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``.

link_cards

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.

unlink_cards

Remove the matching edge by ``(from, to, type)``. A missing edge is ``not_found``.

list_edges

List every edge on ``board_id`` (the lineage graph the agent reasons over).

crown_winner

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_phase

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_phases

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_phase

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.

set_phase_schedule

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_card_phase

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.

link_phases

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``.

unlink_phases

Remove the matching phase edge by ``(from, to, type)`` (GRAPH-04). A missing edge is ``not_found``.

list_phase_edges

List every phase edge on ``board_id`` (the phase-graph the agent reasons over; GRAPH-04).

list_templates

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.

get_template

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_board_from_template

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_deliverable_types

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.

get_deliverable_type

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_mockups

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.

get_asset

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.

upload_asset

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.

upload_asset_from_url

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.

create_asset_upload

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).

finalize_asset_upload

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.

whoami

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_action

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``).

approve_action

Agent approval — REJECTED server-side (``forbidden``/403) for money/irreversible/structural kinds (APPR-05); a reversible kind succeeds (APPR-04).

reject_action

Reject a pending action (chat_auth). A non-pending action is a ``conflict``; a missing/scoped-away id is ``not_found``.

mark_executed

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).

get_action

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_pending_actions

List the board's actions awaiting approval so the agent can poll state (APPR-07). Read-only, RLS-scoped — no platform call.

list_comments

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.

resolve_comment

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_all_comments

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.

get_guidelines

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.

create_guideline_block

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.

update_guideline_block

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.

delete_guideline_block

Remove a guideline block from the workspace library. Unknown id is not_found.

upload_guideline_asset

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.

Claude Desktop / Cursor

Paste into your MCP client config file to install this server.

{
    "mcpServers": {
        "plainpaper": {
            "plainpaper": {
                "type": "http",
                "url": "https://mcp.plainpaper.io/mcp"
            }
        }
    }
}

McpServers

{
    "plainpaper": {
        "type": "http",
        "url": "https://mcp.plainpaper.io/mcp"
    }
}

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.

No reviews yet — be the first

Sign in to leave a review

Use Google, GitHub, or an email account so ratings stay tied to real people.

Email sign in

No reviews posted yet.