For whoever builds your blog API

Publish into your own site

Four endpoints, drafts only, about an afternoon. Paste the prompt into your AI, or use a webhook.

AuthAuthorization: Bearer <key>Posts arrive as drafts. Nothing is published until a person does it.
GET/api/v1/postsList posts. Filterable by slug and status.
POST/api/v1/postsCreate a post. Returns 201 and the object.
GET/api/v1/posts/{id}Retrieve one post.
POST/api/v1/posts/{id}Update a post. Only sent fields change.
POST/api/v1/imagesAccept an image, return a URL. Optional.

What you are building

A small REST API over your posts table. blogwriter uses it to find a post, create it, or update it.

Posts arrive as drafts. Nothing goes live until a person publishes it.


Authentication

A long-lived secret, sent as a bearer token:

Authorization: Bearer sk_live_a1b2c3d4e5f6
Content-Type: application/json

Missing or wrong key: 401. Never accept the key in a query string.

Do not redirect your API paths. A redirect between example.com and www.example.com drops the Authorization header, so every write fails. Serve /api/v1/* at the canonical host.


The post object

snake_case fields. Return every field you store, even when it is null.

{
  "id": "post_9f2ab41c",
  "object": "post",
  "created": 1788267336,
  "updated": 1788267336,

  "title": "Gym Schedule and Timetable Templates",
  "slug": "gym-schedule-timetable",
  "status": "draft",

  "content": "Someone types \"gym schedule template\" into...",
  "content_format": "markdown",
  "excerpt": "How to build a class timetable that members turn up to.",

  "cover_image_url": "https://cdn.example.com/hero.png",
  "tags": ["gym", "scheduling"],
  "author": "The Editorial Team",
  "published_at": null,

  "canonical_url": null,
  "meta_title": null,
  "meta_description": null,
  "url": "https://example.com/blog/gym-schedule-timetable"
}
FieldTypeNotes
idstringrequiredStable and opaque. Any format; never reused.
objectstringrequiredAlways "post".
titlestringrequiredPlain text, no markup.
slugstringrequiredUnique. blogwriter matches posts on it.
statusenumrequired"draft" or "published". Nothing else.
contentstringrequiredThe body, in the format you declare below.
content_formatenumrequired"markdown" or "html": whichever you store.
createdintegerrequiredUnix seconds.
updatedintegeroptionalUnix seconds.
excerptstringoptionalSummary. Doubles as the meta description.
cover_image_urlstringoptionalAbsolute URL. See Images.
tagsstring[]optionalPlain strings.
authorstringoptionalA display name.
published_atstringoptionalISO 8601, or null while it is a draft.
urlstringoptionalWhere the post will live. Saves the writer constructing it.

Two status words only: draft and published.


Endpoints

Updates use POST, not PATCH.

Path
GET/api/v1/postsList posts. Filterable by slug and status.
POST/api/v1/postsCreate a post. Returns 201 and the object.
GET/api/v1/posts/{id}Retrieve one post.
POST/api/v1/posts/{id}Update a post. Only sent fields change.
POST/api/v1/imagesOptional. Accept an image, return a URL.

List posts

GET /api/v1/posts?slug=gym-schedule-timetable

{
  "object": "list",
  "data": [ { /* post objects */ } ],
  "first_id": "post_9f2ab41c",
  "last_id": "post_9f2ab41c",
  "has_more": false
}
ParameterTypeNotes
slugstringrequiredExact match. Returns zero rows or one.
statusenumoptionalFilter to draft or published.
limitintegeroptional1–100, default 20.
afterstringoptionalA post id. Returns the page after it.

Honour the filter, and include drafts. If ?slug= is ignored, or drafts are hidden from the key that wrote them, every push creates a duplicate.

Create a post

POST /api/v1/posts   →   201 Created

{
  "title": "Gym Schedule and Timetable Templates",
  "slug": "gym-schedule-timetable",
  "status": "draft",
  "content": "Someone types...",
  "excerpt": "How to build a class timetable that members turn up to.",
  "cover_image_url": "https://cdn.example.com/hero.png",
  "tags": ["gym"]
}

No status means draft. A slug that is taken returns 409 with code: "slug_taken".

Update a post

POST /api/v1/posts/post_9f2ab41c   →   200 OK

{
  "content": "A corrected opening paragraph...",
  "cover_image_url": "https://cdn.example.com/hero-v2.png"
}

Only the fields you send change.

Never change status unless it is sent. A live post reset to draft starts answering 404. The same goes for published_at and the slug.


Rendering embeds in the body

content is Markdown with some HTML blocks for videos, social posts and live charts:

<figure class="bw-embed bw-embed-tv-chart" style="margin:2rem 0">
  <iframe src="https://s.tradingview.com/widgetembed/?symbol=BYBIT%3AHYPEUSDT&interval=D" title="BYBIT:HYPEUSDT live chart"
          loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;aspect-ratio:16/10"></iframe>
  <figcaption style="font-size:.875rem">Live chart · <a href="https://www.tradingview.com/symbols/BYBIT-HYPEUSDT/">BYBIT:HYPEUSDT on TradingView</a></figcaption>
</figure>

Render them, do not strip them. Allow these, only inside <figure class="bw-embed …">:

ElementAttributes
figureclass, style
iframesrc, title, loading, referrerpolicy, sandbox, allow, allowfullscreen, style, width, height
figcaption, blockquote, p, cite, strong, spanstyle
ahref, rel, target, style

Iframes must be https:// and come from these hosts:

Kind (bw-embed-…)Frame host
youtubewww.youtube-nocookie.com, www.youtube.com
xplatform.twitter.com
tv-chart, tv-prices.tradingview.com
redditwww.redditmedia.com
instagramwww.instagram.com
websiteany https host, framed with sandbox when you add one

bw-embed-link is a plain link card. Keep the style attributes: they size the embed. If the site sends a Content-Security-Policy, its frame-src must list these hosts.


Errors

Show

The OpenAI error shape, with a matching HTTP status. Never a 200 with an error inside.

409 Conflict

{
  "error": {
    "message": "A post with slug 'gym-schedule-timetable' already exists.",
    "type": "invalid_request_error",
    "param": "slug",
    "code": "slug_taken"
  }
}
StatusWhen
400Bad body or missing field. Name it in param.
401Missing or invalid key.
404No post with that id.
409Slug already taken.
413Body too large. Say the limit.
422Valid but rejected, e.g. an unknown author.
429Rate limited. Send Retry-After.

Say what was wrong and what would be accepted.


Images

Show

Doing nothing works: images arrive as absolute public URLs. Store the URL.

Want them on your own storage? Add the optional upload endpoint:

POST /api/v1/images   (multipart/form-data, one "file" part)

{
  "object": "image",
  "id": "img_4c1f88",
  "url": "https://cdn.example.com/blog/4c1f88.png",
  "bytes": 148213
}

Accept PNG, JPEG, WebP and SVG. Diagrams are SVG.


Reference implementation

Show

Next.js route handlers. Same shape in Express, Laravel, Rails or Django.

// app/api/v1/posts/route.ts

// Errors in one place, so every path answers the same shape.
const fail = (status: number, message: string, code: string, param: string | null = null) =>
  Response.json({ error: { message, type: "invalid_request_error", param, code } }, { status });

const authed = (req: Request) =>
  req.headers.get("authorization") === `Bearer ${process.env.BLOG_API_KEY}`;

const shape = (row: any) => ({
  id: row.id, object: "post",
  created: Math.floor(+new Date(row.created_at) / 1000),
  updated: Math.floor(+new Date(row.updated_at) / 1000),
  title: row.title, slug: row.slug, status: row.status,
  content: row.content, content_format: "markdown",
  excerpt: row.excerpt, cover_image_url: row.cover_image_url,
  tags: row.tags ?? [], author: row.author, published_at: row.published_at,
  url: `https://example.com/blog/${row.slug}`
});

export async function GET(req: Request) {
  if (!authed(req)) return fail(401, "Missing or invalid API key.", "invalid_api_key");
  const { searchParams } = new URL(req.url);

  // The slug filter has to actually filter, and drafts stay visible to this key.
  const rows = await db.posts.findMany({
    where: {
      ...(searchParams.get("slug") ? { slug: searchParams.get("slug")! } : {}),
      ...(searchParams.get("status") ? { status: searchParams.get("status")! } : {})
    },
    take: Math.min(Number(searchParams.get("limit") ?? 20), 100),
    orderBy: { created_at: "desc" }
  });

  const data = rows.map(shape);
  return Response.json({
    object: "list", data,
    first_id: data[0]?.id ?? null,
    last_id: data[data.length - 1]?.id ?? null,
    has_more: false
  });
}

export async function POST(req: Request) {
  if (!authed(req)) return fail(401, "Missing or invalid API key.", "invalid_api_key");
  const body = await req.json();

  for (const f of ["title", "slug", "content"])
    if (!body[f]) return fail(400, `${f} is required.`, "missing_field", f);

  const status = body.status ?? "draft";           // draft unless asked otherwise
  if (!["draft", "published"].includes(status))
    return fail(400, `status must be "draft" or "published".`, "invalid_status", "status");

  if (await db.posts.findUnique({ where: { slug: body.slug } }))
    return fail(409, `A post with slug '${body.slug}' already exists.`, "slug_taken", "slug");

  const row = await db.posts.create({ data: { ...body, status } });
  return Response.json(shape(row), { status: 201 });
}

If your host redirects apex ↔ www, exclude /api/ from that rule.


Before you ship

Show
  • [ ] No key, or a key in the query string: 401.
  • [ ] /api/v1/posts answers with no redirect.
  • [ ] ?slug= returns only that post.
  • [ ] Drafts show up in the list for the same key.
  • [ ] Updating a live post without status keeps it live. Check the live URL.
  • [ ] That update leaves published_at and the slug alone.
  • [ ] A post with no status is a draft and is not public.
  • [ ] Sending only content keeps the tags.
  • [ ] A duplicate slug returns 409.
  • [ ] Every error has the right status and names the field.
  • [ ] An SVG cover renders.
  • [ ] A bw-embed block renders as a frame, not just its caption.

Connecting it

Once the API is live, run blogwriter cms setup, or use Settings → The client's CMS in the app. A site built to this spec needs no field mapping.


Or a webhook

Want the least code? blogwriter can POST each post to one webhook URL instead. See the webhook spec, which has its own AI prompt.


Give this to your AI

Open your AI coding tool (Claude Code, Cursor, Lovable…) in the site's repo and paste the box below. It builds and tests everything, then gives you the endpoint and key for blogwriter.

Prompt for your AI
You are working in the repository of a website with a blog. Build a small REST API so blogwriter,
an AI writing tool, can publish posts into this site, and make the blog render everything
blogwriter writes. Everything you need is in this message; do not guess at anything it specifies.
Work through the steps in order and do not stop until the "DONE WHEN" list at the end passes.

STEP 0: LOOK BEFORE CHANGING ANYTHING
Find out and tell me in two or three lines:
- the framework and how API routes are written here (Next.js app or pages router, Express, Astro,
  SvelteKit, Laravel, Rails, Django, etc.);
- where blog posts are stored (a database table and its ORM, a headless CMS, Markdown files in the
  repo) and the fields each post has;
- how a post body becomes HTML on the blog page (Markdown renderer, sanitizer, MDX, templates);
- whether the site sends a Content-Security-Policy header;
- where environment variables live and how the site is deployed.
Change as little as that allows. Reuse the existing posts store and the existing blog page. If there
is no blog yet, create one: a posts table (below), a /blog index page listing published posts
newest first, and a /blog/[slug] page.
If posts are Markdown files in the repo, an API on a serverless host cannot write to them; tell me,
and propose a small database table (or the platform's KV/blob store) that the API writes and the
blog pages also read.

STEP 1: THE POSTS STORE
Each post needs these fields. Add only the ones that are missing, and map existing columns to these
names in the API rather than renaming them:
  id (stable, opaque, never reused), title, slug (UNIQUE index), status ("draft" or "published",
  default "draft"), content, created_at, updated_at, and nullable: excerpt, cover_image_url, author,
  published_at, canonical_url, meta_title, meta_description; tags as a list of strings.
The unique index on slug is what makes the 409 below true: a check-then-insert races.
Keep published_at as its own column; never derive status from it.

STEP 2: THE API KEY
Generate a random key (e.g. `sk_live_` + `openssl rand -hex 24`). Put it in the local env file as
BLOG_API_KEY and make sure that file is git-ignored. Never commit it and never write it into source
code. Tell me to add the same variable to the hosting provider's environment settings.

STEP 3: THE API
Authentication, on every route:
- `Authorization: Bearer <BLOG_API_KEY>`, compared in constant time. Missing or wrong: 401.
- Never accept the key in a query string.
- /api/v1/* must answer at the canonical host with no redirect: a redirect between example.com and
  www.example.com drops the Authorization header, and every write then fails as 401 while reads
  seem fine. Exempt /api/ from any apex <-> www redirect, or tell me which host to use.
- Exclude these routes from auth middleware, CSRF protection or bot protection meant for browsers.

The post object. Field names are snake_case. Return every field you store, even when null:
  {
    "id": "post_9f2ab41c", "object": "post",
    "created": 1788267336, "updated": 1788267336,          (unix SECONDS, integers)
    "title": "Gym Schedule and Timetable Templates",
    "slug": "gym-schedule-timetable",
    "status": "draft",                                       ("draft" or "published", nothing else)
    "content": "Someone types \"gym schedule template\" into...",
    "content_format": "markdown",                            ("markdown" or "html": whichever you store)
    "excerpt": "How to build a class timetable that members turn up to.",
    "cover_image_url": "https://cdn.example.com/hero.png",   (absolute)
    "tags": ["gym", "scheduling"],                           (plain strings)
    "author": "The Editorial Team",                          (a display name)
    "published_at": null,                                    (ISO 8601, or null while a draft)
    "canonical_url": null, "meta_title": null, "meta_description": null,
    "url": "https://example.com/blog/gym-schedule-timetable" (where the post lives or will live)
  }

Endpoints (updates use POST, not PATCH):
  GET  /api/v1/posts         List. Query: slug (exact match, returns 0 or 1 rows), status (draft|
                             published), limit (1–100, default 20), after (a post id: the page after
                             it). Response:
                             {"object":"list","data":[...post objects],"first_id":"...",
                              "last_id":"...","has_more":false}
  POST /api/v1/posts         Create. Returns 201 and the post object.
  GET  /api/v1/posts/{id}    One post, or 404. Also accept the slug in place of the id.
  POST /api/v1/posts/{id}    Update. Only the fields present in the body change. Returns 200 and
                             the post object. Accept PATCH on the same path too, doing the same.
                             Accept the slug in place of the id here as well.
  POST /api/v1/images        Optional. multipart/form-data with one "file" part; accept image/png,
                             image/jpeg, image/webp and image/svg+xml; store it and return
                             {"object":"image","id":"img_...","url":"https://...","bytes":148213}.
                             Skip this if the site has no file storage: posts arrive with their
                             images already hosted.

Rules that have broken real integrations. Each one is required:
- ?slug= must actually filter. An API that ignores it and returns everything looks fine until the
  list is longer than one page, and then every push creates a duplicate.
- Drafts must be returned to the authenticated key, in the list and by id. Hide drafts from the
  public site, never from the API key that wrote them.
- A create with no status makes a draft. A draft never appears on the public site.
- Required on create: title, slug, content. Missing one: 400 naming it.
- A create with a slug that already exists: 409 with code "slug_taken". Never create a second post
  with a suffixed slug.
- An update that does not send status leaves status exactly as it was. Never default it, never reset
  it to draft, never infer it from published_at. A published article turned back into a draft is a
  live URL that starts answering 404. The same update leaves published_at and the slug alone unless
  they are sent. Sending only content leaves the tags alone.
- When a post becomes "published" and published_at is empty, set it to now.
- An update that changes the slug of a published post adds a 301 redirect from the old address.
- If the body sends published_at in the future with status "published", hold it as scheduled and
  publish it then (use the site's cron/scheduled jobs, or have the public pages treat it as
  published once the time has passed), and return published_at so the caller can see it was kept.
- Errors use one envelope and a matching HTTP status; never a 200 carrying an error:
    {"error":{"message":"A post with slug 'x' already exists.","type":"invalid_request_error",
              "param":"slug","code":"slug_taken"}}
  400 malformed body / unknown status / missing field (name it in param); 401 bad key; 404 no such
  post; 409 slug taken; 413 body too large (say the limit); 422 well-formed but rejected (e.g. an
  unknown author); 429 rate limited with Retry-After. Write each message for a person reading a
  terminal: say what was wrong and what would be accepted.
- After a create or update, make it visible without a redeploy: revalidate/purge the blog index,
  the post page, the sitemap and the RSS feed if the site caches them.

STEP 4: THE PUBLIC SITE
- The blog index, the post page, the sitemap and the RSS feed show only published posts (and
  scheduled ones whose time has passed). Drafts are never public.

THE BLOG PAGES: render everything blogwriter writes
A post body is Markdown (tables included) with a few HTML blocks in it on purpose. Render it with
these on top, and change nothing about how existing posts look.

1. Heading ids. Every heading gets an `id` so the contents list can jump to it. The rule: the
   heading's text, lowercased, apostrophes removed, every run of characters that is not a letter or
   digit turned into one hyphen, hyphens trimmed from both ends, cut to 72 characters. This is the
   GitHub / rehype-slug / markdown-it-anchor convention, so a renderer already doing that is fine.
   Example: "What is Hype Coin (HYPE)? Price & 2026 Outlook" -> what-is-hype-coin-hype-price-2026-outlook

2. Raw HTML that must be rendered, not stripped or escaped. Keep the site's sanitizer and allow only:
   - <nav class="bw-toc"> with p, ol, li and a[href] inside: the table of contents.
   - <figure class="bw-embed bw-embed-<kind>"> blocks: embeds. Allow inside them:
       figure[class, style]; iframe[src, title, loading, referrerpolicy, sandbox, allow,
       allowfullscreen, style, width, height]; figcaption, blockquote, p, cite, strong, span [style];
       a[href, rel, target, style].
     Render an iframe only if its src is https:// and its host matches its kind:
       bw-embed-youtube    www.youtube-nocookie.com, www.youtube.com
       bw-embed-x          platform.twitter.com         (an X/Twitter post)
       bw-embed-tv-chart   s.tradingview.com            (live chart)
       bw-embed-tv-price   s.tradingview.com            (live price)
       bw-embed-reddit     www.redditmedia.com
       bw-embed-instagram  www.instagram.com
       bw-embed-website    any https host; add sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
       bw-embed-link       no iframe: a link card
   - Keep inline `style` attributes on these elements. They carry each embed's size (a chart's
     aspect ratio, a price widget's height, an X post's height), and the site has none of
     blogwriter's CSS. None of these blocks contains <script>, and none may ever be allowed one.
   If the site already turns YouTube links into its own player, keep that, but do not let it
   swallow the other kinds.

3. Blockquotes. Every post opens with `> **In short:** ...`, the answer in two or three lines. Style
   blockquotes so it reads as a summary box, not as small grey text.

4. Images. Markdown images and the cover image are usually absolute URLs; also accept root-relative
   paths (/images/x.png) resolved against this site, and SVG files (diagrams are SVG). Never drop
   an SVG.

5. Metadata. Use the meta title / meta description when set (else title / excerpt), the canonical
   URL when set, and the cover image as the social (og:image) image. Drafts never render publicly,
   are never in the sitemap, and never in the RSS feed.

6. Content-Security-Policy. If the site sends one, frame-src must include every host in the embed
   list above and img-src must allow https images.

THE TEST POST (real blogwriter output; use it to check the rendering)
---
title: "blogwriter render test"
slug: "blogwriter-render-test"
description: "A test post with every block blogwriter writes."
---

> **In short:** if this sentence sits in a styled quote box, the blockquote works.

Opening paragraph with a [link to another post](/blog/another-post) and **bold** text.

<nav class="bw-toc" aria-label="Table of contents">
<p class="bw-toc-title">Table of contents</p>
<ol>
<li><a href="#what-is-hype-coin-hype-price-2026-outlook">What is Hype Coin (HYPE)? Price &amp; 2026 Outlook</a>
</li>
<li><a href="#live-data">Live data</a>
</li>
<li><a href="#what-people-said">What people said</a>
</li>
</ol>
</nav>

## What is Hype Coin (HYPE)? Price & 2026 Outlook

A table:

| Measure | Figure |
|---|---|
| Price | $91.52 |
| Market cap | $20.4 billion |

![A diagram drawn as SVG](https://blogwriter.pro/favicon.svg)

## Live data

<figure class="bw-embed bw-embed-tv-chart" style="margin:2rem 0">
  <iframe src="https://s.tradingview.com/widgetembed/?symbol=BYBIT%3AHYPEUSDT&interval=D&theme=light&style=1&locale=en&hide_side_toolbar=1&allow_symbol_change=0&saveimage=0&withdateranges=1" title="BYBIT:HYPEUSDT live chart" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;aspect-ratio:16/10;border-radius:12px"></iframe>
  <figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">Live chart · <a href="https://www.tradingview.com/symbols/BYBIT-HYPEUSDT/" rel="noopener nofollow" target="_blank">BYBIT:HYPEUSDT on TradingView</a></figcaption>
</figure>

## What people said

<figure class="bw-embed bw-embed-x" style="margin:2rem 0">
  <iframe src="https://platform.twitter.com/embed/Tweet.html?id=20&dnt=true" title="Post on X by jack" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="display:block;width:100%;border:0;max-width:550px;height:234px;border-radius:12px"></iframe>
  <figcaption style="font-size:.875rem;opacity:.72;margin-top:.6rem;line-height:1.5">jack · <a href="https://x.com/jack/status/20" rel="noopener nofollow" target="_blank">See the post on X</a></figcaption>
</figure>

<figure class="bw-embed bw-embed-link" style="margin:2rem 0">
  <a href="https://www.coingecko.com/en/coins/hyperliquid" rel="noopener" target="_blank" style="display:block;padding:1rem 1.2rem;border:1px solid rgba(127,127,127,.3);border-radius:12px;text-decoration:none;color:inherit">
    <strong style="display:block">Hyperliquid price</strong><span style="font-size:.875rem;opacity:.7">coingecko.com</span>
  </a>
</figure>

Check the test post on desktop and on mobile:
- the In short box is styled;
- the contents list links jump to their headings;
- the table, the SVG image and the link card render;
- the TradingView chart and the X post render as real frames, not just their captions;
- an iframe from a host not in the list, or a <script> inside a bw-embed figure, is dropped.

STEP 5: TEST IT, FOR REAL
Run the site locally and exercise the API with curl (replace KEY and the port). Show me each result.
a) No key: GET /api/v1/posts -> 401.
b) Create a draft:
   curl -i -X POST http://localhost:3000/api/v1/posts -H 'Authorization: Bearer KEY' \
     -H 'Content-Type: application/json' \
     -d '{"title":"API test","slug":"api-test","content":"> **In short:** it works.\n\n## First\n\nHello.","excerpt":"A test.","tags":["test"]}'
   -> 201, "status":"draft", not visible on /blog.
c) GET /api/v1/posts?slug=api-test -> exactly that one post, draft included.
d) The same create again -> 409 slug_taken.
e) POST /api/v1/posts/<id> with {"status":"published"} -> live at /blog/api-test.
f) POST /api/v1/posts/<id> with only {"content":"Changed."} -> still published, tags unchanged,
   published_at unchanged. Check the live URL, not just the response.
g) POST /api/v1/posts with {"title":"x","slug":"y","content":"z","status":"live"} -> 400 naming status.
h) Create THE TEST POST above as a published post and check it against the list under it, on
   desktop and mobile widths.
Then delete the test posts. If the project has an automated test setup, add tests for (a)–(g).

DONE WHEN
- Every item below is true, and every existing post renders exactly as it did before:
  [ ] An unauthenticated request gets 401, and the key is never read from a query string.
  [ ] /api/v1/posts answers directly at the canonical host, with no redirect.
  [ ] ?slug= returns only the matching post, not the whole table.
  [ ] A draft created through the API comes back in a list request made with the same key.
  [ ] Updating a published post without sending status leaves it published, and leaves
      published_at and the slug alone.
  [ ] A post created with no status is a draft, and does not appear on the public site.
  [ ] Sending only content to the update endpoint leaves the tags intact.
  [ ] A duplicate slug returns 409, not a second post with a suffix.
  [ ] Every error has a matching HTTP status and a message naming the field.
  [ ] An SVG cover image renders rather than being stripped.
  [ ] A bw-embed block renders as a frame on the live page, not as its caption alone.
- You have told me, at the end, in this exact form:
    Endpoint:  https://<the production domain>/api/v1/posts
    API key:   <the key> (also set BLOG_API_KEY on the host)
    Body:      markdown | html
    Scheduling: supported | not supported
  plus anything I still have to do by hand (deploying, adding the env variable on the host).
I will connect it in blogwriter with `blogwriter cms setup` (platform "http"), or in the app:
Settings -> The client's CMS -> Other REST, and paste the endpoint and the key.