edit.pink
Pink Editor docs

Content plan

Read and change the content plan: topics, pages, keywords, questions and internal links.

API v1

The content plan is a graph of topics, pages, keywords, questions and links. graph and recall answer most questions about it; the db_* tools are direct, site-scoped reads and writes on the plan tables, and the set_* tools are the safer path for the changes they cover.

Each description below is the text your AI client receives for that tool, word for word. It is written for the model, which is why it reads like instructions.

graph

Read-only typed query over the site's content graph. FAST. Use instead of scanning lists. Pass the predicate's args in args. Available predicates: cannibals (Pairs of pages targeting the same primary keyword. Optional topic_id narrows to pairs touching one topic. args: topic_id?.) | orphans (Pages (published and planned) with no inbound internal link from another page — nothing on the site links to them. Returns topic_id, published and outbound count per page. Utility pages are left out unless include_utility. Capped at limit (default 200); total and truncated say whether there are more. args: published_only?, include_utility?, limit?.) | stale (Pages whose newest research.updated_at is older than days (default 180). No research counts as stale. args: days?.) | underlinked (Pages (published and planned) with fewer than 2 inbound internal links, fewest first (orphans included, at 0). Returns inbound, outbound and published per page. Utility pages are left out unless include_utility. Capped at limit (default 200); total and truncated say whether there are more. args: published_only?, include_utility?, limit?.) | missing_primary (Pages (published AND planned/unpublished) with no PRIMARY keyword assigned (selected=1). Returns published flag per row so you can distinguish. Filters: topic_id, published_only. args: topic_id?, published_only?.) | coverage (For a topic (required arg topic_id), matrix of keywords × pages × role. Returns assigned (primary/secondary/candidate) counts + unassigned keyword list. args: topic_id.) | intent_mix (For a topic (required arg topic_id), count of pages grouped by intent. Flags missing intents. args: topic_id.) | neighbors (Local subgraph around a node (required args: node_type = page|topic|keyword|question, node_id). Optional edge_types[] filter. Returns outbound + inbound edges, depth=1. Edge types: links_to (internal links, pages only — the same links orphans / underlinked count; capped at 100 per direction, links_total gives the full counts) and cannibalizes (a cached label — the cannibals predicate is the live answer). args: node_type, node_id, edge_types?.) | inventory (Dense CSV enumeration of topics + pages for this site (same view chat sees). Call before planning tasks, audits, or answering "what do we have on X". Filterable: args.topic_id (one topic), args.page_type (one type), args.limit (cap rows). Returns { topics_csv, pages_csv, totals }. args: topic_id?, page_type?, limit?.)

Parameters

ParameterTypeRequiredDescription
predicatecannibals, orphans, stale, underlinked, missing_primary, coverage, intent_mix, neighbors, inventoryyesWhich predicate to run.
argsobjectArguments for the predicate. See predicate description for the shape.

Input schema

{
  "type": "object",
  "properties": {
    "predicate": {
      "type": "string",
      "enum": [
        "cannibals",
        "orphans",
        "stale",
        "underlinked",
        "missing_primary",
        "coverage",
        "intent_mix",
        "neighbors",
        "inventory"
      ],
      "description": "Which predicate to run."
    },
    "args": {
      "type": "object",
      "description": "Arguments for the predicate. See predicate description for the shape."
    }
  },
  "required": [
    "predicate"
  ]
}

recall

Semantic search over the site's memory. FAST. Site-scoped, returns top-k chunks similar to query. Source types (disjoint): article (published article prose), answer (published Q&A prose), note (admin insider knowledge on a page), pain (one customer pain point), fact (one researched fact), opinion (one researched opinion). Filter source_types to narrow intent. Pass page_id to search within one page's chunks. Try BEFORE research — research is much slower.

Parameters

ParameterTypeRequiredDescription
querystringyesNatural-language query to match against chunk content.
source_typesarray of article, answer, note, pain, fact, opinionOptional filter on chunk origin. Omit for all sources.
kintegerTop-k results to return.
page_idinteger or nullOptional: narrow to one page's chunks.

Input schema

{
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "description": "Natural-language query to match against chunk content."
    },
    "source_types": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "article",
          "answer",
          "note",
          "pain",
          "fact",
          "opinion"
        ]
      },
      "description": "Optional filter on chunk origin. Omit for all sources."
    },
    "k": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50,
      "default": 8,
      "description": "Top-k results to return."
    },
    "page_id": {
      "type": [
        "integer",
        "null"
      ],
      "description": "Optional: narrow to one page's chunks."
    }
  },
  "required": [
    "query"
  ]
}

db_select

Read rows from any cm_* table, site-scoped automatically (cm_keywords and cm_research are shared pools, not per-site). Plain column names only — no nested table selections. Where ops: in, lt, lte, gt, gte, like, ilike. Order DESC defaults to NULLS LAST so high-value rows surface first. When filtering cm_keywords, add {volume: {gt: 0}} to drop unscored rows that would dominate the response.

Parameters

ParameterTypeRequiredDescription
tablestringyes
whereobject
columnsarray of string
orderobject
limitinteger
include_deletedboolean

Input schema

{
  "type": "object",
  "properties": {
    "table": {
      "type": "string"
    },
    "where": {
      "type": "object"
    },
    "columns": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "order": {
      "type": "object",
      "properties": {
        "column": {
          "type": "string"
        },
        "ascending": {
          "type": "boolean"
        }
      }
    },
    "limit": {
      "type": "integer"
    },
    "include_deleted": {
      "type": "boolean"
    }
  },
  "required": [
    "table"
  ]
}

db_insert

Insert rows into a writable cm_* table. site_id is stamped automatically — do NOT include it. HARD GATE for cm_topics + cm_pages: every row MUST include keyword_ids:[<cm_keywords.id>, ...] with at least 1 entry. Topics/pages without keyword grounding are guesses against search demand and will be rejected with missing_keyword_binding. If you don't have keyword IDs yet, call get_keywords({seeds:[...]}) FIRST to populate cm_keywords and get back the ids. For cm_pages, the first keyword_id is bound as the page primary (selected=1); the rest as secondaries. cm_pages.page_type must be one of: Pillar, Article, FAQ, Landing, Cornerstone, ResourceIndex, Glossary, RoundUp, Playbook, TemplateHub, Calculator, BenchmarkHub, Utility.

Parameters

ParameterTypeRequiredDescription
tablestringyes
rowsarray of objectyes

Input schema

{
  "type": "object",
  "properties": {
    "table": {
      "type": "string"
    },
    "rows": {
      "type": "array",
      "items": {
        "type": "object"
      }
    }
  },
  "required": [
    "table",
    "rows"
  ]
}

db_update

Update rows matching WHERE with patch. Site-scope predicate is AND-merged automatically. Empty WHERE rejected. NOTE: cm_pages.published and cm_questions.published are READ-ONLY here — flip them via add_article (the state-machine publish flow). Direct db_update would bypass block validation and the reader push. cm_pages.page_type must be one of: Pillar, Article, FAQ, Landing, Cornerstone, ResourceIndex, Glossary, RoundUp, Playbook, TemplateHub, Calculator, BenchmarkHub, Utility.

Parameters

ParameterTypeRequiredDescription
tablestringyes
whereobjectyesConditions as {col: value}, {col: [..]} or {col: {in: [..]}}. Writes take no range/like operators.
patchobjectyes

Input schema

{
  "type": "object",
  "properties": {
    "table": {
      "type": "string"
    },
    "where": {
      "type": "object",
      "description": "Conditions as {col: value}, {col: [..]} or {col: {in: [..]}}. Writes take no range/like operators."
    },
    "patch": {
      "type": "object"
    }
  },
  "required": [
    "table",
    "where",
    "patch"
  ]
}

db_delete

Delete PLANNING rows matching WHERE. cm_pages and cm_topics use SOFT delete; child tables hard-delete with snapshot. Empty WHERE rejected. ⚠️ This never removes a page from the destination site — the published article/answer lives in the receiver's database and the Publish Protocol has no delete verb. Deleting a PUBLISHED cm_pages or cm_questions row is therefore refused (delete_blocked_published): unpublish it first with set_article / set_answer status:"draft", or delete it on the site itself, which removes the page and tells Pink.

Parameters

ParameterTypeRequiredDescription
tablestringyes
whereobjectyes

Input schema

{
  "type": "object",
  "properties": {
    "table": {
      "type": "string"
    },
    "where": {
      "type": "object"
    }
  },
  "required": [
    "table",
    "where"
  ]
}

set_page

Update a cm_pages row. Re-indexes page embeddings on notes/pains/title changes (so recall stays fresh) and refreshes orphan edge labels when topic_id moves. Prefer this over db_update(cm_pages, ...) when changing topic_id — db_update skips the edge refresh. A page that has an article (live or draft) keeps its slug — its URL — and a slug change is refused (slug_locked); rename its title instead.

Parameters

ParameterTypeRequiredDescription
idintegeryes
titlestring
slugstringA page slug is lowercase letters, digits and hyphens, at most 200 characters (e.g. "how-to-brew-green-tea"); a page that maps an existing multi-level URL may join segments with "/" (no leading or trailing "/").
page_typePillar, Article, FAQ, Landing, Cornerstone, ResourceIndex, Glossary, RoundUp, Playbook, TemplateHub, Calculator, BenchmarkHub, Utility
intentarray of Informational, Commercial, Transactional, Navigational
painsarray of string
notesstring
topic_idinteger

Input schema

{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer"
    },
    "title": {
      "type": "string"
    },
    "slug": {
      "type": "string",
      "description": "A page slug is lowercase letters, digits and hyphens, at most 200 characters (e.g. \"how-to-brew-green-tea\"); a page that maps an existing multi-level URL may join segments with \"/\" (no leading or trailing \"/\")."
    },
    "page_type": {
      "type": "string",
      "enum": [
        "Pillar",
        "Article",
        "FAQ",
        "Landing",
        "Cornerstone",
        "ResourceIndex",
        "Glossary",
        "RoundUp",
        "Playbook",
        "TemplateHub",
        "Calculator",
        "BenchmarkHub",
        "Utility"
      ]
    },
    "intent": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "Informational",
          "Commercial",
          "Transactional",
          "Navigational"
        ]
      }
    },
    "pains": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "notes": {
      "type": "string"
    },
    "topic_id": {
      "type": "integer"
    }
  },
  "required": [
    "id"
  ]
}

Author internal-link creates/deletes (single or bulk). Returns a soft warning when a delete would drop an endpoint below the editorial floor (≥2 in, ≥2 out) — the delete still applies; warning is advisory. Every create must join two different live pages of this site; a self-link or a page that is deleted or on another site refuses the whole call with bad_input. Prefer this over raw db_insert/db_delete on cm_page_links — those skip the floor check. graph orphans/underlinked read links live, so they reflect any change at once.

Parameters

ParameterTypeRequiredDescription
createsarray of object
deletesarray of object

Input schema

{
  "type": "object",
  "properties": {
    "creates": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "source_id": {
            "type": "integer"
          },
          "target_id": {
            "type": "integer"
          }
        },
        "required": [
          "source_id",
          "target_id"
        ]
      }
    },
    "deletes": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "source_id": {
            "type": "integer"
          },
          "target_id": {
            "type": "integer"
          }
        },
        "required": [
          "source_id",
          "target_id"
        ]
      }
    }
  }
}

set_keyword_roles

Atomic primary/secondary/candidate role swap on cm_page_keywords. Pass primary_keyword_id to set the page primary (selected=1) — the existing primary is auto-demoted first, so the cm_page_keywords_one_primary_per_page unique index never trips. Pass secondary_keyword_ids[] to set selected=0 rows. Pass candidate_keyword_ids[] to set selected=NULL (in pool but not bound). Auto-links unbound keyword_ids if they exist in cm_keywords, rejects hallucinated IDs. Bubbles primary changes to cm_topic_keywords (source=page_primary) and refreshes cannibals edges. Use this instead of manual db_update sequences on cm_page_keywords — it removes the orphan-state risk between demote and re-bind. A page with no keywords whose topic already has some: pick its primary from that pool — db_select({table: 'cm_topic_keywords', where: {topic_id, skip: false}, limit: 1000}) lists their keyword_ids; leave out rows with source='page_primary', which are other pages' primaries — instead of pulling new keywords with get_keywords.

Parameters

ParameterTypeRequiredDescription
page_idintegeryes
primary_keyword_idinteger or nullSet the new primary. Pass null to demote the current primary without setting a new one.
secondary_keyword_idsarray of integerSet these as secondaries (selected=0). Existing roles for unlisted keywords are unchanged.
candidate_keyword_idsarray of integerSet these as candidates (selected=NULL).

Input schema

{
  "type": "object",
  "properties": {
    "page_id": {
      "type": "integer"
    },
    "primary_keyword_id": {
      "type": [
        "integer",
        "null"
      ],
      "description": "Set the new primary. Pass null to demote the current primary without setting a new one."
    },
    "secondary_keyword_ids": {
      "type": "array",
      "items": {
        "type": "integer"
      },
      "description": "Set these as secondaries (selected=0). Existing roles for unlisted keywords are unchanged."
    },
    "candidate_keyword_ids": {
      "type": "array",
      "items": {
        "type": "integer"
      },
      "description": "Set these as candidates (selected=NULL)."
    }
  },
  "required": [
    "page_id"
  ]
}

merge_topics

Merge source topics INTO target topic: re-parents pages, moves keyword attachments, soft-deletes sources.

Parameters

ParameterTypeRequiredDescription
from_topic_idsarray of integeryes
into_topic_idintegeryes

Input schema

{
  "type": "object",
  "properties": {
    "from_topic_ids": {
      "type": "array",
      "items": {
        "type": "integer"
      },
      "minItems": 1
    },
    "into_topic_id": {
      "type": "integer"
    }
  },
  "required": [
    "from_topic_ids",
    "into_topic_id"
  ]
}

On this page