<!-- OpenSmartRoute: Content publishing for your website. https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/PUBLISHING -->
# Content publishing for your website

The platform writes and publishes content for your own website as a service of your workspace: follow the RSS or
Atom feeds you write about, or send a brief, and the posts are written through the router as **your workspace's
own metered requests**, reviewed in the dashboard (or not, if you say so), then **delivered to your website** - a
signed webhook any site or CMS can receive, a WordPress site, a Ghost publication - **and to your social
accounts** (LinkedIn, X, Facebook, Threads, Discord, Telegram, Mastodon, Bluesky, Reddit, or re-posted on Dev.to,
Hashnode, Medium, Blogger, Tumblr with a canonical link), each on its own posting schedule - or **pulled** from
the API and an RSS feed of your own. The same operations are tools on your MCP server, so an agent in your IDE or
your automation can list, write and publish posts. Everything is scoped to the workspace: another workspace never
sees your feeds, posts or destinations.

The platform's own blog and newsletter run on the same engine; this page is about *yours*.

## Where it lives

| Surface | What you get |
|---|---|
| `/platform/dashboard/content` | Write a post from a brief, review and publish posts, follow feeds (check a URL first, rename, recategorise, pause, fetch now), add destinations and test them, copy or replace the RSS pull link, set the editorial profile, read the delivery log. |
| `GET /api/v1/content` | The service at a glance: profile, post and delivery counts, feeds, destinations, the writer in use, limits, the pull URL. |
| `/api/v1/content/*` | Every operation below, with your API key (`Authorization: Bearer <key>`). |
| `POST /mcp` | The `content_*` tools on your MCP server (see [MCP](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MCP.md)). |

Members write, edit and publish; adding, changing or removing a **destination** (it holds a credential of your site)
takes the `admin` role. API keys act as the workspace and pass every role check.

## Posts: three ways in

1. **From a brief** - `POST /api/v1/content/posts/write` with `topic`, optional `notes` (facts, figures, names the
   writer stays within) and optionally `url`, the page the article is about - its readable text is fetched and
   given to the writer next to your notes. The writer routes the request through
   the platform with a plan - persona, skill and model slots, the `editorial-writer` skill always in a skill slot -
   and the post's `routing` says which model wrote it, at what cost, with which companions. `status: published`
   publishes at once; the default is a draft. The answer carries `writer_decision`: when no executable model is
   available, or the request was refused (quota, policy), the post is your brief itself and the decision says why.
2. **From your feeds** - `POST /api/v1/content/feeds` with `url` (RSS 2.0, RSS 1.0 or Atom - or the page of a blog:
   the feed it advertises in its `<head>`, or at `/feed`, `/feed.xml`, `/rss.xml`, `/rss`, `/atom.xml` or `/index.xml`,
   is the one followed), an optional `name` (the feed's own title when left out) and `category` (a tag on every post
   from it). The URL is looked at once before it is followed: the answer carries the feed with what was found under
   `probe` (`ok`, `title`, `site_url`, `entries`, the newest `recent` entries, `discovered_from` when a page led to
   the feed); a page that answers but has no feed is refused with the reason (400), a URL that does not answer is
   followed with the error on its row and tried again on every pass. `POST /api/v1/content/feeds/preview` is the
   same look without following - the dashboard's *Check* button - and also says whether you follow it already.
   The scheduler polls every enabled feed on the deployment's cadence (`OSR_PLATFORM_PUBLISHING_REFRESH_S`,
   an hour by default; a newly added feed replays the last seven days), stores each new entry once (deduplicated by
   the entry's id and by the article URL across your feeds) and writes a post for it from the article page's readable
   text, or the feed's excerpt when the page cannot be fetched. `POST /api/v1/content/feeds/{feed_id}/fetch` polls
   one feed now, `POST /api/v1/content/refresh` all of them; `PATCH /api/v1/content/feeds/{feed_id}` renames,
   recategorises or pauses a feed (`enabled`), `DELETE` stops following it (its posts stay). Each feed carries its
   last fetch, its last HTTP status and error, how many entries it has yielded and the site it belongs to. Posts
   from feeds wait as **drafts** unless the profile's `auto_publish` is on.
3. **Written elsewhere** - `POST /api/v1/content/posts` with `title`, a Markdown `body`, optional `summary`, `tags`,
   `source_url`, `image_url` and `status` - your own tool, a form on your intranet, an agent.

`GET /api/v1/content/posts` lists them newest first (`status`, `q`, `limit`, `offset`; bodies left out),
`GET /api/v1/content/posts/{post_id}` has the Markdown body, an HTML rendering and the post's deliveries,
`PATCH /api/v1/content/posts/{post_id}` edits (title, summary, body, tags, image, status) and
`DELETE /api/v1/content/posts/{post_id}` removes a post with its delivery log.
`POST /api/v1/content/posts/{post_id}/regenerate` rewrites a feed post with the current writer, keeping its slug
and status.

Every post has a `slug` (unique within the workspace), `summary`, `why` (one sentence on why it matters to your
readers), `highlights`, `tags`, `keywords` and an `seo` block with a title, description and keywords for your page.

## The editorial profile

`GET|PUT /api/v1/content/profile` - what the writer is told about you: the `publication` name, its `audience`,
the `tone`, the `language` it writes in, the `site_url` where the posts live (the RSS links to it),
`auto_publish` and `article_words` - the length a post aims at (300 to 4,000 words; `0` or empty = the deployment's
default, 2,000). Empty fields fall back to plain defaults (the workspace name, "readers of the publication's website
who want the facts first", "plain, factual and friendly", English). The writer never invents facts: it only uses
what the source text or your notes say.

## How a post is written

Every post from a feed or a brief is written in two metered passes, both routed through the platform with the
`editorial-writer` skill in a skill slot: first the **brief** - a small JSON object with the title, the summary, the
key points, *why it matters*, an outline of sections (three to nine, scaled to the length asked), tags and keywords -
then the **article** itself, plain Markdown from that outline, written for *your* publication, its readers and tone,
in your language, from the source page's readable text (a feed entry) or from your notes and the linked page (a
brief). A draft that stops short of three quarters of the length asked is continued once from where it ended. The
post's `routing` carries both passes (`passes`), the measured length (`length`: words, target, minimum, whether it
came in short) and, when the article pass could not produce at least 400 words, a `reason` - the post then keeps
the brief's own text or your notes. `GET /api/v1/content` reports the lengths in `writer.article`.

## Destinations: getting posts onto your website - and your social accounts

`GET|POST /api/v1/content/destinations`, `PATCH|DELETE /api/v1/content/destinations/{destination_id}`. Each has a
`kind`, a `name`, a `url` (the site, for the three CMS kinds), `auto_publish` (every post that becomes published
is delivered there), a `schedule` (below) and, for the CMSs and blog platforms, `post_status` - `publish`
(default) or `draft`, when you want a last look inside the CMS. Credentials are stored encrypted (AES-GCM under
the deployment secret, bound to your workspace) and never returned; the URL must be a public endpoint (a
self-hosted deployment that runs its CMS on a private network sets `OSR_PLATFORM_BYOK_ALLOW_PRIVATE=true`, the
same switch as for private model endpoints).

| Kind | What the platform sends | What you provide |
|---|---|---|
| `webhook` | `POST` to your URL: JSON with `event: "post.published"`, the `post` (fields above plus `body` in Markdown and `html`), the `workspace` and the `platform`; headers `X-OSR-Event`, `X-OSR-Delivery` and, with a secret, `X-OSR-Signature: t=<unix>,v1=<hmac-sha256(secret, "<t>.<body>")>` - verify it like a Stripe signature. Answer `2xx`; a JSON body with `id` and `url` is recorded as the post's address on your site. | Any URL that accepts JSON: a route of your site, a serverless function, a CMS plugin, an automation platform. Optional `secret`. |
| `wordpress` | `POST <url>/wp-json/wp/v2/posts` with the title, the HTML content, the excerpt, the slug and the status, authenticated as your user. | The site URL, `username` and an **application password** (Users → Profile → Application passwords) as `secret`. |
| `ghost` | `POST <url>/ghost/api/admin/posts/?source=html` with the title, the HTML, the excerpt, the slug, the tags, the lead image and the source as canonical URL, authenticated with a short-lived token made from your key. | The site URL and an **Admin API key** (`<id>:<secret>`, Settings → Integrations → custom integration) as `secret`. |
| `linkedin`, `x`, `facebook`, `threads`, `mastodon`, `bluesky`, `reddit` | A message on your account or page - title, summary, the link to the post on your site (the profile's **site URL** plus the slug; the source article when you have none) and hashtags from the tags, or your own `template` with `{title}`, `{summary}`, `{url}`, `{hashtags}`, `{brand}`, `{why}` - fitted to the network's limit without cutting the link, with the post attached as a link card where the network has one. | `credentials`: the fields `GET /api/v1/content/destinations` lists under `networks` for the kind (a LinkedIn access token and author URN; X's four OAuth 1.0a values or an OAuth 2 user token; a Facebook page id and page token; a Threads user id and token; a Mastodon instance and token; a Bluesky handle and app password; a Reddit script app, account and subreddit). No `url`. |
| `discord`, `telegram` | A channel message with an embed or link preview: title, summary, link, lead image. | A Discord webhook URL; a Telegram bot token and chat id. |
| `devto`, `hashnode`, `medium`, `blogger`, `tumblr` | The article itself (Markdown or HTML as the platform wants it, the tags, the lead image) with your post's page as the canonical URL, published or as a draft (`post_status`). | A Dev.to API key; a Hashnode token and publication id; a Medium integration token; a Blogger blog id and OAuth client with refresh token; Tumblr's blog and OAuth 1.0a values. |

**Schedules.** `schedule` decides when a queued delivery goes out: `{"mode": "immediate"}` (default) sends the
moment a post is published; `{"mode": "window", "days": [0,1,2,3,4], "start": "09:00", "end": "17:00",
"timezone": "Europe/Berlin"}` waits for the next weekday (0 = Monday) and time of day in that zone;
`gap_minutes` keeps two deliveries to the same destination at least that far apart and `max_per_day` caps them
per local day - so a feed that drops five stories at once posts them spread out instead of flooding your
followers. The slot is computed after the last delivery sent or planned on that destination. An explicit
`POST /api/v1/content/posts/{post_id}/publish` always goes now.

`POST /api/v1/content/destinations/{destination_id}/test` sends a clearly marked test post now and answers with
the site's verdict (`ok`, HTTP `status`, `detail`, the `external_url` the site gave it); nothing is stored.

The raw HTML a feed, a model or an editor may have put into a Markdown body is dropped before rendering, so only
what Markdown produces reaches your site.

## Publishing and the delivery log

A post becomes public when it is created or edited with `status: published`, or with
`POST /api/v1/content/posts/{post_id}/publish` - which also delivers it *now* to every enabled destination (or the
`destination_ids` you name), waits for the sites' answers and returns each delivery. A status change alone (PATCH,
auto-publish from a feed, a post created as published) queues one delivery per destination set to `auto_publish`
at the time its schedule allows and sends what is due in the background at once; the scheduler's minute pass
picks up anything left over and the deliveries whose slot has come.

`GET /api/v1/content/deliveries` (`post_id`, `limit`) is the log: every attempt with the destination, the site's
HTTP status, a short `detail`, the `external_id` and `external_url` the site assigned, and `next_at` while a retry
is pending. A delivery is `queued` until its time comes, `sending` during an attempt (an attempt a crashed process
left behind is retried after ten minutes), then `sent` or `failed`. A site that did not answer, failed on its side
(5xx) or asked for a pause (429) is retried after 1, 5,
30 and 120 minutes - five attempts in all; a rejected configuration (401, 403, 404, 400) fails at once with the
site's message. `POST /api/v1/content/deliveries/{delivery_id}/retry` tries again on demand. Each destination
carries its last status, last error and how many posts it accepted.

## Pulling instead of pushing

Your site can fetch the posts itself: `GET /api/v1/content/posts?status=published` and
`GET /api/v1/content/posts/{post_id}` with your API key, or `GET /api/v1/content/feed.xml` - RSS 2.0 of your
published posts, each linked under your `site_url` (or its source when none is set), with the summary, the tags,
the lead image and the source. A feed reader cannot send an API key, so the feed also answers to the workspace's
**feed token** in the query string: the overview's `feed_url` is the complete pull URL
(`.../api/v1/content/feed.xml?token=...`), shown with a copy button on the Content page under *Destinations*. The
token reads published posts and nothing else; `POST /api/v1/content/feed/token` (admins; *Replace link* on the
page) replaces it, and the old link stops working at once. A static-site build step, a feed reader or an
automation platform can consume it.

## From an agent: the MCP tools

Your MCP server (`POST /mcp`, [MCP](https://osr-web.kindrock-caaab1a2.centralindia.azurecontainerapps.io/docs/MCP.md)) carries, next to `route`, `estimate` and the others:

| Tool | Does |
|---|---|
| `content_list_posts` | List posts (filter by `status`, search with `q`). |
| `content_get_post` | One post by id or slug with its body and deliveries. |
| `content_write_post` | Write a post from a brief (`topic`, `notes`, `url`, `status`) through the router - a metered request of the workspace. |
| `content_create_post` | Store a post the agent wrote itself (Markdown body). |
| `content_publish_post` | Publish a post and deliver it to the destinations now; returns each site's answer. |
| `content_list_feeds` | The feeds the workspace follows, each with its last fetch and error. |
| `content_add_feed` | Follow a feed, or the page of a blog (the feed it advertises is found); the answer says what was found under `probe`. |
| `content_fetch_feed` | Poll one feed now and write posts for its new entries (a metered request per post). |
| `content_list_destinations` | The websites posts are delivered to, with their last delivery. |

So "write a post about our new opening hours from this page and publish it to the site" is one instruction to an
agent connected to your workspace - and one entry on your usage, activity and savings pages.

## Limits, metering and privacy

- A workspace follows up to `OSR_PLATFORM_PUBLISHING_MAX_FEEDS` feeds (20) and has up to
  `OSR_PLATFORM_PUBLISHING_MAX_DESTINATIONS` destinations (10); one scheduler pass writes at most ten posts per
  workspace, the rest follow on the next passes.
- Every written post is one metered request (`endpoint: content`, app `publishing`) against your plan's quota,
  budgets and wallet, visible on `/platform/dashboard/usage` and `/platform/dashboard/activity` with its routing
  decision; the models learn from the outcome like from any other request. Listing, editing, publishing and
  delivering are free.
- Feed entries, posts, destinations and deliveries are deleted with the workspace.
- The operator switches the service off with `OSR_PLATFORM_PUBLISHING=false` (no routes, no tools, no scheduler) and
  sets the polling cadence with `OSR_PLATFORM_PUBLISHING_REFRESH_S` (0 = only on request).
