PostKing Landing Pages
Handles the landing-page lifecycle on PostKing: AI generation, targeted or full AI ("vibe") edits, side pages under a parent page, custom domains, and publishing. Requires an active brand — use the postking skill's brand-pick flow first if one isn't set.
When to Use
Use this skill when the user wants to generate a new landing page, edit or AI-rewrite one, add or edit a side page (a comparison, text, or landing-style sub-page under a parent LP), attach a custom domain, or publish/preview a page.
When NOT to Use
- Social posts or content weeks →
postking-social. - Blog articles →
postking-blog. - The SEO/GEO roadmap pipeline that decides what pages to brief (seeds → keywords → clusters → roadmap items) →
postking-seo. This skill covers generating and editing the pages themselves, including side pages fed by a roadmap brief. - Saved voice profile management or a standalone de-slop pass →
postking-brand-voice. - No active brand yet →
postking-getting-started.
Minimal tool subset
list_landing_pages,generate_landing_page,view_landing_page,get_job— create and inspect (get_jobis for the asyncgenerate_landing_page/regenerate_landing_pageops only — vibe-edit sessions are polled withget_vibe_edit_status, notget_job).edit_landing_page,set_landing_page_section,set_lp_section_layout,regenerate_landing_page,vibe_edit_landing_page,get_vibe_edit_status,apply_vibe_edit,restore_lp_version,delete_lp_version— editing.list_lp_versions,view_lp_version,view_lp_draft— landing-page version-history reads (restore_lp_version/delete_lp_versionabove are the writes) — see "Version history".list_side_pages,generate_side_page,import_side_page_html,view_side_page,edit_side_page,set_side_page_section,set_side_page_state— side pages.list_block_types,add_block,edit_block,delete_block,reorder_blocks— block-model editing forsidePageType: "custom"side pages — see "Custom side pages (block model)".list_side_page_versions,view_side_page_version,restore_side_page_version,delete_side_page_version— side-page version history — see "Version history".get_landing_page_translations,create_landing_page_translation— landing-page language variants — see "Landing page translations".list_asset_slots,assign_asset_to_slot— discover a page's asset slots and assign/reassign/clear the asset in a slot (works for main LPs and side pages — see "Assign or reassign an asset to a slot").add_domain,verify_domain,list_domains,delete_domain— custom domains (partial support — see Pitfalls).publish_landing_page,delete_landing_page— publish/lifecycle.
Procedure
Generate and publish a landing page
generate_landing_page({ brandId, topic, slug?, language? })— async; creates the LP record and kicks off AI content generation.language(en/es/pt-BR/de/fr/cs) overrides the brand'scontentLanguagefor this page only. Returns{ slug, operationId, pollUrl }. Poll withget_job({ pollUrl: operationId, brandId, wait: true })untilstateiscompleted(orfailed/partially_failed/cancelled).view_landing_page({ slug })— preview the generated content.publish_landing_page({ slug })— free-tier choke point. Surface the returnedwebUrlto the user.
Edit a landing page
- Targeted metadata/instructions patch:
edit_landing_page({ slug, title?, instructions? }). - Direct field/section write (no AI):
set_landing_page_section({ slug, section, field?, value, replaceSection? })— each call creates a new draft version. For many related changes across a page, prefer the vibe-edit flow below.sectionis one of the 12 built-ins (hero,showcase,videos,howItWorks,features,categoryExplorer,replacesStack,comparisonMatrix,cta,faq,pricing,roiCalculator— the same listset_lp_section_layoutorders) or a top-level root (slotMap,config,navigation,siteMetadata). Freeform HTML sections are a separate address:section: "customHtml"withfield: "<id>"andvalue: { name?, html }(orreplaceSection: trueto replace the wholeid -> {name?, html}map) — the HTML is sanitized server-side and rejected (not stripped) if it breaks theblk-*-class/no-script rules. - Reorder or toggle visibility of sections:
set_lp_section_layout({ slug, sectionOrder?, sectionVisibility? })—sectionOrdertakes the 12 built-in keys above plus anycustomHtml:<id>freeform sections in the page. - Full AI regeneration pass, optionally scoped to specific sections:
regenerate_landing_page({ slug, voiceProfileId?, instructions?, sections? }). - Natural-language AI edit ("vibe edit") — this is a propose → review → apply flow, nothing changes until you apply it:
vibe_edit_landing_page({ slug, instructions, scope?, sectionId?, language? })—scopeis"full" | "section"(sectionIdrequired whenscope: "section"; example section keys:hero,features,pricing,cta,faq,howItWorks,showcase,categoryExplorer,replacesStack,comparisonMatrix,roiCalculator).scope: "section"is hard-enforced: any patch keys outsidesectionIdare silently dropped, not applied elsewhere. Usescope: "full"for any edit that spans more than one section.languageregenerates the edited content in that language for this edit only, overriding the brand's configured content language. Returns{ operationId }.- Poll
get_vibe_edit_status({ slug, operationId })— notget_job, which cannot find vibe-edit sessions — untilstatusiscompleted(orfailed). The defaultdetail: "medium"is the review view:{ status, stale, changes: [{ index, path, before, after }] }. A structurally impossible request (e.g. asking for a 6th pricing plan when the schema caps at 5) fails fast with a clear error on the status response instead of looping/repairing — read the error and adjust the instructions rather than retrying the same payload. - Review the
changesarray, thenapply_vibe_edit({ slug, operationId, all? | indices? | paths? })to apply the accepted changes to the draft as one new version. A stale session (draft moved on since the edit was proposed) returns astale_sessionerror — re-runvibe_edit_landing_pageor retry withforce: true. publish_landing_page({ slug })to make it live.restore_lp_version({ slug, versionId })rolls the draft back if needed.
Pricing section (pricing.plans)
- 1–5 plans max, no more. A request for a 6th plan is structurally impossible and fails fast (see step 2 above).
- Plan objects are strict — unknown keys are rejected. Required:
id,name,price(number ≥0),period,description,features(array of{ text, included }),cta. Optional:isHighlighted,nofollow,contactSales,isComingSoon,outputs[],meta,metaItems[],valueProp,annualPrice,billingNote,ctaUrl,url,annualUrl,urls{monthly,annual}. - Put a plan's checkout/signup link in
urlorctaUrl(orurls.monthly/urls.annualfor cadence-specific links) — don't invent a new key for it; it'll be rejected.
Assign or reassign an asset to a slot
Images and videos on a page are not stored inside section text — they live in a slotMap keyed by dotted slot keys (e.g. hero.backgroundVideo, cta.image, hero.logoCarousel, showcase.video.1, pricing.illustration). Each slot holds one asset ID, or an array of IDs for "array" slots. A main LP's slots are namespaced under a pageKey (main by default); a side page keeps its own separate slotMap.
- Make sure the asset is in the brand library first —
list_assets,upload_asset, orimport_asset_from_url.assign_asset_to_slotonly references existing assets; it does not upload. - Discover slot keys and their current assignments:
list_asset_slots({ slug, sidePageSlug?, pageKey? })— passsidePageSlugfor a side page. Returns each slot with its media type and current asset. - Assign, reassign, or clear the slot:
assign_asset_to_slot({ slug, slotKey, assetId? | assetIds?, sidePageSlug?, pageKey?, clear? }). Synchronous — applies immediately, no polling.
- Main landing page:
assign_asset_to_slot({ slug, slotKey: "hero.backgroundVideo", assetId }). - Side page:
assign_asset_to_slot({ slug, sidePageSlug, slotKey: "cta.image", assetId }). - Clear a slot:
assign_asset_to_slot({ slug, slotKey: "hero.logoCarousel", clear: true }).
Rules:
- Array slots require
assetIds(plural); single slots requireassetId. Using the wrong one is rejected. - The asset must belong to the brand and match the slot's media type (IMAGE vs VIDEO), or the call is rejected.
- A main-LP assignment creates a new draft version —
publish_landing_page({ slug })to make it live. Side-pageslotMapwrites apply in place with no version of their own — unlikeset_side_page_section/edit_side_page/the block tools, which do create one (see "Version history"). - A side page carrying its own
slotMapshadows the parent LP's slots for that page.
Side pages
list_side_pages({ slug })— see what already exists under a parent landing page. Each entry includespreviewUrl(browser-openable draft preview, present whenever the server has a preview domain configured) andliveUrl(only when a custom domain is connected) — hand these to the user instead of constructing URLs yourself.generate_side_page({ slug, key, prompt?, brief?, keywords?, sidePageType?, name? })—keyis the required URL-slug fragment for the side page.sidePageTypeis"landing" | "text" | "comparison" | "custom" | "spotlight"(defaults to"landing") — there is notypeparam, and"comparison"requires a persistedbriefId."spotlight"is the feature / service spotlight page: a fixed-schema single-topic page (hero, a 3-6 step feature tour, 3-6 benefits, a stat + quote proof band, CTA footer) with no testimonial carousel, pricing table or showcase — use it whenever the page covers ONE feature or service, instead of"landing", which pulls in the full marketing template. Spotlight is freeform-only: passkey+prompt(+ optionalnamefor the display title,keywords);briefandselectedSectionsare ignored for it. Async for most cases (returnsoperationId); some comparison briefs run synchronously and returnsidePageIddirectly with nooperationId. Pollget_jobwhen anoperationIdis returned; the completed result includespreviewUrl/liveUrlas above.import_side_page_html({ slug, url? | html?, sidePageSlug?, voiceProfileId?, autoAssignAssets? })— clone an existing web page as a side page under the parent LPslug. Passurl(PostKing fetches the page server-side — don't download the HTML yourself; an invalid or SSRF-blocked URL 400s) orhtmldirectly, not both.sidePageSlugis optional withurl(derived from the URL path), required withhtml. Behavior follows the parent LP's type: raw-HTML parent → synchronous verbatim import, returns{ sidePage: { id, slug }, mode: "import", previewUrl?, liveUrl? }; sectioned parent → the source page's text is extracted and seeds an async side-page generation instead, returns{ mode: "generate", operationId, key }— poll withget_job, same asgenerate_side_page. 409 = a side page with that slug already exists. Contrast withimport_landing_page_html, which creates a standalone top-level landing page, not one nested under a parent.view_side_page({ slug, sideKey })— inspect content and rendered HTML; also returnspreviewUrl/liveUrl.edit_side_page({ slug, sideKey, name?, newKey?, instructions?, updateReferences? })—namesets the display title, which is what shows up in the parent landing page's auto-generated footer/nav "Solutions" links and in breadcrumbs; changing it updates those automatically.newKeyrenames the side page's URL-slug fragment — the old URL 404s afterward (no redirect), and existing internal references (blog backlinks, internal links) are rewritten in the background; poll the returnedslugRewriteOperationIdfor that rewrite.updateReferencesdefaults totrueand can be setfalseto skip that reference-rewrite cascade when renaming vianewKey.instructionsstill only records a page-level annotation and does not itself trigger an AI edit. Useset_side_page_section({ slug, sideKey, sectionId, fields?, field?, value?, instructions? })for a single structured section edit instead.set_side_page_state({ slug, sideKey, published })— publish or unpublish it.
Custom side pages (block model)
A sidePageType: "custom" side page (see generate_side_page above) stores content as an ordered blocks[] array instead of the fixed section list "landing"/"spotlight" pages use — reach for it when the page needs a shape the fixed sections can't express. Every block tool addresses a specific side page with both the parent LP slug and the side page's sideKey (from list_side_pages) — there is no way to add blocks to a top-level landing page directly, only to a "custom"-type side page nested under one.
list_block_types({ brandId?, detail? })— the entire runtime discovery mechanism for whatadd_blockaccepts: Tier 1 (the 12 built-in Uland sections —hero,showcase,videos,howItWorks,features,categoryExplorer,replacesStack,comparisonMatrix,cta,faq,pricing,roiCalculator) merged with Tier 2 (brand-visibleBlockTyperows seeded in the DB).detail: "full"returns each type's completejsonSchema(andtemplatefor Tier 2) — call it before everyadd_block/edit_blockto see exactly what shapepropsmust have, rather than assuming a fixed catalog; a new Tier 2 type is usable immediately with no server rebuild. Tier 3 (type: "html") is always available and is not listed here.add_block({ slug, sideKey, type, props, position? })—typeis a key fromlist_block_typesor the literal"html".propsmust match that type's schema exactly (a mismatch is rejected, not coerced, and the error names the offending field); for"html"it's{ html: "<section class=\"blk-section\">...</section>" }, sanitized server-side against ablk-*class/tag allowlist (no<script>/<style>/<img>/inline standard CSS) and REJECTED with a reason on violation, never silently stripped.positionis a 0-based insert index into the current array; omit to append. The server generates and returns the new block's id (blk_xxxxxxxx).edit_block({ slug, sideKey, blockId, type?, props? })—propsreplaces the block's ENTIRE props object, not a shallow merge; fetch current props first viaview_side_page({ slug, sideKey, detail: "full" })(underoverrides.blocks) if you're only changing one field, merge locally, then send the full object back. Passingtypealso revalidatespropsagainst the new type's schema — pass matchingpropsin the same call.delete_block({ slug, sideKey, blockId, confirm: true })— removes one block; recoverable vialist_side_page_versions/restore_side_page_versioneven after deletion, since version history is append-only.reorder_blocks({ slug, sideKey, blockIds })—blockIdsmust be the full, exact set of the page's current block ids in the new order; it cannot add or remove blocks (useadd_block/delete_blockfor that), and a set mismatch is rejected.
All four writes create a new draft version (draft-only until set_side_page_state({ published: true })) and are fenced by optimistic concurrency: a 409 means another write landed between your last read and this call — call view_side_page again to re-fetch current blocks and retry against the fresh state, don't blindly resend the same call.
Version history
Every content write to a landing page (set_landing_page_section, apply_vibe_edit, regenerate_landing_page, a main-LP assign_asset_to_slot) or to a side page (set_side_page_section, edit_side_page, the block tools above) creates a new draft version — publishing is always a separate step (publish_landing_page / set_side_page_state({ published: true })). The one exception is assign_asset_to_slot targeting a side page's slotMap, which writes in place with no version of its own.
- Landing page:
list_lp_versions({ slug })— history, each entry with apreviewUrl.view_lp_version({ slug, versionId })— inspect one version's section content.view_lp_draft({ slug })— the current unpublished draft.restore_lp_version({ slug, versionId })— roll the draft back to a prior version (does not touch the published version).delete_lp_version({ slug, versionId, confirm: true })— permanently remove a historical version; the published version and the last remaining version can't be deleted. - Side page:
list_side_page_versions({ slug, sideKey })— history; the response's top-levelcurrentVersionId(draft) andpublishedVersionId(live) tell you draft vs. live without a second call.view_side_page_version({ slug, sideKey, versionId })— inspect a snapshot ({ type, overrides, siteMetadata, slotMap, config }).restore_side_page_version({ slug, sideKey, versionId })— moves the draft forward to a new version copied from the target (forward history is never deleted); the live page is unaffected until youset_side_page_state({ published: true })again.delete_side_page_version({ slug, sideKey, versionId, confirm: true })— deleting the current draft version is allowed and repoints the draft to the newest remaining version; the published version and the last remaining version can't be deleted.
Landing page translations
A landing page can have language variants linked into a shared translation group — one page row per language, distinct from the brand's default contentLanguage. The target language must already be enabled for the brand (get_brand_languages/add_brand_language, in the postking skill) or create_landing_page_translation is rejected with a language-not-enabled error.
get_landing_page_translations({ slug })— read the translation groupslugbelongs to:{ slug, languageCode, pathPrefix, translationGroupId, isTranslationSource, variants: [...] }.variantslists the OTHER language variants already in the group (empty if none yet). Call this beforecreate_landing_page_translationto check whether a variant in the target language already exists.create_landing_page_translation({ slug, language })—slugmust be the group's actual source page: fails with 400 ifslugis itself already a translation (chain new variants off the source, never off another variant), and 409 if a variant in that language already exists in the group. Creates a new DRAFT page row (correct slug suffix and/{languageCode}path prefix) and kicks off an async job that pulls and translates the source page's current content; the response'soperationIdis pollable withget_job, andpullFromSourceUrlis a documented manual fallback (an AI-edit pull-from-source call) if you need to re-trigger the pull yourself.
Custom domain (partial support)
add_domain({ domain, primaryContentType: "landing_page", brandId? })— registers the domain and returns a DNS TXT record to add at the registrar.verify_domain({ domainId })— checks DNS once the record is added;domainIdcomes from theadd_domainresponse orlist_domains.- There is currently no MCP or CLI tool to connect a verified domain to a specific landing page.
connect_domain_to_publicationonly attaches a domain to a blog publication (it takes apublicationId, not a landing-page slug) — using it for a landing page is a no-op/wrong target, not a substitute. Tell the user to finish the connection from the landing page's own settings in the PostKing dashboard. delete_domain({ domainId })— removes a custom domain; connected blogs and landing pages are unlinked but not deleted.
CLI fast path
| Goal | Command |
|---|---|
| List / generate | pking lp list / pking lp generate --topic "..." [--slug <slug>] [--voice <id>] |
| View / preview | pking lp view <slug> |
| Edit (metadata or AI pass) | pking lp edit <slug> [--title "..."] [--instructions "..."] |
| Vibe edit | pking lp vibe <slug> --instructions "..." [--scope full|section --section-id <id>] --wait then pking lp vibe status <slug> <operationId> |
| Side pages | pking lp side list <slug> / pking lp side edit <slug> <sideKey> --instructions "..." — generation is currently MCP-only, see Pitfalls |
| Side page section | `pking lp side section --id [--content "..." |
| Publish / unpublish a side page | pking lp side state <slug> <sideKey> --publish (or --unpublish) |
| Custom domain (register + verify only) | pking domains add <domain> → pking domains verify <domain> |
| Publish the landing page | pking lp publish <slug> |
For the full command catalog, use the postking skill's references/commands.md, or run pking lp --help.
Pitfalls
generate_side_pagehas notypeparam — it'ssidePageType, and the only valid values are"landing","text","comparison","custom","spotlight". Don't invent other values (e.g. "pricing", "features", "legal") assidePageType— those aren't recognized side-page types by this tool. A feature page is"spotlight", not a made-up"features"value.keyis required ongenerate_side_page— it's the side page's URL-slug fragment, not optional metadata.pking lp side generate <slug> --type <type>is currently broken — the inner route ignores the<slug>path param and requiresname/slug/landingPageIdin the request body, so it always 400s. Use the MCP toolgenerate_side_page({ slug, key, sidePageType })instead.- Custom domains for landing pages stop short of dashboard-only. Registering (
add_domain) and verifying (verify_domain) work via MCP/CLI, but attaching the verified domain to a specific landing page does not — don't tell the userconnect_domain_to_publicationwill do it; that tool is blog-only. pking domains connect <id> --target lp:<slug>is not a working command — the CLI accepts the flag but the endpoint it calls does not exist on the server; don't suggest it.- Async operations can take 30s–5min. Poll
get_job(or the CLI's built-in--wait) rather than assuming failure early. - Comparison-type side pages may return synchronously with
sidePageIdand nooperationId— don't pollget_jobwith an ID you don't have; check the immediate response first. - Side-page content writes DO have version history and a draft/publish split, the same shape as the main LP:
set_side_page_section,edit_side_page, and the block tools (add_block/edit_block/delete_block/reorder_blocks) each create a new draft version, andset_side_page_state({ published: true })is the side-page equivalent ofpublish_landing_page— see "Version history". The one exception isassign_asset_to_slottargeting a side page'sslotMap, which still writes in place with no version of its own — double-check slot assignments before writing. - Renaming a side page's title or key is MCP-only (
edit_side_page({ name, newKey })) — there is nopkingCLI flag for it yet;pking lp side editonly supports--instructions. - A missing
previewUrlorliveUrlis not an error. NoliveUrljust means no custom domain is connected yet; nopreviewUrlmeans the server has no preview domain configured. Don't retry or report failure — just skip handing that link over. newKeychanges the live URL with no redirect — the old URL 404s immediately. Warn the user before renaming a published side page, and mention that internal references are fixed up asynchronously (pollslugRewriteOperationId), not instantly.
Verification
get_credits({ detail: "short" })— confirms auth and balance.list_landing_pages({})— returns the brand's landing pages (or an empty list if none created yet, which is expected pre-setup).