Using Polaris
Using with AI agents
How to let an AI agent publish through Polaris CMS safely: API keys, the list, get, save workflow, idempotency by slug, images, translations and what agents must not do.
Polaris was built for a workflow where an agent drafts, a person reviews, and the site publishes on schedule. The API is deliberately small: one endpoint for posts with seven actions, one for uploads. Everything an agent needs fits in a short brief.
Give the agent a key#
- Sign in at
/studio, open API Keys and create a key with a name that says who uses it (writer agent,nightly links job). - Copy it now: it is shown once and only its SHA-256 hash is stored.
- Hand it to the agent as an environment variable (for example
CMS_API_KEY), never in a prompt that gets logged or committed.
A key can read and write posts, upload and delete images, take and restore backups and ping IndexNow. It can't create, list or revoke keys, so a leaked key can't lock you out or mint more keys. Revoke it in the admin and it stops working at once. Use one key per agent, so you can revoke one without stopping the others.
The workflow: list, get, save#
1. Look before writing#
# Live posts: internal-link targets with their URLs and focus keywords
curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $CMS_API_KEY" \
-H "Content-Type: application/json" -d '{"action":"list","lang":"en"}'
# Every post, drafts and scheduled too: to find an existing post by slug
curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $CMS_API_KEY" \
-H "Content-Type: application/json" -d '{"action":"list-all","lang":"en"}'
list is what the agent links to: only live posts, each with url and focusKeyword. Linking to a draft from list-all would create a broken link until the draft goes live.
2. Read the full post before changing it#
curl -s -X POST "$SITE/api/cms/posts" -H "Authorization: Bearer $CMS_API_KEY" \
-H "Content-Type: application/json" -d '{"action":"get","id":12}'
3. Save the whole post#
save replaces the post. Fields that are left out go back to their defaults: an update that sends only title and content wipes the excerpt, tags, categories, featured image and the rest. So an edit is always: get, change the fields, send the complete post back with its id.
// Node 22+: update one field without losing the others
const api = (body) =>
fetch(`${process.env.SITE}/api/cms/posts`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.CMS_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(async (r) => {
const data = await r.json();
if (!r.ok) throw new Error(`${r.status}: ${data.error}`);
return data;
});
const { post } = await api({ action: "get", id: 12 });
await api({ action: "save", post: { ...post, excerpt: "A sharper excerpt." } });
Sending back the object from get works: extra read-only fields such as seoScore, readingTime and url are ignored.
Idempotency by slug#
A post is identified by its slug within its language. Creating a post whose slug already exists is refused with 409, so a create that is retried after a timeout can't produce a duplicate. Build the agent around that:
- Decide the slug yourself (from the focus keyword) and send it explicitly.
- Before creating, check
list-allfor that slug. If it exists,getit and update it byidinstead. - If a
savefails with a network error, don't blindly retry: checklist-allfor the slug first. If the post is there, the first call worked. - On
409, treat it as "already exists": fetch it and decide whether to update.
Images#
Upload first, then reference the returned URL:
curl -s -X POST "$SITE/api/cms/upload" -H "Authorization: Bearer $CMS_API_KEY" \
-F "file=@chart.png" -F "alt=Organic clicks per month, Sep to Nov"
# → {"url":"/media/<id>/chart.webp", …}
Then use it as featuredImage and in the body as a figure: . Images from other hosts are blocked by a strict CSP, and an image with alt text in the body is one of the 11 SEO checks.
Translations (newest version)#
Publish the first language, then save the translation with translationOf set to the first post's id:
{
"action": "save",
"post": {
"lang": "pl",
"translationOf": 12,
"title": "Jak statyczna strona w Astro utrzymuje LCP poniżej sekundy",
"category": "building",
"content": "## Budżet\n\n…",
"status": "scheduled",
"publishedAt": "2026-11-20T08:00:00+01:00"
}
}
The two are paired, and the hreflang links appear once both are live.
Scheduling a series#
Save each post as scheduled with its own publishedAt (with a time zone offset). Each one goes live at its time on its own; no cron job is needed. To move a date, use set-status with a new publishedAt instead of resending the whole post.
What agents must not do#
- Don't send partial posts to
save. It replaces the post. Alwaysgetfirst. - Don't publish without review unless the owner said so. Save as
draft(the default) orscheduled, and let a person read it in the admin preview. - Don't delete posts, images or backups unless asked. A deleted image breaks every post that shows it.
- Don't write raw HTML. It is dropped on render. Use Markdown,
##and###for sections, and no#heading (the title is theh1). - Don't hotlink images. Upload them.
- Don't link to drafts. Take internal links from
list, notlist-all. - Don't invent categories. Unknown keys are dropped and the post falls back to the default category; read the allowed keys from the site's docs or from existing posts.
- Don't backdate silently.
publishedAtin the past on apublishedpost rewrites its date in feeds and the sitemap. - Don't loop on errors.
4xxmeans the request is wrong; fix it. Retry only5xx, with a backoff. - Don't put the key anywhere public: not in the post, a commit, a log or a client-side script.
A brief for your agent#
Paste this into the agent's instructions and fill in the blanks:
You publish to a Polaris CMS blog at $SITE through its API.
Auth: header "Authorization: Bearer $CMS_API_KEY" (from the environment; never print it).
All calls: POST $SITE/api/cms/posts with JSON {"action": ...}. Uploads: POST $SITE/api/cms/upload (multipart: file, alt).
Before writing: call list (internal-link targets) and list-all (to check the slug is free).
To edit: get the post, change fields, save the COMPLETE post with its id. save replaces everything.
New posts: status "draft" unless told otherwise. Set slug, focusKeyword, excerpt, metaDescription (50-160 chars),
category (one of: ...), and a featured image you uploaded. Use ## / ### headings, no raw HTML, no # heading.
Aim for the SEO checks: keyword in title, meta, slug, first paragraph and one subheading; 0.5-2.5% density;
600+ words; title <= 60 chars; one internal link from list; one image with alt text in the body.
On 409: the slug exists, fetch it instead of creating. Retry only 5xx. Never delete anything.Open source under MIT. Public release on GitHub and npm in preparation. Roadmap.