Node SDK
Publish, manage, and deliver Cli Blog content from Node.js with the first-party SDK.
@cli-blog/node is the first-party Node.js client for the Cli Blog API. It is ESM-first, requires Node.js 20 or newer, uses native fetch, Blob, and FormData, and adds no production dependencies.
Use the SDK in servers, route handlers, scripts, build jobs, CI, CLIs, and trusted agent environments. Browser code should read published content through the REST API with a public key; do not bundle this Node package or a private key into a client application.
Install and create a client
npm install @cli-blog/nodeimport { CliBlog } from "@cli-blog/node";
const apiKey = process.env.CLI_BLOG_API_KEY;
if (!apiKey) throw new Error("CLI_BLOG_API_KEY is required");
const blog = new CliBlog({ apiKey });Use a public key for published-content delivery reads. Use a narrowly scoped private key for drafts, publishing, uploads, and other content changes. See API keys for browser and server boundaries.
Posts
Posts are Markdown documents with workflow, author, category, tag, media, locale, and SEO fields. Omitted status values default to draft on create.
posts.list()
Returns one cursor or numbered page. Use fields to select field groups and include to embed related resources.
const posts = await blog.posts.list({
status: "published",
locale: "en-US",
fields: ["summary", "seo"],
include: ["authors", "categories", "tags", "media"],
limit: 20,
});Field groups add post fields: summary for list metadata, content for Markdown and the generated table of contents, seo for search and social metadata, workflow for editorial state, and metadata for custom metadata. Includes add related objects: authors, categories, tags, media, and translations.
posts.paginate()
Returns an async iterator that follows cursor pages for you. Use it for synchronization and exports instead of requesting an oversized page.
for await (const post of blog.posts.paginate({
status: "published",
locale: "en-US",
limit: 100,
})) {
console.log(post.id, post.slug);
}posts.get()
Retrieves one post by ID or locale-scoped slug. Pass field groups and includes when the page needs more than the default response.
const post = await blog.posts.get("release-notes", {
locale: "en-US",
fields: ["summary", "content", "seo"],
include: ["authors", "media"],
});posts.related()
Returns published posts in the source post's locale. Shared tags, categories, and authors rank first, and the newest published posts fill any remaining positions. The response contains normal post objects and does not expose internal ranking scores.
const related = await blog.posts.related("release-notes", {
locale: "en-US",
limit: 4,
fields: ["summary"],
include: ["authors", "media"],
});The source post and its translations are excluded. The default limit is 4, the maximum is 12, and after accepts next_cursor when another page is needed.
posts.create()
Creates a draft, scheduled post, or published post. title is required; Markdown belongs in body_markdown.
const draft = await blog.posts.create({
title: "July release notes",
body_markdown: "## What changed\n\nA concise summary of the release.",
locale: "en-US",
status: "draft",
author_profile_ids: [author.id],
category_ids: [category.id],
tag_ids: [tag.id],
});posts.update()
Updates editable fields or sets status directly. Send expected_version when concurrent edits are possible so a stale writer receives a 409 instead of overwriting newer work.
const reviewed = await blog.posts.update(draft.id, {
excerpt: "The changes shipping in July.",
expected_version: draft.version,
});posts.publish()
Convenience helper for an update with status: "published". The post status field remains the underlying workflow model.
const published = await blog.posts.publish(reviewed.id, {
expected_version: reviewed.version,
});posts.schedule()
Schedules publication at an ISO 8601 timestamp and sets the post status to scheduled.
const scheduled = await blog.posts.schedule(
reviewed.id,
"2026-07-21T16:00:00.000Z",
{ expected_version: reviewed.version },
);posts.delete()
Removes a post through the API. Use a private key with post delete permission, and require confirmation in human or agent workflows.
await blog.posts.delete(draft.id);Revisions and slug redirects
Revisions preserve earlier post versions. Slug redirects let a delivery layer resolve an old slug after a post slug changes.
posts.revisions.list()
Returns one cursor or numbered page of revision summaries for a post.
const revisions = await blog.posts.revisions.list("release-notes", {
locale: "en-US",
limit: 20,
});posts.revisions.paginate()
Follows revision cursors and yields each revision summary.
for await (const revision of blog.posts.revisions.paginate("release-notes", {
locale: "en-US",
})) {
console.log(revision.id, revision.version);
}posts.revisions.get()
Retrieves the saved content for one revision.
const revision = await blog.posts.revisions.get(
"release-notes",
revisions.data[0]!.id,
{ locale: "en-US" },
);posts.slugRedirects.get()
Looks up the redirect target for an old, locale-scoped post slug.
const redirect = await blog.posts.slugRedirects.get("old-release-notes", {
locale: "en-US",
});
console.log(redirect.to_slug, redirect.status_code);Authors
Authors are public byline profiles. Create them before assigning their IDs through author_profile_ids.
authors.list()
Returns one cursor or numbered page of authors.
const authors = await blog.authors.list({ limit: 20 });authors.paginate()
Yields every author by following cursor pages.
for await (const author of blog.authors.paginate({ limit: 100 })) {
console.log(author.public_name);
}authors.get()
Retrieves one author by ID or slug.
const author = await blog.authors.get("maya-chen");authors.create()
Creates a public author profile. public_name is required.
const author = await blog.authors.create({
public_name: "Maya Chen",
bio: "Product notes from the Cli Blog team.",
website_url: "https://example.com/authors/maya-chen",
});authors.update()
Updates an author profile by ID or slug.
await blog.authors.update(author.id, {
bio: "Release notes, engineering stories, and field guides.",
});authors.delete()
Deletes an author profile with a private key that has author delete permission.
await blog.authors.delete(author.id);Categories
Categories provide structured navigation and can have up to two child levels beneath a root category. Localized variants use the same shared category concept.
categories.list()
Lists categories for a locale. Pass include: ["translations"] to add linked translation summaries.
const categories = await blog.categories.list({
locale: "en-US",
include: ["translations"],
limit: 100,
});categories.paginate()
Yields categories across cursor pages.
for await (const category of blog.categories.paginate({ locale: "en-US" })) {
console.log(category.slug);
}categories.get()
Retrieves one category by ID or locale-scoped slug.
const category = await blog.categories.get("engineering", { locale: "en-US" });categories.create()
Creates a category. Use parent_taxonomy_term_id for a child category and translation_of_id for another locale.
const category = await blog.categories.create({
name: "Engineering",
locale: "en-US",
description: "How the product is built.",
});categories.update()
Updates a category by ID or locale-scoped slug.
await blog.categories.update(category.id, {
description: "Architecture, infrastructure, and product engineering.",
});categories.delete()
Deletes a category term without deleting posts that referenced it.
await blog.categories.delete(category.id);Tags
Tags are flat, flexible topic labels. Like categories, they support localized names, slugs, descriptions, and SEO fields.
tags.list()
Lists tags for a locale, with cursor or numbered pagination.
const tags = await blog.tags.list({ locale: "en-US", limit: 100 });tags.paginate()
Yields tags across cursor pages.
for await (const tag of blog.tags.paginate({ locale: "en-US" })) {
console.log(tag.name);
}tags.get()
Retrieves one tag by ID or locale-scoped slug.
const tag = await blog.tags.get("release-notes", { locale: "en-US" });tags.create()
Creates a tag. Use translation_of_id to link another locale to the same topic.
const tag = await blog.tags.create({
name: "Release notes",
locale: "en-US",
});tags.update()
Updates a tag by ID or locale-scoped slug.
await blog.tags.update(tag.id, { description: "Product launch updates." });tags.delete()
Deletes a tag term without deleting posts that referenced it.
await blog.tags.delete(tag.id);Media
Media is upload-first. The API stores the file and returns the generated URL and metadata; it does not accept arbitrary remote-URL registration.
media.list()
Returns one cursor or numbered page of uploaded assets.
const assets = await blog.media.list({ limit: 20 });media.paginate()
Yields assets across cursor pages.
for await (const asset of blog.media.paginate({ limit: 100 })) {
console.log(asset.id, asset.original_filename);
}media.get()
Retrieves one media asset by ID.
const asset = await blog.media.get("media_123");media.upload()
Uploads a Blob and optional alt text, caption, and metadata.
import { readFile } from "node:fs/promises";
const file = new Blob([await readFile("release-cover.png")], {
type: "image/png",
});
const cover = await blog.media.upload({
file,
filename: "release-cover.png",
alt_text: "Cli Blog release overview",
caption: "The July release at a glance.",
});media.update()
Updates media metadata without replacing the stored file.
await blog.media.update(cover.id, {
alt_text: "July Cli Blog release overview",
});media.delete()
Deletes a media asset. Unlink attachments from active content before removal.
await blog.media.delete(cover.id);Locales
Locale values are curated BCP 47 tags. Content does not fall back implicitly between locales.
locales.list()
Returns the available locale records as an array.
const locales = await blog.locales.list();
console.log(locales[0]);
// { tag: "en-US", name: "English (United States)", ... }Sitemap
The sitemap resource returns XML text for published, indexable posts.
sitemap.get()
Retrieves sitemap XML. Use locale to select one language, or omit it to include every published, indexable localized URL. The sitemap protocol caps one document at 50,000 URLs.
const sitemapXml = await blog.sitemap.get({
locale: "en-US",
limit: 100,
});Feed
The feed resource returns RSS XML for published posts.
feed.get()
Retrieves feed XML for a locale and item limit.
const feedXml = await blog.feed.get({
locale: "en-US",
limit: 20,
});Browser delivery with REST
The Node SDK targets Node.js 20+. A browser can read published content directly with fetch and a public key:
const response = await fetch(
"https://api.cli-blog.com/v1/posts?status=published&fields=summary&include=authors",
{
headers: {
"x-api-key": import.meta.env.PUBLIC_CLI_BLOG_KEY,
},
},
);
if (!response.ok) throw new Error(`Cli Blog returned ${response.status}`);
const posts = await response.json();Keep create, update, publish, schedule, upload, and delete requests behind your own authenticated server route with a private key.
Complete draft-to-publish example
const author = await blog.authors.create({ public_name: "Maya Chen" });
const category = await blog.categories.create({
name: "Engineering",
locale: "en-US",
});
const tag = await blog.tags.create({
name: "Release notes",
locale: "en-US",
});
const draft = await blog.posts.create({
title: "July release notes",
body_markdown: "## Faster publishing\n\nWhat changed and why.",
locale: "en-US",
author_profile_ids: [author.id],
category_ids: [category.id],
tag_ids: [tag.id],
seo_title: "Cli Blog July release notes",
seo_description: "New publishing, delivery, and agent workflow improvements.",
});
console.log({ id: draft.id, slug: draft.slug, status: draft.status });
const published = await blog.posts.publish(draft.id, {
expected_version: draft.version,
});
console.log(published.status); // "published"Request options
Per-request options can override the client key. This is useful when one trusted service performs public reads and private writes without creating a second client.
const posts = await blog.posts.list(
{ status: "published", limit: 20 },
{ apiKey: process.env.CLI_BLOG_PUBLIC_KEY },
);Errors
The SDK throws CliBlogError for API responses and client setup failures.
import { CliBlogError } from "@cli-blog/node";
try {
await blog.posts.publish("post_123", { expected_version: 4 });
} catch (error) {
if (error instanceof CliBlogError) {
console.error(error.code, error.status, error.requestId);
}
throw error;
}| Status | Meaning | Next step |
|---|---|---|
400 or 422 | Invalid request or field value | Fix the named parameter and do not retry unchanged input |
401 | Missing or invalid key | Check the selected environment variable and key rotation state |
403 | Wrong key type or missing permission | Use the correct key and add only the required permission |
404 | Resource, slug, or locale not found | Confirm the organization, identifier, and requested locale |
409 | Stale expected_version | Fetch the latest post, reconcile the edit, and retry intentionally |
429 | Request or plan limit reached | Honor Retry-After when present and avoid immediate repeated requests |
5xx | Temporary service failure | Retry eligible reads with bounded exponential backoff |
The SDK makes up to three attempts for safe GET, HEAD, and OPTIONS requests after network failures and eligible 408, 425, 500, 502, 503, or 504 responses. It also retries 429 when the response supplies Retry-After. Create, update, publish, schedule, upload, and delete requests are attempted once so an uncertain response does not silently repeat a write.
Never log complete request headers or API key values.