Content engine
Sites, business knowledge, the keyword planner, the article pipeline and publishing.
The engine turns a website into a daily stream of reviewed SEO articles. Everything lives in apps/api/src/modules/ as five modules — sites, knowledge, linking, keywords, articles — each with its schema, service, routes, jobs and tests. The dashboard pages are under apps/app/src/app/(app)/sites.
Flow
- Add the site. An organization is one website: its knowledge, calendar and articles stay together, and another website is another organization with its own plan.
POST /sitesstores the URL and queuessites.analyze: the homepage is read, the writer model derives the description, audiences, competitors and language, and three jobs follow. - Knowledge.
knowledge.extractreads up to 12 pages and turns them into facts in nine categories, each with its source URL and an on/off switch. Facts are the only claims the writer may make about the business; the reviewer checks against them. The crawl is read again every month (see Business knowledge below). - Link targets.
linking.detectreads the sitemap into targets the strategist links to. Prioritized targets and custom anchors go first. - Keyword plan.
keywords.planasks the writer for seed phrases per competitor and audience, enriches them with the search-data provider, scores them, lets the model pick formats, and schedules one keyword per day. - Articles.
articles.sweep(every 15 minutes) starts the day's keyword when the site hasautoGenerate.articles.writeruns research → strategist → writer → reviewer → link check and files a draft. Below the review threshold the writer gets one revision. - Publish.
articles.publishPOSTs the article to the site's webhook with its Bearer token. The receiving site decides whether that becomes a draft or a live post.
Business knowledge
The Knowledge section is what the engine knows about the business, and articles are checked against it. Facts are one-sentence claims in nine categories (how you work, policies & terms, offer & prices, products & services, credentials & proof, service area, what customers say, founder, stories), each with the page it was read from and an on/off switch; only facts that are on reach the writer. The page lists the categories with their counts, and the list can be searched (?q=), limited to facts that are on or off (?enabled=) or to one category (?category=), and sorted by group, newest or A–Z; a fact's text and category can be edited from its menu. GET /sites/{siteId}/facts/summary returns the counts without the list.
Sources are where the facts came from: the website crawl (automatic), pages added by URL and pasted texts, each with who added it, how many facts it produced, its status (reading, ready, failed) and when it was last read. POST /sources/{id}/reread reads one again and replaces its facts; "Re-read website" (POST /sites/{siteId}/facts/reread) rebuilds the crawl. The crawl is also read again every month: the daily knowledge.reread_sweep job queues it for every site whose crawl is more than 30 days old. Facts you added or edited by hand are not touched by a re-read; deleting a fact the crawl produced is final only until the next read, so turn one off instead to keep it out for good.
House style
When a site is analysed, the engine also reads up to three of its own posts (the sample URLs you give, else links found on the blog page) and writes a few lines on tone, person, sentence length, headings, examples and words to keep or avoid. The writer matches those notes on every article. You can edit them on the Website page.
Integrations
| Kind | What it needs | What happens |
|---|---|---|
webhook | URL and a Bearer token | Every article is POSTed in the payload below; the receiver decides draft or live. |
wordpress | Site URL, username, application password, post status | Posts are created through the WordPress REST API with the chosen status (draft by default) and updated in place after edits. |
ghost | Site URL, Admin API key, post status | Posts are created through the Ghost Admin API as drafts or published, with the featured image, and updated in place. |
webflow | Site API token, collection ID, field map, post status | Items are created in a CMS collection through the Webflow Data API, staged or published, and updated in place. |
shopify | Store domain, Admin API access token, blog ID, post status | Articles are created in a blog through the Shopify Admin API, hidden or published, and updated in place. |
"Test connection" pings a webhook, asks WordPress who the password belongs to, reads the Ghost site, the Webflow collection (and checks every mapped field exists) or the Shopify blog, without publishing anything. Secrets are encrypted at rest and never returned by the API. WordPress, Ghost, Webflow and Shopify are Beta until each has been run against a live site (see the integrations reference).
Publish to Webflow / Shopify / Next.js
Webflow. In Webflow, open Site settings → Apps & integrations → API access and generate a site token with CMS read and write and Sites publish permissions. Copy the collection ID of the blog posts collection (CMS → collection settings) and, optionally, the site ID. The field map says which collection field, by slug, receives each part of an article: title, slug and body (a rich text field) are required; summary, featured image, meta title and meta description are sent only when you name a field. The defaults match a new blog collection (name, slug, post-body, post-summary, main-image). With post status draft the item is staged in the CMS; with publish it is written and then published to the live site. The article keeps the Webflow item id, so an edit patches the same item. The body is sent as HTML, converted from the Markdown the same way as for WordPress and Ghost.
Shopify. In the Shopify admin, Settings → Apps and sales channels → Develop apps → create a custom app with write_content access, install it and copy the Admin API access token. Enter the store's myshopify.com domain and the blog's numeric id (the number at the end of the blog's address under Online Store → Blog posts → Manage blogs). Each article becomes a blog article with the slug as its handle, the HTML body, the meta description as the summary, the keyword as a tag, the featured image, and the meta title and description as the global title_tag and description_tag metafields that Shopify themes read for the page head. Post status draft keeps the article hidden until you publish it in Shopify; an edit updates the same article and leaves its visibility as you set it.
Next.js, Framer, Astro. These have no API to write content into, so they use the webhook kind. The Integrations page has a Recipes section with a copyable receiver for each: a Next.js route handler that checks the Bearer token, writes the Markdown with front matter into a drafts folder and revalidates the blog; an Astro API route that does the same into a content collection; and, for Framer, a small handler that stores the HTML and serves it as an RSS feed the Framer CMS can sync from. Answer the webhook with { "id": …, "url": … } and the article shows the page it became.
Playbooks, top-up and quota
A plan run can follow a programmatic playbook — alternatives, vs/comparison, best-of lists, use cases, integrations, pricing, glossary — which changes how seeds are proposed and tags every keyword with that source, so coverage shows how much of each series exists. keywordInstructions on the site steers every run. With autoTopUp the sweep plans more keywords whenever fewer than 25 are scheduled. The planner holds at most 60 scheduled keywords per website (the header shows x / 60 scheduled): adding, scheduling or planning past that answers 409 PLANNER_FULL, and the top-up stops there. On the calendar a keyword without an article can be dragged to another future day, or moved from its row menu ("Move to…", the next 30 publishing days); a day that already carries articlesPerDay keywords refuses the move (409 DAY_FULL). Plans carry a monthly article quota (limits.articles); when it is used, scheduled keywords wait for the next month.
Research keywords chat
Research keywords on the Content Planner opens a chat with the strategist. It knows the business, what is already planned and, with Search Console connected, the queries the site appears for today. Ask for keywords you can realistically rank for, long-tail phrases with little competition, comparisons with competitors or the gaps in the plan; each answer lists keywords with their format, search volume, difficulty and intent (when a search-data provider is configured), marks the ones already in the plan, and points out queries where a page of yours already ranks. Add a keyword with one click, or ask the strategist to schedule them and they land on the next free publishing days. The conversation stays in the browser; nothing is stored.
Schedule and translations
publishDays limits the calendar to chosen weekdays (Outrank's "8 articles a month on two weekdays"), articlesPerDay allows up to three a day and scales the top-up queue with it. Write articles ahead (preCreateDays, up to a week) writes each draft that many days before its scheduled day so there is time to read it; with automatic publishing on, the draft shows when it publishes and goes out at 09:00 UTC on the day, unless someone publishes it by hand first. translations lists extra languages: every finished draft gets one translated copy per language, written by the writer model with the structure, links and citations kept. Each copy is its own article, so an editor can publish or drop one language alone; copies count against the monthly quota, and the webhook payload carries language and translated_from.
Pausing a website
POST /sites/{id}/pause stops everything for the website until POST /sites/{id}/resume: the article sweep (and its top-up), the improvements, citations, Search Console and knowledge sweeps skip a paused site, a publish that was waiting for its day stays a draft, and writing, publishing or refreshing by hand answers 409 SITE_PAUSED. Keywords keep their dates and articles already being written finish as drafts. pausedAt on the site says since when; the dashboard shows a notice on every site page with the way to resume, and resuming queues the drafts whose publish moment passed. Both calls are audited (site.paused, site.resumed) and need sites:write.
Images, charts and video
Each finished draft can carry a cover, pictures inside the body, one chart and one video. What you get depends on the keys you set; every piece is optional and a failure in one never fails the article.
Cover. With an image model configured (IMAGE_GENERATION_PROVIDER=openai, using your OPENAI_API_KEY), the cover is generated in the site's featured-image style (Articles → Settings → Images): match follows the article image style, signature and editorial collage put the article title on the image, custom uses your own style prompt. Without an image model, an Unsplash access key still picks a landscape photo from the title and keyword, keeps the photographer credit (and hits Unsplash's download endpoint, as its terms ask). Either way the cover travels as image_url, image_alt and image_credit in the webhook payload, is uploaded to the WordPress media library as the post's featured image, or set as Ghost's feature image. Turn it off per site with the featured image setting.
Body images. With an image model, Images per article (0–5) asks the writer where a picture helps and what it shows, generates each in the chosen style (sketch, watercolor, realistic, illustration, brand text in your brand color, or a custom prompt) and places it right under that section's heading as ordinary Markdown. Generated files are stored with your file storage and served at a public, cacheable URL (/api/v1/assets/{id}.png) that your CMS can hotlink; the id is random, so the URL is the only way to reach it.
Infographics. When Infographics is on and the article states numbers with sources (a table of figures, or three cited statistics), one of the image slots becomes a chart: a bar, line or donut built only from the numbers already in the text, drawn as an SVG in your brand color with a title and a source line.
Video. With a YouTube Data API key (YOUTUBE_API_KEY) and YouTube video on, the engine finds one embeddable, medium-or-longer video for the article title, preferring your own channel when you name it, and embeds it after the second section with the privacy-friendly youtube-nocookie.com player.
On the article page the aside lists every generated image with a Regenerate button (optionally with a short description of what to change) and a summary line such as "generated cover, 2 images, 1 chart, video embedded". Regenerating swaps the picture in the Markdown; the API is POST /articles/{id}/images/{assetId}/regenerate.
Refreshing articles
Outrank's Improvements need Search Console; the engine refreshes without it. Refresh an article from its page, or set refreshAfterDays on the site and the sweep does it for published articles past that age. The keyword is researched again, the writer updates the existing text (same title, structure, links and voice; new numbers and sources; a missing question answered; unsupported claims removed), the reviewer scores it, and the result waits as a draft. Publishing it sends an update to the same post.
Editing with AI and exporting
Open an article and choose Edit. Select a passage in the body and the toolbar above it offers Improve, Shorten (about 60 % of the words), Expand (about 150 %), Simplify, Fix grammar (errors only) and Custom… for your own instruction; the writer model rewrites only that passage in the site's voice, keeps its links and facts and adds no new numbers. Rewrite with AI opens a chat beside the article: describe a change ("shorten the introduction", "add a short FAQ", "add a comparison table") or ask a question. When the answer carries a rewritten article it appears as a proposal with Apply and Discard; Apply puts it in the editor. Nothing is sent to your site until you save, and a save after an AI action leaves a "Rewritten with AI" or "Edited with AI" line in the history. The conversation stays in the browser. Export downloads the article as <slug>.md with a front-matter block (title, description, keyword, date) or copies the Markdown; both work on the text on screen, saved or not. The same two actions are MCP tools, article_ai_selection and article_ai_rewrite.
AI citations
The engine writes the questions a buyer would ask an AI assistant about this category (never naming the brand), asks the reviewer model each one as a plain assistant every two days, and records whether the business or a listed competitor was named. The dashboard shows the share of questions that name the business over the last seven days and which competitors appear. Answers come from the model you configured, so treat the numbers as a trend, not as ChatGPT's own behaviour.
Google Search Console
The Improvements tab reads how the website already performs in Google search and names the one thing to do about each page. It needs a Google account with access to the site's Search Console property, connected once under Articles → Integrations.
What the operator sets up once, in Google Cloud:
- A project with the Google Search Console API enabled (APIs & Services → Library).
- An OAuth consent screen that requests the scope
https://www.googleapis.com/auth/webmasters.readonly(plusopenidandemail, which only identify the account). While the app is in Testing, add the Google accounts that will connect as test users; publish it when customers connect their own. - An OAuth client of type Web application with the authorized redirect URI
<API_URL>/api/v1/search-console/callback(for examplehttps://api.example.com/api/v1/search-console/callback). Its id and secret becomeGOOGLE_CONNECT_CLIENT_IDandGOOGLE_CONNECT_CLIENT_SECRETinapps/api/.env(or the Compose.env). Unset, the API falls back to the Google sign-in client (AUTH_GOOGLE_CLIENT_*), which then needs the same redirect URI.
What happens when a customer connects. POST /sites/{siteId}/search-console/connect returns Google's consent URL; the state is HMAC-signed with a key derived from AUTH_SECRET and expires after ten minutes. Google lands on the public callback, which exchanges the code, stores the refresh token encrypted with the search-console secret box and sends the browser back to the Integrations page. The customer then picks the property (sc-domain:example.com or a URL prefix) from the ones the grant can read; unverified properties are left out. Choosing one queues the first pull.
What gets pulled. search_console.pull reads the last 28 finalised days (Google reports a day about three days late, so the window runs from 31 to 3 days ago) in seven-day chunks: performance by date and page into search_pages_daily, and by date, query and page into search_queries_daily, where each query keeps the page Google showed most. The first pull of a connection also reads pages back to 93 days ago, so the 90-day view below has history from day one; later pulls revise only the 28-day window, and nothing pulled is ever deleted. Rows are upserted, so retries and the daily search_console.sweep revise recent days without duplicates. Tokens never enter a job payload, a log or a response; GET /sites/{siteId}/search-console/queries exposes the top queries for the content planner.
How Improvements ranks pages. Each page's 28-day clicks, impressions, CTR and impression-weighted position are aggregated; pages under ten impressions are ignored. The issue is checked in this order: declining (position slipped two or more places, or clicks fell 30%, between the two halves of the window), page_two (position 11–20), low_ctr (position 1–10 with a CTR under half of what that position usually earns), striking_distance (position 4–10); an article of ours without one of those is checked for content_gap (queries with ten or more impressions whose words the article never uses) and thin (under 900 words past position 5). Pages in the top three with a healthy CTR, or beyond position 20, are not listed. Each row carries a one-line detail with the numbers behind the issue. Pages are ordered by impressions × (1 − CTR) × positionFactor, where positions 4–10 count fully, 11–20 at 0.9, the top three at 0.2 and anything past 20 much less. A page whose URL matches an article's published URL, or whose last path segment is an article's slug, is marked as ours. The action is rewrite_title for a low CTR (the dashboard asks POST /sites/{siteId}/improvements/suggest for a meta title and description only when you click Suggest title), refresh for an article of ours (the existing POST /articles/{id}/refresh), or write_article when none of our articles ranks for the page's queries (Plan this topic adds the page's strongest query to the planner).
Weekly improvements. With Search Console pulling and Schedule improvements on (Articles → Settings → Improvements), a daily sweep fills the site's improvement days (up to two a week) for the coming week with the best candidates among our own articles published at least 30 days ago and not improved in the last 60, one per day. On its day the article is researched again and rewritten with a brief for its issue; the rewrite must keep every heading, link and image, must not shrink and must carry no filler phrases (one retry with the failures spelled out), then the reviewer model scores it against ARTICLE_REVIEW_THRESHOLD. What passes lands in the Improvements inbox section by section, with the old and new text, the writer's reason for each change, and the new title and meta. The reviewer accepts or rejects each part, edits any new text inline, and approves: the accepted parts are written into the article (an improved line in History) and the update goes to the CMS. Auto-push skips the review for proposals that passed every check. A rejected or failed improvement leaves the article untouched. Two weeks after the push the page's clicks and position are compared with the two weeks before; a gain of 10% in clicks or one position is a win, listed on Home. Improve now on any candidate of ours writes a proposal outside the weekly plan; Schedule improvements fills the week on demand.
Cannibalization. The Cannibalization tab on Improvements (GET /sites/{siteId}/search-console/cannibalization) lists the queries where two or more of your pages each earned 20 or more impressions in the last 28 days, up to 50 groups by combined impressions. Each group shows every competing page with its clicks, impressions and position, marks the winner (most clicks, then the best position) and suggests one fix: canonical when the pages are one article or one path under several URLs (a trailing slash, a parameter), merge when every page is an article of yours (fold the weaker into the winner and redirect it), otherwise differentiate (give the weaker page a title and H1 aimed at a different intent). Open both opens the two strongest pages side by side: your articles open in the editor, other pages on the live site.
URL inspection. On a published article's page, Inspect in Google (POST /sites/{siteId}/search-console/inspect with { url }) asks Google's URL Inspection API how it sees the page and shows the verdict (on Google, partly indexed, not on Google, excluded), the coverage state in Google's words, the last crawl, the indexing, fetch and robots.txt states, Google's canonical against yours, and the mobile usability and rich results verdicts. The same grant serves it; nothing else to set up. Answers are kept for 24 hours in search_url_inspections and served with cached: true; Refresh (?refresh=true) asks Google again. Google's own quota is small, so a site gets 20 calls a day; past that the API answers 429 INSPECTION_LIMIT and cached answers keep working.
Best articles. The Best articles card on Home (GET /sites/{siteId}/search-console/best-articles?window=28|90) ranks your published articles by clicks in the window with impressions, CTR, impression-weighted position, their top three queries and the change in clicks against the window before (clicksDeltaPct, "New" when the previous window had none). A page counts for an article when its URL matches the published URL or its last path segment is the article's slug, so an article under two URLs is one row. The 90-day view needs the backfill described above; a site connected less than 90 days ago shows what has been pulled so far.
The CLI and MCP server expose the three as gsc_cannibalization, gsc_inspect_url (url, optional refresh) and gsc_best_articles (optional window: 28 or 90) in the gsc toolset.
Removing the connection deletes the grant and keeps the pulled rows. The integration is beta until verified end to end against a live property (see INTEGRATIONS.md).
Backlinks
"Publish & Earn" is a link exchange between the websites on your platform. A website joins from Backlinks → Overview; the model derives five to ten topic tags from its description, audiences and the facts about what it sells and how it works (refresh them after those change), and the keyword-data provider looks up its domain rating (shown as a badge in the header, refreshed weekly; without a provider it stays unknown and trades as 10). From then on, when the strategist plans an article it may add one partner link: a page on another member's site, in the same language, in another organization, not among the site's competitors, with the best topic overlap. Ties go to the member that received the fewest live links in the last 30 days, so links spread. The link is planned while the article is written, live once it is published (with the article's URL) and removed if the article is deleted. Leaving the exchange keeps the links a site already gave (they are in published articles) and stops new ones.
Backlink credits pay for links: one credit is one point of domain rating. Joining grants 10 credits and every member holding fewer than 30 receives 3 free a day. When a link goes live, the giving site earns its domain rating in credits and the receiving site pays the same amount; a site is only picked as a receiver when it can pay for a link from the giver, and a balance never goes below zero. The Backlink credits card shows the balance and the activity (joined, daily, gave a link, received a link).
Settings on the overview: Min domain rating (any, 5, 10, 20, 30, 40 or 50) means only sites rated at least that link to you; Link targeting picks and orders the pages, from your link targets, that receive partner links in turn. With none chosen, your highest-priority link target receives them. The API is PATCH /sites/{siteId}/backlinks/membership with minDomainRating and targetPages (link-target ids), and GET /sites/{siteId}/backlinks/credits for the ledger.
Earned lists the links placed to this website with the giving site's name and host only, never its organization. AI-cited lists reads the last 30 days of AI citation checks and aggregates the competitors, domains and "best X / top 10 X" roundups the assistants named instead of the site. Best backlinks is a planned add-on for hand-picked placements outside the platform; the page shows example tiers only and takes no orders.
Webhook payload
{
"event_type": "publish_articles",
"timestamp": "2026-11-01T09:00:00.000Z",
"data": {
"articles": [
{
"id": "01J…",
"title": "…",
"content_markdown": "…",
"content_html": "…",
"meta_description": "…",
"slug": "…",
"image_url": null,
"tags": ["…"],
"created_at": "2026-11-01T09:00:00.000Z"
}
]
}
}An edit after publishing sends update_article with a single article. The payload matches the common article-webhook shape, so a receiver written for another vendor works unchanged.
Configuration
| Variable | Meaning |
|---|---|
AI_WRITER_PROVIDER, AI_WRITER_MODEL | The model that researches, plans and writes. anthropic or openai. |
AI_REVIEWER_PROVIDER, AI_REVIEWER_MODEL | A different model family that scores each draft. |
ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL | Keys. openai means any OpenAI-compatible API. |
KEYWORD_DATA_PROVIDER | none or dataforseo, with DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD. |
ARTICLE_REVIEW_THRESHOLD | Score (0–100) a draft must reach. Default 70. |
IMAGE_PROVIDER, UNSPLASH_ACCESS_KEY | none, or unsplash for a credited stock cover when no image model is configured. |
IMAGE_GENERATION_PROVIDER, IMAGE_GENERATION_MODEL | none, or openai (with OPENAI_API_KEY) to generate covers, body images and brand-text images. |
YOUTUBE_API_KEY | A YouTube Data API v3 key; set it to embed one relevant video per article. |
GOOGLE_CONNECT_CLIENT_ID, GOOGLE_CONNECT_CLIENT_SECRET | The Google OAuth web client for Search Console connections; falls back to AUTH_GOOGLE_CLIENT_*. |
Without a keyword-data provider the planner still runs; keywords are scored on the model's own estimates and marked as such. Provider and model names never appear in an API response.
Scopes
API keys and members use sites, knowledge, keywords, articles (with publish), linking, citations, backlinks (read for the overview, credits and lists, write to join, leave, refresh topics or change the settings) search-console (read for the candidates and queries, manage to connect, choose a property, pull or disconnect; connecting itself is browser-only) and improvements (read for the inbox and wins, write to schedule, start, review, approve or reject). Members hold all of them except search-console:manage; viewers read.