---
name: seonix-blog
description: Add an AI-search-optimized blog to any site and connect it to Seonix (https://seonix.ai) for automated SEO publishing. Use when asked to integrate Seonix, add a Seonix blog, or set up automated SEO content on a site.
---

# Seonix blog integration — instructions for AI coding agents

> You are an AI coding agent. The user wants this site connected to Seonix
> (https://seonix.ai) — a service that researches keywords, generates SEO- and
> AI-search-optimized articles, and publishes them to the site on a schedule.
> Your job: make this site able to RECEIVE those articles (and let Seonix
> READ the articles it already has), then connect the channel to the user's
> Seonix account. Follow every step, in order, and finish with the hand-over
> report in Step 11 — the user needs it to check the channel in Seonix.
> Human-readable reference for the same contract: https://seonix.ai/docs/api
> Different job? If the user wants their AI agent to READ Seonix data
> (Site Health, fix plan, Search Console, keywords, draft scoring) rather
> than receive articles, that is the Seonix MCP server — follow
> https://seonix.ai/mcp.md instead of this file.

Seonix is the HTTP client; this site is the receiver. You will implement one
publishing endpoint (POST), two read endpoints on the same path (GET list and
GET one — this is what "Import Articles" in Seonix calls), one media upload
endpoint, an optional delete endpoint, public blog pages with correct SEO
plumbing — then register the channel in the user's Seonix account with a
one-time connect code (Step 9). The contract below is live in production —
seonix.ai's own blog and customer sites already run on it.

The integration is done only when an article Seonix publishes is, within
minutes and without anyone rebuilding the site: live at its URL as
server-rendered HTML (readable with JavaScript off), linked from the blog
list, listed in the sitemap, and returned by the GET endpoints. The
Architecture section and Step 6 say how; Step 8 proves it with curl.

## Step 0 — assess the site before writing code

1. Detect the stack (framework, language, database, hosting). Everything below
   works with any stack that can serve an HTTP endpoint: Next.js, Rails,
   Laravel, Django, FastAPI, Go, plain PHP, serverless functions — all fine.
2. Check whether the site already has a blog.
   - **Existing blog:** reuse it. Map the payload fields in Step 1 onto the
     existing schema (add missing columns/fields rather than creating a
     parallel blog), keep existing URLs stable, and wire the endpoints into
     the existing rendering. Existing posts MUST be visible through the read
     endpoints in Step 3 — that is how Seonix learns what the site already
     covers.
   - **No blog:** create one at /blog using the site's existing stack and
     visual style. Do not introduce a new framework or a separate CMS for it.
3. Find the site's real public origin (scheme + host the blog is served from,
   e.g. https://www.example.com). You will build absolute article URLs from
   it in Steps 2 and 3. Do not guess it from an env var you have not
   verified — read it from the deployed site, the existing canonical tags,
   or ask the user. A wrong origin here is the single most damaging mistake
   in this integration (see Step 2, point 4).
4. If the stack cannot host server code at all (pure static hosting), stop and
   tell the user; they need either a serverless function on their host or a
   platform connector (WordPress, Ghost, Webflow, Shopify, Wix) instead.
5. Find out what a crawler receives — it decides the rendering route in
   Step 6. Fetch an existing content page (an existing blog post, if there is
   one) with plain `curl` and compare it with what a browser shows. If the
   raw HTML lacks the visible text (an empty `<div id="root">` plus scripts,
   or the same generic `<title>` and canonical on every URL), the site is
   client-rendered. Also find where /sitemap.xml, /robots.txt and /llms.txt
   come from: a file in `public/` or one written by a build script is
   static and must become store-driven (see Architecture).

## Architecture — one article store, read by everything

Build the integration around ONE article store (the table of Step 1). It
is the single source of truth for every surface that shows articles:

```
POST / DELETE   /api/seonix/articles  ── write ──►  article store
GET list / one  /api/seonix/articles  ◄── read ───  article store
/blog, /blog/page/N, category pages   ◄── read ───  article store
/blog/<slug>                          ◄── read ───  article store
/sitemap.xml (+ child sitemaps)       ◄── read ───  article store
/blog/rss.xml, /llms.txt              ◄── read ───  article store
```

1. **Readers query the store when requested,** server-side — or are
   cached/prerendered AND invalidated by the write that changed them.
   Nothing that lists or renders articles may exist only as a build output:
   no articles exported to JSON/markdown files in the repo, no build-time
   content collection, no sitemap.xml written at build, no post list baked
   into the JS bundle. On serverless hosting, the store is a real database
   or KV service — a file written at runtime does not survive there.
2. **The POST and DELETE handlers own the invalidation.** After the database
   commit and before responding, invalidate everything the change touches:
   the article URL, /blog (with its pagination and category pages), the
   sitemap and the feed. E.g. Next.js `revalidatePath()` for each path or
   one `revalidateTag("articles")` shared by every article query; Nuxt /
   Nitro cached routes → purge them; a CDN in front → its purge API, or
   `Cache-Control: s-maxage` of 300 s at most on these pages; a
   build-time-prerendered site → call the host's deploy hook (fire it, do not
   wait for the build — Seonix times out after 30 s).
3. **Deadline: 5 minutes.** Within 5 minutes of the 200 response a new
   article is reachable at its URL as complete server-rendered HTML, linked
   from /blog, listed in the sitemap and returned by GET; an edit shows in
   the same window; a delete removes the article from all of them in the
   same window. Nobody rebuilds or redeploys by hand. If a build-and-deploy
   cycle cannot meet 5 minutes, render the blog routes per request instead.
4. **One query, many views.** The GET list endpoint, the /blog list and the
   sitemap use the same "published articles" query (same filter, newest
   first), so they never disagree about which articles exist. If the
   existing blog is file-based (markdown/MDX in the repo), migrate those
   posts into the store, or make every reader merge both sources (the
   file-based part only changes on deploys, so it may stay build-time).

## Step 1 — data model

Store articles with these fields (rename to match house conventions, but keep
the semantics; if a blog table already exists, map onto it):

| Field | Type | Notes |
|---|---|---|
| id | your primary key | internal; also what GET /{id} and DELETE /{id} accept |
| external_id | string, unique, nullable | Seonix's stable article UUID — the upsert key. Null for posts that did not come from Seonix |
| translation_key | string, nullable | shared across language variants of one article |
| slug | string, unique per lang | matches ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
| lang | string | e.g. "en", "de", "ua" (Seonix sends "ua", not "uk") |
| title | string, required | |
| excerpt | text | |
| content_html | text, required | full article body, HTML |
| category | string, nullable | |
| key_takeaways | JSON array of strings, nullable | |
| key_takeaways_title | string, nullable | heading for the takeaways block, already localized |
| author | string, nullable | |
| cover_url | string, nullable | absolute URL |
| cover_alt | string, nullable | |
| seo_description | string, nullable | meta description |
| og_image | string, nullable | |
| schema_jsonld | text, nullable | pre-serialized JSON-LD @graph — store as received; Step 6 says which node to render |
| published_at | timestamp, nullable | RFC 3339 on the wire |
| created_at / updated_at | timestamps | house convention |

Bump `updated_at` on every upsert — the sitemap's lastmod and the
article's dateModified read it. Media files get their own small table
(hash, url, width, height, content type — Step 4).

## Step 2 — publishing endpoint (POST)

Implement `POST <site>/api/seonix/articles` (any path works; keep it out of
public sitemaps). The SAME path also serves the GET read endpoints of Step 3
— Seonix derives them from this one URL, there is no separate setting.
Behaviour, in order:

1. **Auth.** Require `Authorization: Bearer <token>` and compare against the
   secret from Step 7 using a constant-time comparison. On mismatch respond
   401 with a JSON body. Never log the token.
2. **Verify probe.** If the request has an `X-Seonix-Verify` header, respond
   `200 {"ok":true}` immediately and persist nothing. Seonix sends this
   empty-body probe when the channel is registered, to check URL + token.
3. **Upsert.** Parse the JSON body (fields exactly as in Step 1, plus nothing
   else) and upsert keyed on `external_id`: insert when new, update every
   field when the external_id already exists. Re-publishes and edits arrive as
   the same POST — this is what prevents duplicates. When an update changes
   the slug, keep the old URL as a 301 redirect to the new one. Then, still
   before responding, make the change live (Architecture, rule 2).
4. **Respond** `200 {"id":"<your record id>","url":"<absolute public URL of
   the article>"}`. `id` is a JSON string — send an integer key as
   `"42"`, not `42`; a number fails Seonix's parser and the publish is
   recorded as failed. Seonix stores that id (it is what GET /{id} and
   DELETE /{id} receive later) and shows the url in the dashboard, tracks
   rankings against it and matches Search Console data to it. Build the url
   from the REAL public origin found in Step 0 — the host the page is
   actually served from. A placeholder or stale "public base URL" setting
   has already shipped articles as `https://www.<other-domain>/blog/…` on a
   live site, which breaks every link and every ranking report for them.
5. **Errors.** Always respond with `Content-Type: application/json`, even
   for errors, shaped `{"error":{"code":"...","message":"..."}}`. A framework
   HTML 404/500 page breaks the channel — the operator sees your message, so
   make it helpful. Validation failures are 400; auth failures 401.
6. **Sanitize** `content_html` server-side before storing, with the exact
   allowlist in Step 6 → "Article body". Treat it as untrusted input — but a
   sanitizer that silently drops tables, figures or images is a bug (Step 8
   checks they survive).
7. **Re-host foreign images.** Articles written in Seonix BEFORE this
   channel existed can arrive with `cover_url`, `og_image` or inline
   `<img src>` on a seonix.ai host. Those are delivery URLs, not hosting:
   download each one, store it through the same content-addressed storage as
   Step 4, and rewrite the URL before saving. A published page must never
   point at a Seonix URL.

Facts you can rely on: Seonix times out after 30 s, retries failed publishes
3 times (after 15 min, 45 min, 2 h), sends `X-Seonix-Contract: 1`, never
sends fields outside the Step 1 list for contract version 1, and reads back
at most 500 articles per import.

## Step 3 — read endpoints (GET) — required for "Import Articles"

"Import Articles" in the Seonix dashboard pulls the site's existing posts
into the project, so the content plan does not repeat topics the site
already covers and new articles can link to them. It calls the publishing
URL with GET. Without these two routes the button fails with
"Method Not Allowed" — a FastAPI/Express/Next route that only declares POST
is the most common way this integration ends up half-working.

1. `GET <site>/api/seonix/articles` — same Bearer check as Step 2 (401
   JSON on mismatch). Respond `200 {"data":[ … ]}` with EVERY published
   article of the blog — the same query the /blog page uses (Architecture,
   rule 4) — newest first, drafts excluded: posts that existed
   before Seonix AND posts Seonix published (return their `external_id` so
   Seonix matches them to its own records instead of creating copies).
   Seonix reads the first 500 entries; pagination is not needed.
   Each entry carries the Step 1 fields: `id` (your record id — the same
   one GET /{id} accepts and the publish response returned), `external_id`
   (null/omitted when the post is not from Seonix), `slug`, `lang`,
   `title`, `excerpt`, `category`, `key_takeaways`, `cover_url`,
   `cover_alt`, `seo_description`, `published_at`. Include
   `content_html` when it is cheap; omit it to keep the list light — Seonix
   then fetches each article through the next route.
2. `GET <site>/api/seonix/articles/{id}` — same auth. Respond
   `200 {"data":{ … }}` with the full article including `content_html`;
   404 JSON for an unknown id.
3. `title` and `content_html` must be non-empty for an article to import;
   `slug` + `lang` are what Seonix uses to recognise an article it imported
   before, `external_id` is what it uses to recognise its own.
4. Wire types are strict: `id` and `external_id` are JSON strings (or
   null), `key_takeaways` an array of strings (not a JSON-encoded string),
   `published_at` an RFC 3339 string. One numeric `id` makes Seonix reject
   the whole list.

## Step 4 — media upload endpoint

Implement `POST <site>/api/seonix/media` — this is what keeps every image on
the site's own host instead of pointing pages at Seonix:

1. Same `Authorization: Bearer` check as Step 2.
2. Accept `multipart/form-data` with a `file` field (images: png, jpeg,
   webp, gif).
3. **Content-address the storage:** hash the bytes (e.g. sha256), store under
   that hash, and when the hash is already known return the existing URL
   instead of storing a copy. Seonix re-sends the same image across edits and
   re-publishes; content-addressing is what prevents duplicates. Use
   persistent storage (object storage, the database, a mounted volume) — not
   a serverless function's local disk.
4. Respond `200 {"url":"<url you assigned>"}` — absolute preferred (built
   from the real origin of Step 0), root-relative accepted (resolved against
   the endpoint's origin).
5. Record each image's width and height at upload (you hold the bytes) next
   to its hash and URL. Seonix's inline images arrive without dimensions;
   Step 6 adds them to every `<img>` so the page does not shift while
   loading. Serve the files with `Cache-Control: public, max-age=31536000,
   immutable` — content-addressed URLs never change.

By publish time every `<img src>` and the cover URL in the payload already
point at URLs this endpoint returned (except the pre-channel case handled in
Step 2, point 7).

## Step 5 — delete endpoint (recommended)

Implement `DELETE <site>/api/seonix/articles/{id}` where {id} is the id your
publish endpoint returned. Same Bearer auth. Delete the article, run the
same invalidation as a publish (Architecture, rule 2) so the URL answers 404
and the article leaves /blog, the sitemap and the feed, then respond
`200 {"ok":true}`; respond 200/404 for an unknown id too (treated as
already gone). Skipping this step just means deletes in Seonix don't propagate.

## Step 6 — public pages and SEO plumbing

These requirements mirror the checks Seonix's own site audit runs later —
follow them exactly and the blog passes its first scan clean.

### Server-side rendering — hard requirement

Every blog URL — /blog, its pagination and category pages, every
/blog/<slug> — must return its complete content in the FIRST HTML response,
with no JavaScript executed: `<title>`, meta description, canonical, Open
Graph tags and JSON-LD in the head; the `<h1>`, the key takeaways, the
full content_html and real `<a href>` links in the body. Most AI crawlers
and many search crawlers never run JavaScript: to them a client-rendered
article is an empty page, and an app shell that answers every article URL
with the blog index's `<title>` and canonical tells search engines each
article is a duplicate of /blog. Serve the same HTML to every client — no
user-agent sniffing, no bot-only prerender service.

Pick the route for the stack you found in Step 0:
- **Next.js (App Router):** Server Components that read the store,
  `generateMetadata` for the head, `app/sitemap.ts` reading the store.
  Either `export const dynamic = "force-dynamic"` on the blog routes, or
  cached/ISR reads revalidated from the POST/DELETE handlers
  (Architecture, rule 2). With `generateStaticParams`, keep
  `dynamicParams` on so slugs published after the build render on first
  request, and call `notFound()` for unknown slugs. Pages Router:
  `getServerSideProps`, or `getStaticProps` + `fallback: "blocking"` +
  `res.revalidate()` from the API route.
- **Nuxt, SvelteKit, Remix / React Router framework mode, Astro:** server
  rendering for the blog routes (Astro `output: "server"` or
  `export const prerender = false`; SvelteKit
  `export const prerender = false`; Nuxt without a `prerender: true`
  route rule on /blog/**), loading from the store in the server loader.
  Purge any route cache on publish and
  delete.
- **Classic server frameworks** (Rails, Django, Laravel, Express, FastAPI +
  Jinja2, Go templates, plain PHP): server templates that query the store on
  each request, inside the site's existing layout.
- **Client-only SPA** (Vite / CRA / React Router / Vue / Svelte served as a
  static index.html, usually with a separate API backend — the typical
  AI-built site): the static host cannot do this alone; the server must own
  the blog URLs.
  1. Route /blog, /blog/page/*, /blog/* and /sitemap.xml (+ the feed) to the
     server that hosts /api/seonix/* — with the host's rewrites or proxy
     (vercel.json `rewrites`, Netlify `_redirects` with status 200, an
     nginx `location`, an edge worker) or by serving the whole SPA from that
     server.
  2. Render those routes on the server. Best: a server template that reuses
     the site's header, footer and built CSS bundle (read the hashed asset
     names from the built `index.html` or the build manifest). Minimum: take
     the built `index.html` shell and, server-side, replace `<title>` and
     the head tags (description, canonical, OG/Twitter, JSON-LD,
     `<html lang>`) AND put the fully rendered page (h1, byline, cover,
     takeaways, content_html — on /blog the list of article links) inside the
     root element, then let the SPA mount over it. Head tags alone are not
     enough: the body must carry the article text.
  3. Unknown slugs get a real 404 from the server — never the SPA fallback
     with status 200.
  Build-time prerendering (a prerender script, react-snap, vite-ssg) is
  acceptable ONLY when the POST and DELETE handlers trigger the rebuild and
  deploy themselves (Architecture, rules 2–3) and the host answers
  not-yet-built /blog/* paths with 404, not the shell. Never acceptable: the
  article appearing only through a client-side fetch, head tags set only by
  client-side JavaScript, a sitemap.xml generated at build time.

### Blog list page (e.g. /blog)

- Server-rendered from the store, newest first by published_at: cover,
  title, excerpt, date. Each entry is a real `<a href="<article URL>">`
  (crawlers follow anchors, not click handlers). Paginate past ~20.
- The list page needs its OWN unique `<title>`, `<meta name="description">`
  and self-referencing canonical — never leave them empty or inherited.
- Paginated pages (page 2+) get `<meta name="robots" content="noindex,
  follow">` — they are navigation, not landing pages. Category pages, if
  you add them, follow the same rules as the list page.
- An unknown slug must return a real HTTP 404 status, not a rendered
  "not found" page with status 200 (soft-404). Single-page apps with a
  catch-all route fail this by default — add a server-side 404 for unknown
  article slugs.

### Article page (e.g. /blog/<slug>)

Body, top to bottom:
1. Optional breadcrumb (Home › Blog › title) as real links.
2. `title` as the only `<h1>` on the page.
3. Byline: author and `<time datetime="<published_at>">` (also show the
   updated date when updated_at is a day or more later).
4. Cover, when cover_url is set: `<img>` with `cover_alt` as alt text,
   explicit width/height (stored at upload, Step 4) and
   `fetchpriority="high"` — not lazy-loaded, it is usually the largest
   element on screen.
5. Key takeaways, when `key_takeaways` is non-empty, above the body:
   `<section class="key-takeaways"><h2>{key_takeaways_title}</h2><ul><li>…
   </li></ul></section>`. Items are plain text — HTML-escape them and keep
   their order; no title → render the list without a heading. content_html
   never repeats this block when the field is present.
6. The body: sanitized content_html, rendered as HTML inside the article
   container (see "Article body" and "Styling" below).
7. Optional: a few links to related articles from the store (same category
   or newest) and a link back to /blog.

Head:
- `<title>` from title — it arrives length-tuned, so keep it as delivered;
  if you append a brand suffix, keep the whole tag under ~60 characters.
- `<meta name="description">` from seo_description.
- A self-referencing canonical link — the SAME absolute URL the publish
  endpoint returns for this article.
- The FULL Open Graph set + Twitter Card: `og:title` (= title),
  `og:description` (= seo_description), `og:image` (= og_image),
  `og:type` = article, and `og:url` — which must EXACTLY match the
  canonical URL. Plus `<meta property="article:published_time">` from
  published_at and `twitter:card` = summary_large_image.
- Structured data, in the server HTML: build your own Article JSON-LD from
  the stored fields (headline, description, image, datePublished,
  dateModified, author, `mainEntityOfPage` = the canonical URL) and a
  BreadcrumbList (Home → Blog → article) with your real URLs. From
  `schema_jsonld`, keep ONLY the FAQPage node (when present), set its
  `@id` to `<canonical URL>#faq`, and output it next to yours; drop every
  other node. Seonix builds that @graph before your URL exists, so its
  Article, WebPage and BreadcrumbList nodes point at
  `https://<project domain>/<slug>/` and a `/category/<name>/` page your
  site may not have — rendering them verbatim publishes a second, conflicting
  set of URLs. Seonix's own blog does exactly this. Store `schema_jsonld`
  as received.
- Never add noindex to article pages.
- If the site publishes multiple languages, route by `lang` (e.g.
  /blog/<slug> for the default language, /<lang>/blog/<slug> for others),
  set `<html lang>` accordingly, and use `translation_key` to emit
  hreflang alternates between variants.

### Article body — what content_html contains

`content_html` is a body fragment: no `<html>`, `<head>`, `<h1>`,
scripts or styles. Seonix generates it as plain semantic HTML WITHOUT
classes or ids; when the user edits the article in the Seonix editor, a few
editor attributes can appear. Everything below can reach you — render and
style all of it:

- **Headings:** `<h2>` sections (many phrased as questions), `<h3>`
  sub-points, occasionally `<h4>`. Never `<h1>` from the generator; if one
  arrives (manual edit), demote it to `<h2>`.
- **Paragraphs:** short `<p>`. The first one is the lead answer — a
  self-contained definition AI engines quote; never hide or truncate it.
- **Inline:** `<strong>`, `<em>`, `<a>`; after edits also `<u>`,
  `<s>`, `<code>`, `<br>`.
- **Labelled callouts:** 1–3 paragraphs opening with a bold label, e.g.
  `<p><strong>Tip:</strong> …</p>` ("Good to know:", "Watch out:",
  "Rule of thumb:", "Example:" — in the article's language). Render as-is.
- **Lists:** `<ul>` / `<ol>` with `<li>` — every article has at least
  one, usually an ordered list of 3–7 steps. After edits, items can wrap
  their text in `<p>`.
- **Pull quote:** exactly one `<blockquote><p>…</p></blockquote>` — an
  editorial highlight without attribution. Style it as an inset, not as a
  cited quotation.
- **Tables:** 0–2 per article, `<table><caption>…</caption><thead><tr><th>…
  </th></tr></thead><tbody><tr><td>…</td></tr></tbody></table>`. The first
  header cell can be an empty corner `<th></th>`; cells hold short values.
  After edits the caption can arrive as a `<p>` right before the table, plus
  `<colgroup>`/`<col>`, `colspan`/`rowspan` and `<p>` inside cells.
- **Images:** 2–3 inline `<figure><img src alt loading="lazy"></figure>`,
  each after the first paragraph of an H2 section — no `<figcaption>`, no
  width/height, src on your own media host (Step 4). After edits: a bare
  `<img>`, possibly inside a `<p>`.
- **Links:** internal only — absolute URLs on the site's own origin (other
  articles, in the URL shape your publish endpoint returns, and other pages
  of the site), each with a `title` attribute equal to its href and no
  target. Seonix generates no external links; a manually added one carries
  `target="_blank" rel="noopener noreferrer"`. Keep every href as
  delivered.
- **FAQ:** the last section — an `<h2>` with a localized FAQ heading
  ("FAQ", "Häufige Fragen", "Часті питання", …) followed by 3–5 pairs of
  `<h3>Question?</h3><p>Answer</p>`. Keep questions and answers as visible
  text in the server HTML — they back the FAQPage JSON-LD, and FAQ markup
  whose questions are not visible on the page is flagged as spam. No
  JavaScript-only accordions.
- **Rare, from manual edits:** `<hr>`, `<pre><code>`,
  `style="text-align: …"` on paragraphs and headings, and editor class
  names (`seonix-table`, Tailwind utilities like `max-w-full rounded-lg`
  or `underline`).

Sanitize to exactly this set (Step 2, point 6) and remove everything else:
- Tags: `h2 h3 h4 p br strong b em i u s a ul ol li blockquote figure
  figcaption img table caption colgroup col thead tbody tfoot tr th td hr pre
  code` (`h1` → `h2`).
- Attributes: `a[href title target rel]`, `img[src alt width height
  loading decoding srcset sizes]`, `th/td[colspan rowspan scope]`,
  `ol[start reversed]`. Drop every `class`, `style`, `id`, `data-*`
  and `on*` attribute — editor classes would otherwise pick up the site's
  own CSS (on a Tailwind site `underline` and `rounded-lg` really apply),
  and an edited article would look different from an unedited one.
- URL schemes in href/src: http, https, mailto, tel.

At render time — not in the stored copy, which GET returns to Seonix —
wrap every `<table>` in `<div class="table-scroll">` so wide tables
scroll inside the column, and give every `<img>` whose src is in your
media table its width/height plus `decoding="async"`.

### Styling the article

Make it look native: inherit the site's font families, text and link
colors, container width and side gutters from its existing layout (reuse
its CSS variables or Tailwind theme tokens) — no new fonts, no CSS
framework. Wrap the takeaways and the body in one container (e.g.
`<div class="seonix-article">`, or the site's existing prose class) and
scope every rule to it, styling elements by tag — content_html carries no
classes, and nothing may leak into the rest of the site. Resets matter:
Tailwind's preflight and most CSS resets remove heading sizes, margins,
list bullets and table borders, so unstyled content_html renders as one
wall of text. Style every element explicitly, or use the site's typography
plugin (e.g. `prose`) if it already has one, tuned to these values.

Starter stylesheet — replace the `var(--site-…)` tokens with the site's
own colors:

```css
.seonix-article { max-width: 70ch; margin-inline: auto; font-size: 1.0625rem; line-height: 1.7; overflow-wrap: break-word; }
.seonix-article > * { margin: 0 0 1.25em; }
.seonix-article :is(h2, h3, h4) { line-height: 1.25; font-weight: 700; scroll-margin-top: 5rem; }
.seonix-article h2 { font-size: 1.6em; margin: 2em 0 .6em; }
.seonix-article h3 { font-size: 1.25em; margin: 1.6em 0 .5em; }
.seonix-article h4 { font-size: 1.05em; margin: 1.4em 0 .4em; }
.seonix-article :is(ul, ol) { padding-left: 1.5em; }
.seonix-article ul { list-style: disc; }
.seonix-article ol { list-style: decimal; }
.seonix-article li + li { margin-top: .4em; }
.seonix-article li > p { margin: 0; }
.seonix-article a { color: var(--site-link); text-decoration: underline; text-underline-offset: .15em; }
.seonix-article blockquote { margin: 2em 0; padding: .25em 0 .25em 1.25em; border-left: 3px solid var(--site-accent); font-size: 1.15em; font-style: italic; }
.seonix-article blockquote p { margin: 0; }
.seonix-article figure { margin: 2em 0; }
.seonix-article img { display: block; max-width: 100%; height: auto; border-radius: 8px; }
.seonix-article figcaption { margin-top: .5em; font-size: .875em; opacity: .75; text-align: center; }
.seonix-article .table-scroll { overflow-x: auto; margin: 1.75em 0; }
.seonix-article table { width: 100%; border-collapse: collapse; font-size: .95em; }
.seonix-article caption { caption-side: top; text-align: left; font-weight: 600; padding-bottom: .5em; }
.seonix-article :is(th, td) { padding: .6em .8em; border-bottom: 1px solid var(--site-border); text-align: left; vertical-align: top; }
.seonix-article thead th { font-weight: 600; background: var(--site-surface); }
.seonix-article code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: .1em .35em; border-radius: 4px; background: var(--site-surface); }
.seonix-article pre { overflow-x: auto; padding: 1em; border-radius: 8px; background: var(--site-surface); }
.seonix-article pre code { padding: 0; background: none; }
.seonix-article hr { border: 0; border-top: 1px solid var(--site-border); margin: 2.5em 0; }
.seonix-article .key-takeaways { margin: 0 0 2em; padding: 1.25em 1.5em; border-radius: 12px; background: var(--site-surface); border-left: 4px solid var(--site-accent); }
.seonix-article .key-takeaways h2 { font-size: 1.1em; margin: 0 0 .5em; }
@media (max-width: 640px) { .seonix-article { font-size: 1rem; } .seonix-article h2 { font-size: 1.4em; } }
```

Keep the site's own header, footer and page gutters around it (16 px or
more on phones); the page itself must never scroll sideways.

### Site-wide

- `<html lang>` on every blog page and `<meta name="viewport">` if you are
  creating a standalone layout (an existing site layout usually has both).
- Pick ONE slug form — with or without trailing slash — and 301-redirect the
  other, so every article has exactly one URL.
- **Sitemap:** /sitemap.xml is generated from the store on request (or
  revalidated by POST and DELETE) — never a file written at build time or
  committed to the repo. It lists /blog and every published article:
  `<loc>` = exactly the URL the publish endpoint returns (same host, same
  slash form), `<lastmod>` = updated_at (else published_at) in RFC 3339.
  No drafts, paginated pages, API or media URLs. If the site already has a
  static sitemap for its other pages, keep those entries but serve the file
  dynamically (static routes + store rows), or turn /sitemap.xml into a
  `<sitemapindex>` whose blog child (e.g. /sitemap-blog.xml) is dynamic.
  Serve it as `application/xml` and reference it from /robots.txt
  (`Sitemap: https://<site>/sitemap.xml`); robots.txt must not disallow
  /blog or the media path.
- An RSS feed at /blog/rss.xml is a nice-to-have; add it when cheap, from the
  store. If the site has no /llms.txt, adding one that links the blog is a
  small AI-search win (the Seonix audit checks for it); if it lists
  articles, generate that list from the store too.

## Step 7 — secret token

Generate the shared secret yourself: `sx_` + 48 hex chars from a
cryptographically secure RNG (e.g. `openssl rand -hex 24`). Store it in the
site's server-side secret store / env (e.g. `SEONIX_TOKEN`) — never in
client-side code, never committed to the repo. This is the Bearer token for
Steps 2-5.

## Step 8 — self-test before connecting

Run these against the DEPLOYED site from the outside (deploy first if you
have been working locally — Seonix verifies the connection by calling your
public endpoint), with plain `curl`: no browser, no JavaScript — that is
how crawlers and AI engines see the site. Behind a CDN, test the public
URLs, not the origin. Pick a random tag T (`openssl rand -hex 4`) so the
test article is unmistakable. Fix every failure, redeploy, and re-run from
the failing item.

1. **Verify probe:** `curl -X POST <endpoint> -H "Authorization: Bearer
   <token>" -H "X-Seonix-Verify: 1" -H "Content-Type: application/json"
   -d '{}'` → 200 JSON, nothing persisted (the GET list is unchanged).
2. **Auth:** a wrong token → 401 with a JSON body (never HTML) on POST,
   GET list, GET /{id}, DELETE and the media endpoint.
3. **Media:** upload a small PNG → 200 {url} on the real origin; the URL
   answers 200 with an `image/*` content type; uploading the same bytes
   again returns the same URL. Call that URL IMG.
4. **Publish:** save this as selftest.json (replace every T and IMG, and set
   published_at to now) and POST it with `-d @selftest.json`:
   ```json
   {
     "external_id": "seonix-selftest-T",
     "slug": "seonix-selftest-T",
     "lang": "en",
     "title": "Seonix self-test T",
     "excerpt": "Temporary article created by the integration self-test.",
     "seo_description": "Temporary self-test article T.",
     "key_takeaways": ["Takeaway one T", "Takeaway two T"],
     "key_takeaways_title": "Key takeaways",
     "author": "Self-test",
     "cover_url": "IMG",
     "cover_alt": "Self-test cover",
     "og_image": "IMG",
     "published_at": "2026-01-01T00:00:00Z",
     "content_html": "<p>Marker sentence T: this paragraph must be in the raw HTML.</p><h2>What does the self-test check?</h2><p>Server rendering, the sitemap and the read endpoints.</p><figure><img src='IMG' alt='Self-test image' loading='lazy'></figure><ul><li>First point</li><li>Second point</li></ul><blockquote><p>A pull quote that should look like an inset.</p></blockquote><table><caption>Self-test table T</caption><thead><tr><th></th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Rendering</td><td>Client</td><td>Server</td></tr><tr><td>Sitemap</td><td>Static</td><td>Live</td></tr><tr><td>Delay</td><td>Next build</td><td>Minutes</td></tr></tbody></table><h2>FAQ</h2><h3>Is this a self-test T?</h3><p>Yes, it is removed in the last step.</p><h3>Does it need JavaScript?</h3><p>No, the page must work without it.</p>",
     "schema_jsonld": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https://example.com/seonix-selftest-T/#webpage\",\"url\":\"https://example.com/seonix-selftest-T/\"},{\"@type\":\"FAQPage\",\"@id\":\"https://example.com/seonix-selftest-T/#faq\",\"mainEntity\":[{\"@type\":\"Question\",\"name\":\"Is this a self-test T?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Yes, it is removed in the last step.\"}}]}]}"
   }
   ```
   → 200 `{"id":"…","url":"…"}`: `id` is a JSON string, `url` is on the
   real origin from Step 0 and follows the site's article URL pattern. Call
   that URL U.
5. **Server-rendered HTML:**
   `curl -s -o /tmp/sx.html -w '%{http_code}' "$U"` → 200, and the raw
   file (grep it) contains:
   - a `<title>` with "Seonix self-test T" — not the site's or the blog's
     generic title;
   - exactly one `<h1` (`grep -o '<h1' /tmp/sx.html | wc -l` → 1), whose
     text is the title;
   - "Marker sentence T", "Takeaway one T", "Self-test table T",
     "Is this a self-test T?" and the IMG URL;
   - one canonical, `<link rel="canonical" href="U">` — exactly U, not
     /blog or the homepage; `og:url` exactly U; the meta description;
   - `application/ld+json` with your Article node for U and the FAQPage
     with `@id` U#faq — and no "example.com" anywhere (the
     publisher-computed URLs you were told to drop);
   - no `noindex`.
   A file that is only the app shell (an empty root element, a generic
   title, a canonical to /blog) fails — fix the rendering route in Step 6.
6. **Listed without a rebuild:** without redeploying or rebuilding anything
   yourself, poll every 30 s for up to 5 minutes until: /sitemap.xml contains
   `<loc>U</loc>` (when it is a `<sitemapindex>`, fetch every child
   `<loc>` and search those); the raw HTML of /blog links to U (absolute or
   root-relative `href`); the feed contains U if you built one.
7. **Update:** POST selftest.json again with only the title changed to
   "Seonix self-test T updated" → same id and url; within 5 minutes U's raw
   `<title>` and `<h1>` show the new title; the GET list holds exactly one
   entry with external_id "seonix-selftest-T".
8. **Read endpoints:** `GET <endpoint>` with the token → 200
   `{"data":[…]}` with the test article (string `id`, `external_id`,
   `slug`, `title`) plus every pre-existing post — as many entries as /blog
   lists across all its pages. `GET <endpoint>/<id>` → 200 `{"data":{…}}`
   with `content_html`; an unknown id → 404 JSON. Pre-existing posts come
   back with non-empty title + content_html.
9. **Existing pages:** one pre-existing article (if the blog had any) passes
   the item-5 checks with its own title, h1, body text and canonical. The
   blog list page has its own title, meta description and canonical; a
   made-up slug returns HTTP 404 (the status code, not a 200 "not found"
   page); the alternate slash form of U 301-redirects to U; page 2 of the
   list (if any) carries noindex, follow.
10. **Looks native:** open U in a browser or headless browser at desktop
    width and at 375 px: the site's header, footer, fonts and colors; body
    lines of ~65–75 characters; headings, lists, the pull quote, the
    takeaways box, the table and the FAQ styled; the table scrolls inside its
    wrapper and the page never scrolls sideways; images fit the column. If
    you cannot open a browser, report this item as not verified.
11. **Delete:** DELETE `<endpoint>/<id>` → 200; within 5 minutes and
    without a rebuild by you: U → 404 (or 410); U is gone from the sitemap
    (every child), from the /blog HTML and from the GET list;
    `GET <endpoint>/<id>` → 404 JSON. If you skipped Step 5, delete the row
    from the store directly and run the same checks — they test the
    store-driven pages, not the endpoint.
12. **Clean up:** remove the test image and anything else the test left;
    the GET list again equals the pre-existing posts.

Print the result as a checklist (one line per item, pass/fail, with the
evidence for items 5, 6 and 11 — status codes and the matching lines) for
the user. Do not continue to Step 9 while any item fails, and do not
describe the integration as done if you skipped an item — say which one and
why.

## Step 9 — connect the channel to the user's Seonix account

The user's agent (you) can register the channel directly — no manual copying.
Ask the user for a one-time connect code, but ONLY once Step 8 passes and
you are ready to send the request immediately: the code expires 30 minutes
after it is generated, and a code that expired while the site was still
being deployed is the usual cause of a 410 below.

> "Open https://app.seonix.ai, go to your project →
> Channels → Custom API → 'Connect with your AI agent' → Generate connect
> code, and paste the code here. No Seonix account yet? Start at
> https://seonix.ai/start — the free analysis creates your project."

The code looks like `sxc_` + 16 hex chars, lives 30 minutes, and is single
use. Then register the channel:

```
POST https://api.seonix.ai/api/public/agent-connect
Content-Type: application/json

{
  "code": "sxc_<paste from user>",
  "channel": {
    "name": "<site name>",
    "api_url": "https://<site>/api/seonix/articles",
    "api_token": "sx_<the token from Step 7>",
    "delete_url_template": "https://<site>/api/seonix/articles/{id}",
    "media_upload_url": "https://<site>/api/seonix/media",
    "lang": "<site's main language, e.g. en>",
    "author": "<team or brand name>"
  }
}
```

`api_url` is the one URL for POST publish, GET list and GET /{id}; there
is no separate read URL to configure.

Handle the response:
- `200 {"data":{"status":"connected", "dashboard_url": …}}` — done. Tell the
  user the channel is verified and connected, link the dashboard_url, and
  that articles will publish on the schedule set in Seonix. Then go to
  Step 11.
- `200 {"data":{"status":"verify_failed", "verify_detail": …}}` — the channel
  is saved but the probe against your endpoint failed. Fix the endpoint
  (verify_detail says what happened), then either ask the user to press
  "Verify" on the channel in the dashboard, or redeem a fresh code (codes are
  single use; redeeming again updates the same channel).
- `400 VALIDATION_FAILED` — fix the named field and retry with a fresh code.
- `404 AGENT_CODE_INVALID` / `410 AGENT_CODE_EXPIRED` / `409
  AGENT_CODE_USED` — ask the user for a new code; do not retry the same one.
- `409 INTEGRATION_ALREADY_EXISTS` — the project has a different platform
  connected (e.g. WordPress); the user must disconnect it in the dashboard
  or pick another project, then give you a new code.

## Step 10 — manual fallback

If the user cannot generate a code (or prefers pasting), print exactly this
block, filled in:

```
Seonix Custom API channel — paste these in the Seonix dashboard
(app.seonix.ai → your project → Channels → Custom API → Connect):

  Channel name:        <site name>
  Endpoint URL:        https://<site>/api/seonix/articles
  Bearer token:        sx_<the generated secret>
  Delete URL template: https://<site>/api/seonix/articles/{id}
  Media upload URL:    https://<site>/api/seonix/media
  Default locale:      <site's main language, e.g. en>
  Default author:      <team or brand name>

Click "Connect" — Seonix verifies the connection immediately.
```

## Step 11 — hand over to the user (always, after either Step 9 or 10)

Finish with this report, filled in with the real values. The user checks
the channel against it in Seonix (Channels → Custom API → Manage shows the
same fields) and keeps it for later. Print the full token only when the
channel is NOT connected yet (Step 10 route); otherwise name the env var it
lives in.

```
Seonix integration — summary

Built on this site:
  Blog list:         https://<site>/blog
  Article pages:     https://<site>/blog/<slug>  (server-rendered, verified with curl)
  Sitemap:           https://<site>/sitemap.xml  (generated from the article store)
  Going live:        <rendered per request | revalidated by the publish endpoint | deploy hook, ~N min>
  Existing posts exposed to Seonix: <N>

Channel settings (app.seonix.ai → <project> → Channels → Custom API → Manage):
  Endpoint URL:        https://<site>/api/seonix/articles
                       (POST = publish, GET = list, GET /{id} = read one)
  Bearer token:        <value, or "stored in SEONIX_TOKEN on the server">
  Delete URL template: https://<site>/api/seonix/articles/{id}
  Media upload URL:    https://<site>/api/seonix/media
  Default locale:      <lang>
  Default author:      <author>

Status: <connected and verified via connect code | not connected — paste the values above>
Self-test: <12/12 passed | list what was skipped and why>

Next steps in Seonix:
  1. Channels → the channel shows "connected"; press Verify if you change anything.
  2. Press "Import Articles" once — it pulls the <N> existing posts so the plan
     does not repeat their topics and new articles can link to them.
  3. Articles then publish on the schedule set in Seonix — nothing else to
     build on this site.
```

## Payload example (what Step 2 receives)

```json
{
  "external_id": "0b155cf3-7a3f-4a9e-9a51-1c9ef22ab1f2",
  "translation_key": "f4f8a3f1-6e56-4be4-8b8f-2b3f7f2d9c11",
  "slug": "how-to-choose-a-crm",
  "lang": "en",
  "title": "How to Choose a CRM",
  "excerpt": "A practical guide to…",
  "content_html": "<h2>…</h2><p>…</p>",
  "category": "Guides",
  "key_takeaways": ["First…", "Second…"],
  "key_takeaways_title": "Key takeaways",
  "author": "Acme Team",
  "cover_url": "https://<site>/media/ab12…f9.webp",
  "cover_alt": "Team comparing CRM dashboards",
  "seo_description": "Meta description text.",
  "og_image": "https://<site>/media/ab12…f9.webp",
  "published_at": "2026-08-14T09:30:00Z",
  "schema_jsonld": "{\"@context\":\"https://schema.org\",\"@graph\":[…]}"
}
```

Optional fields are omitted when empty — treat every field except
external_id, slug, lang, title and content_html as nullable.

## Read example (what Step 3 returns)

```json
GET /api/seonix/articles            → 200
{
  "data": [
    {
      "id": "42",
      "external_id": "0b155cf3-7a3f-4a9e-9a51-1c9ef22ab1f2",
      "slug": "how-to-choose-a-crm",
      "lang": "en",
      "title": "How to Choose a CRM",
      "excerpt": "A practical guide to…",
      "category": "Guides",
      "cover_url": "https://<site>/media/ab12…f9.webp",
      "cover_alt": "Team comparing CRM dashboards",
      "seo_description": "Meta description text.",
      "published_at": "2026-08-14T09:30:00Z"
    },
    {
      "id": "7",
      "external_id": null,
      "slug": "our-first-post",
      "lang": "en",
      "title": "Our first post",
      "excerpt": "Written long before Seonix…",
      "published_at": "2025-03-02T08:00:00Z"
    }
  ]
}

GET /api/seonix/articles/42         → 200
{ "data": { …same fields…, "content_html": "<h2>…</h2><p>…</p>" } }
```
