--- name: clickfunnels-blogs description: > Manage ClickFunnels 2.0 blog posts programmatically - create, list, update, and delete posts; manage authors, categories, and tags. The post body is authored in PML (Page Markup Language); the DSL itself lives in the Page Markup Skill. version: "1.0" author: ClickFunnels tools: - http references: - path: https://accounts.myclickfunnels.com/llms.txt description: Quick-reference index - product overview, login, funnels, broadcasts, API docs. - path: https://accounts.myclickfunnels.com/.well-known/page-markup/skill.md description: PML reference - elements, attributes, design system, examples. Read this before authoring any `markup` field. - path: https://accounts.myclickfunnels.com/.well-known/pages/skill.md description: Companion API for funnel/site pages (same PML body, different surface). --- # ClickFunnels Blogs Skill The HTTP surface for managing CF2 blog posts and their supporting taxonomy (authors, categories, tags). Post bodies are authored in **PML (Page Markup Language)** - for the DSL itself, see the [Page Markup Skill](https://accounts.myclickfunnels.com/.well-known/page-markup/skill.md). This document covers discovery, the post lifecycle, and the gotchas worth knowing. ## When to use this skill - Publishing a new blog post (article) from a brief or prompt - Listing or reading existing posts and their PML - Updating a post's body, metadata, or publish state - Creating tags and assigning them to posts - Discovering authors and categories to attach to posts ## A note on blogs vs. blog posts Every CF2 site ships with a default blog ("Primary Blog"). **You cannot create a new blog itself via the public API** - blogs are provisioned automatically. What you can create via the API is a **blog post** (an article) inside an existing blog. This skill is almost entirely about that post lifecycle. If you need a new blog container in a workspace, do it through the admin UI ("Sites & Blogs" -> "Blogs") before calling the API. ## Authentication OAuth2 password grant using OAuth application credentials. See [Pages Skill -> Authentication](https://accounts.myclickfunnels.com/.well-known/pages/skill.md#authentication) for the full handshake - the same access token works for the blogs surface. ## Discovering your blog Every blog post is nested under a blog; you need a `blog_id` to create posts. List blogs in a workspace: ``` GET /api/v2/workspaces/:workspace_id/blogs Authorization: Bearer ``` Returns the array of blogs in the workspace. Most workspaces have exactly one (the "Primary Blog" provisioned with the site). Capture `id` and use it as `:blog_id` in every endpoint below. Show a single blog: ``` GET /api/v2/blogs/:id ``` ### Update blog metadata ``` PATCH /api/v2/blogs/:id Authorization: Bearer Content-Type: application/json { "blog": { "name": "Engineering Notes", "seo_title": "Engineering Notes - ClickFunnels", "seo_description": "What we're building, what we're learning.", "seo_image_id": 42, "theme_id": 5 } } ``` Returns **200 OK** with the updated blog. Permitted fields: `name`, `seo_title`, `seo_description`, `seo_image_id`, `theme_id`. ### Configure blog appearance with existing Theme and Page APIs There is no separate blog-appearance resource. A Blog selects a Theme with `theme_id`, and that Theme supplies the Pages used for the blog index, posts, and categories. `GET /api/v2/blogs/:id` returns the effective Page IDs directly: - `resolved_template_page_id` - blog index - `resolved_post_template_page_id` - individual posts - `resolved_category_template_page_id` - category archives List the workspace's Themes and choose one whose `template_pages` response has a non-null `blogs_post_template_page`. Then set that Blog Theme and read or update its effective Pages with the Pages API. If the Blog already resolves the Pages you want, skip the Theme change and update those resolved Pages directly. ```http GET /api/v2/workspaces/:workspace_id/themes PATCH /api/v2/blogs/:id Content-Type: application/json {"blog":{"theme_id":5}} GET /api/v2/blogs/:id GET /api/v2/pages/:resolved_post_template_page_id?expand[]=custom_css PATCH /api/v2/pages/:resolved_post_template_page_id Content-Type: application/json { "page": { "custom_css": "article { max-width: 72ch; margin-inline: auto; }", "custom_css_mode": "append" } } ``` The Blog model already rejects Themes outside the workspace and Themes without Blog templates. A resolved template Page can be shared by more than one Blog or Theme, so confirm its consumers before changing its CSS. Read the current CSS first. Use `replace` only when sending the complete replacement stylesheet; use `append` for a new scoped rule. ## Supporting taxonomy These return ids you can pass to `author_ids`, `category_ids`, and `tag_ids` when creating or updating posts. ### Authors (read-only) ``` GET /api/v2/blogs/:blog_id/authors # list GET /api/v2/blogs/authors/:id # show ``` Authors are managed through the admin UI; this surface is read-only. ### Categories (read-only) ``` GET /api/v2/blogs/:blog_id/categories # list GET /api/v2/blogs/categories/:id # show ``` Categories are managed through the admin UI; this surface is read-only. ### Tags (list / show / create) ``` GET /api/v2/blogs/:blog_id/tags # list GET /api/v2/blogs/tags/:id # show POST /api/v2/blogs/:blog_id/tags # create ``` Create a tag: ```http POST /api/v2/blogs/:blog_id/tags Authorization: Bearer Content-Type: application/json { "blogs_tag": { "name": "engineering" } } ``` Returns **201 Created** with the new tag (capture its `id` for `tag_ids` on posts). ## Creating a blog post This is the core operation. The post body lives in the `markup` field as PML. ```http POST /api/v2/blogs/:blog_id/posts Authorization: Bearer Content-Type: application/json { "blogs_post": { "title": "Becoming an Agentic Engineer in 2026", "visibility": "public", "published_at": "2026-05-12T14:00:00Z", "excerpt_content": "Notes from a year of letting agents take the keyboard.", "seo_title": "Becoming an Agentic Engineer in 2026: A Field Guide", "seo_description": "A field guide to the skills that compound in 2026.", "image_id": 17, "author_ids": [3], "category_ids": [1], "tag_ids": [5, 8], "markup": "
Hello
" } } ``` On success: **201 Created** with the post resource. Pass `?expand[]=markup` to also receive the round-tripped PML in the response. On invalid markup: **400 Bad Request** with the PML parser error - no post is created. ### Permitted fields | Field | Type | Notes | |-------|------|-------| | `title` | string | The post title (required for usable URLs - slug is derived from it). | | `visibility` | enum | `"draft"` or `"public"` - see the gotcha below. Default `"draft"`. | | `published_at` | ISO8601 | When the post becomes visible publicly. See the gotcha below. | | `excerpt_content` | string | Short summary used in indexes and previews. | | `seo_title` | string | `` tag override. Defaults to `title` if omitted. | | `seo_description` | string | Meta description. | | `image_id` | id | Featured image - id of a workspace Image resource. | | `author_ids` | array of ids | Authors attached to the post (see Authors). | | `category_ids` | array of ids | Categories (see Categories). | | `tag_ids` | array of ids | Tags (see Tags). | | `markup` | PML string | Post body. See [Page Markup Skill](https://accounts.myclickfunnels.com/.well-known/page-markup/skill.md). | | `post_content` | Markdown | **Deprecated.** Older Markdown-based authoring path kept for back-compat with the live MCP `create_blog_post` tool. New consumers should use `markup`. When both are passed, `markup` wins. | ### Rehost source images during a migration The Images API already accepts a remote source URL. Rehost each source image before creating the post, then use the returned ClickFunnels URL in the body: ```http POST /api/v2/workspaces/:workspace_id/images Content-Type: application/json { "image": { "upload_source_url": "https://source.example/articles/diagram.png", "name": "Architecture diagram", "alt_text": "Services connected through an event bus" } } ``` The response contains both `id` and `url`. Use `id` as the post's `image_id` only when this is the featured image. For an image in the article body, replace the source URL with the returned `url`, for example `<image src="https://images.example/hosted.png" alt="Architecture diagram"/>`. Keep a source-URL-to-hosted-URL map so repeated images are uploaded once, and do not create the post until all required uploads succeed. ! **FORBIDDEN - never encode a nested path into a post's title or slug.** A blog post's slug is derived **automatically** from its `title`, and its full public URL is composed by ClickFunnels from the blog/post hierarchy. Never prefix the blog's path (or any other parent path) onto the post - the post's slug must stand on its own, and you must **never** guess or build the composed URL yourself. e.g. for a blog served at path `recipes`, a post titled "Catering" must **not** be given a title/slug like `recipes/catering`; keep it as its own standalone slug (`catering`) and the platform composes the full nested URL for you. ### Two gotchas worth knowing **1. `visibility: "public"`, not `"published"`.** The model uses `"public"` as the canonical value for a visible post (`Blogs::Post::PUBLIC_STATUS = "public"`). `"published"` is the name of an ActiveRecord scope, not a column value - sending it as a string is accepted by the API but produces a half-published row whose admin index shows a grey badge with no date. Always use `"public"`. **2. `published_at` is `before_create` only.** The model auto-stamps `published_at = Time.zone.now` on **create** when `visibility == "public"`. On **update** the callback does not fire, so flipping a draft to published via `PATCH` requires sending `published_at` explicitly: ```http PATCH /api/v2/blogs/posts/:id Content-Type: application/json { "blogs_post": { "visibility": "public", "published_at": "2026-05-12T14:34:40Z" } } ``` ## Reading a blog post ``` GET /api/v2/blogs/posts/:id GET /api/v2/blogs/posts/:id?expand[]=markup ``` `markup` is opt-in to keep show/list responses small. Without `expand[]=markup`, the response excludes the field entirely. ### Listing posts for a blog ``` GET /api/v2/blogs/:blog_id/posts GET /api/v2/blogs/:blog_id/posts?expand[]=markup ``` Returns the array of posts in the blog. Pagination, filtering, and sorting follow the standard V2 conventions. ## Updating a blog post **STOP before you send `markup` here.** A `markup` write replaces the post's entire body, published version and editor draft alike, and PML covers only a subset of what the ClickFunnels editor can build. If this post could have been written or edited in the editor, ask the person for explicit approval first and tell them editor-only content will not survive. "Update the post" is not that approval. The full rule, including the cases where you do not need to ask, is in [STOP: get approval before overwriting an existing page](https://accounts.myclickfunnels.com/.well-known/page-markup/skill.md#stop-get-approval-before-overwriting-an-existing-page). Updating only metadata (title, SEO fields, taxonomy, visibility) is not affected: leave `markup` out of the payload and the body is untouched. ```http PUT /api/v2/blogs/posts/:id Authorization: Bearer <access_token> Content-Type: application/json { "blogs_post": { "title": "Updated title", "markup": "<section><row><column><headline>Updated body</headline></column></row></section>" } } ``` Returns **200 OK**. All permitted fields above are accepted on update. Remember the `published_at` gotcha above - explicit value required to publish a draft via update. ## Deleting a blog post ``` DELETE /api/v2/blogs/posts/:id Authorization: Bearer <access_token> ``` Returns **204 No Content** on success. ## Common Errors | Error | Cause | Fix | |-------|-------|-----| | 400 "Markup is not valid PML: ..." | Invalid DSL | See [Page Markup Skill -> Error Reference](https://accounts.myclickfunnels.com/.well-known/page-markup/skill.md#18-error-reference) | | 401 "API key missing or invalid" | Missing, revoked, or wrong token | Re-authenticate | | 404 "Not found" | Blog or post does not exist, or token does not have access | Check ids and authorization | | 422 Unprocessable Entity | Validation failure on permitted attributes | Read the `error` body - usually a missing title or invalid id reference |