Programmatic content
How an autonomous agent generates, drafts, previews, and publishes content through @floggy/cms.
An autonomous agent can now run the whole content lifecycle from a server, with nothing but { project, key }.
Before 0.4.0 the SDK could only read. Writing meant one of two things: the floggy CLI, which is a terminal binary and cannot be called from a request handler, or raw REST, which forces every consumer to hardcode Floggy's API origin, its auth header and its response shapes. 0.4.0 adds the posts write surface to client.posts, so the same typed client that renders your blog can also create, preview and publish it.
The loop this page documents:
- Generate. The agent writes a post as Markdown.
- Draft.
posts.create({ title, content })defaults tostatus: "draft". Nothing is public. - Preview. Your own site serves
/drafts/<slug>fromposts.draft(slug), server-side. A human-clickable URL, no login, pasteable to the owner. - Approve. A human looks at it.
- Publish.
posts.publish(id)makes it live, fires thepost.publishedwebhook, and your cache tag expires.
Collection entries follow the same shape, and the two compose: one agent can write landing pages as entries and blog posts as posts, preview both at /drafts/*, and publish both on approval.
Setup
Create a key in the dashboard under Settings > Developer with the scopes the agent actually needs:
| Scope | Buys you |
|---|---|
posts:read |
posts.mine, posts.getById, posts.draft |
posts:write |
posts.create, update, publish, unpublish, bulk (including bulk("delete", ...)) |
posts:delete |
posts.delete only |
collections:read / collections:write |
the collections half of the loop |
Grant posts:delete only if the agent is supposed to destroy things. A publish-only agent does not need it, and bulk("delete", ids) deliberately does not either.
// lib/floggy.ts - server only. This module carries the private key.
import { createClient } from "@floggy/cms";
export const floggy = createClient({
project: "projecta",
key: process.env.FLOGGY_API_KEY,
});
Two things to keep straight:
- Never ship a write-scoped key to a browser. Everything on this page runs on a server. A public blog read (
posts.list,posts.get,posts.search,tags.list,feeds.*) needs no key at all, so the client-side half of your app should not have one. - Authenticated calls answer as the key's owner, not as
config.project.projectscopes the public reads. Every write, plusmine,getById,draftand all ofcollections, lands on whichever account issued the key. Point a key at someone else's project and the reads followprojectwhile the writes go to the key owner.
1 and 2. Generate, then draft
content is a Markdown string by default. It is converted to Tiptap in-process by markdownToTiptap, with zero dependencies and no network round trip, which is why this works on Workers and other edge runtimes.
import { floggy } from "@/lib/floggy";
const markdown = await writeThePost(); // your model call
const { id } = await floggy.posts.create({
title: "How we cut our build time in half",
content: markdown,
excerpt: "Three changes, measured, in order of payoff.",
tagNames: ["engineering", "build"],
// status defaults to "draft". Say nothing and nothing goes live.
});
slug is optional and derived from the title. The same derivation is exported as slugify when you need the slug before the post exists (to build a preview URL, for instance):
import { slugify } from "@floggy/cms";
const slug = slugify("How we cut our build time in half");
// "how-we-cut-our-build-time-in-half"
Pass slug explicitly when you want to control it. It must match ^[a-z0-9-]+$; the SDK rejects anything else before the request.
A generated slug collides often, so handle it deliberately. A slug already used on the account is a plain FloggyError with status === 409, never a ConflictError:
import { FloggyError, slugify, type PostCreateInput } from "@floggy/cms";
async function createUnique(input: PostCreateInput) {
const base = input.slug ?? slugify(input.title);
for (let n = 0; n < 5; n++) {
const slug = n === 0 ? base : `${base}-${n + 1}`;
try {
return await floggy.posts.create({ ...input, slug });
} catch (err) {
if (err instanceof FloggyError && err.status === 409) continue;
throw err;
}
}
throw new Error(`Could not find a free slug near "${base}".`);
}
Do not catch ConflictError here. It is reserved for optimistic-concurrency failures on collection entries (entries.update with ifVersion), where the right move is to re-read and rebase. A taken slug has nothing to rebase; you just pick another name.
Editing a draft is a patch, so send only what changed:
await floggy.posts.update(id, { content: revisedMarkdown });
tagNames and coauthors are the exceptions: when present they replace the post's set wholesale, and tagNames: [] clears it.
The Markdown subset
markdownToTiptap parses exactly what Floggy's editor renders:
- Blocks: headings (
#to######), paragraphs, bullet and ordered lists (2-space nesting), blockquotes, fenced code with a language, horizontal rules, and a standalone image line. - Inline: bold, italic, inline code, strikethrough, links, images, hard breaks (two trailing spaces), backslash escapes.
Outside that subset are tables, embeds, GitHub repo cards, underline, highlight, subscript, superscript and text alignment. They do not error; they degrade to plain paragraph text. If an agent is generating content, tell it the subset up front rather than letting it discover the degradation in a preview.
Two practical rules:
- An image must sit alone on its own line to become a block image. Text on the same line makes it an inline image inside a paragraph.
- A GitHub repo card comes from the bare repo URL alone on its own line, which Markdown will carry through as a paragraph link. Sub-paths and inline URLs stay plain links.
Already have Tiptap JSON? Pass the object directly, or pass a JSON string with contentFormat: "tiptap". tiptapToMarkdown goes the other way, which is how you hand an existing post back to a model for revision:
import { tiptapToMarkdown } from "@floggy/cms";
const post = await floggy.posts.getById(id);
const editable = tiptapToMarkdown(post.content);
3. Preview
posts.draft(slug) is the whole preview primitive. It returns a DraftPost, shaped for the same component your live post page uses, or null when there is nothing to show.
It returns null for a published post too. /drafts exists to show what is not live yet; a published post already has a URL of its own.
const post = await floggy.posts.draft("how-we-cut-our-build-time-in-half");
// null, or a DraftPost with `rendered` holding HTML
DraftPost is Omit<PostDetail, "seo" | "jsonLd">, and that omission is the useful part. The endpoint that can see a draft does not compute either block, so a type claiming they exist would hand you two objects full of undefined at render time. Instead:
const draft = await floggy.posts.draft(slug);
floggy.seo.meta(draft); // compile error: DraftPost is not a PostDetail
Build the draft page's <head> from the post's own fields. That is what you want anyway, since a preview should be noindex rather than carrying the canonical and Open Graph tags of a page that is not live.
adjacent is present, filled with { prev: null, next: null } by the SDK, so a component that reads it keeps working. A draft has no position in the published sequence yet, and inventing neighbours would preview a layout the published post will not have.
Cost: two requests per call. The listing resolves the slug to an id, then the post is fetched with format, which defaults to "html". mine() underneath it is unpaginated and returns full content for every post you own, so a preview route on a large blog is not free. Wrap it in React's cache() so generateMetadata and the page body share one call.
The route
// app/drafts/[slug]/page.tsx
import { cache } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { createClient } from "@floggy/cms";
// Server-only. This module holds a private key, so never import it from a
// client component. Uncached on purpose: a draft is looked at precisely
// because it is still changing.
const floggy = createClient({
project: "projecta",
key: process.env.FLOGGY_API_KEY,
fetch: ((input: RequestInfo | URL, init?: RequestInit) =>
fetch(input, { ...init, cache: "no-store" })) as typeof fetch,
});
// One call per request, shared by generateMetadata and the page below.
const getDraft = cache((slug: string) => floggy.posts.draft(slug));
// Never prerendered, never revalidated. The point of the route is to show what
// changed a minute ago.
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ slug: string }> };
// noindex on every response, including the 404, so nothing here is indexable
// regardless of what resolved. nofollow matters as much: a draft links to live
// pages, and those links should not be crawled from a URL that is not supposed
// to exist.
const ROBOTS: Metadata["robots"] = {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false },
};
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const post = await getDraft(slug);
return {
title: post ? `Draft: ${post.title}` : "Draft",
robots: ROBOTS,
// No canonical. This URL is not the canonical anything, and pointing at the
// live URL would invite Google to treat the draft as a copy of a page whose
// content it does not match.
alternates: {},
};
}
export default async function DraftPreviewPage({ params }: Params) {
const { slug } = await params;
const post = await getDraft(slug);
if (!post) notFound();
return (
<>
<div className="sticky top-0 z-50 bg-amber-400 px-4 py-2 text-center text-sm font-medium text-amber-950">
Draft preview of an unpublished post. Not indexed, not linked, not live.
</div>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />
</article>
</>
);
}
Closing off discoverability
Access to a preview is the link itself, which is the point: a URL you can paste to a person or a tool with no account in between. Discoverability is therefore the entire risk, and one defense is not enough. Use four.
1. noindex, nofollow on every response. Above, including on the 404 path.
2. Disallow: /drafts in robots.txt.
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/api", "/drafts"] },
],
sitemap: "https://example.com/sitemap.xml",
};
}
3. Absent from the sitemap. Your sitemap should be built from posts.list or feeds.sitemap(), both of which serve published posts only, so this happens by construction. The failure mode to watch for is a sitemap built from posts.mine(), which includes drafts.
4. Not linked from anywhere. No nav entry, no "preview" button in a public layout, no mention in a feed.
A slug with no draft must be a plain 404, the same answer an unknown slug gets. The route should never confirm that a slug exists.
4 and 5. Approve, then publish
Approval is a human reading the preview URL. Publishing is one call:
await floggy.posts.publish(id);
That fires post.published, which your webhook endpoint turns into a cache expiry, so publish-to-live is seconds rather than a revalidate window. See Webhooks.
Without at, publish costs an extra GET first. The server stamps publishedAt to "now" the first time a post goes live, which would silently discard a backdated draft's date, so the current value is read and re-sent. Pass at to skip the round trip and be explicit:
await floggy.posts.publish(id, { at: "2026-03-01T10:00:00Z" });
A clearly future at schedules the post instead of publishing it now. Note that scheduled posts do not fire post.published at write time.
The reverse of publish is unpublish, which sets the status back to draft and leaves publishedAt intact, so publishing again restores the original date:
await floggy.posts.unpublish(id);
For a batch, bulk takes up to 100 ids in one call:
const { affected } = await floggy.posts.bulk("publish", ids);
Ids the key's owner does not own are skipped, so affected can be lower than the number you sent. Bulk publish has no equivalent of publish's date guard: the server stamps publishedAt to now on anything that was not already published. Publish backdated drafts one at a time.
Seeing what the agent has queued
const drafts = await floggy.posts.mine({ status: "draft" });
mine() is unpaginated and returns full Tiptap content for every post, and the status filter is applied client-side after the fetch. It is fine for a review dashboard and wrong for a hot path.
An unlisted post is a third option worth knowing about: visibility: "link-only" publishes a post that is reachable by URL but absent from the index, feeds and sitemap. Use it when the content should be live but not announced. Use a draft when it should not be live at all.
The same loop for collection entries
Collections are the headless half of Floggy: schema-driven stores that never render on the blog and exist for external sites to consume. An SEO agent writing landing pages as entries and blog posts as posts is running one workflow against two surfaces.
The publish gate is a label rather than a status. Every write creates a version, and the production label points at whichever version is live.
// Draft: no label, so nothing serves it.
const entry = await floggy.collections.entries.create(collectionId, {
slug: "seo-tools",
data: { headline: "The tools we actually use", body: draftHtml },
});
// Publish: point production at that version.
await floggy.collections.publish(collectionId, entry.id, entry.version);
An edit to a live entry behaves the same way: entries.update writes a new version and leaves production on the old one, so the live page keeps serving the published copy while the pending edit waits for approval. That is what makes the same /drafts/<slug> route work for entries:
// The entry's unpublished version, or null when there is none.
export async function getDraftEntry(collectionId: string, slug: string) {
const { entries } = await floggy.collections.entries.list(collectionId, {
slug,
perPage: 1,
});
const entry = entries[0];
if (!entry) return null;
// Published with nothing newer on top: there is no draft to preview.
const production = entry.labels?.production?.version;
if (production === entry.currentVersion) return null;
// The listing serves the production version of a published entry, so the
// draft is only reachable by asking for its exact version number.
return floggy.collections.entries.get(collectionId, entry.id, {
version: entry.currentVersion,
});
}
Resolve a slug against entries first and posts second in the same route, and one preview URL covers both kinds of content.
Errors
Every non-2xx surfaces as a typed error. Three are worth handling by name in an agent.
import { ScopeError, RateLimitError, FloggyError } from "@floggy/cms";
try {
await floggy.posts.publish(id);
} catch (err) {
if (err instanceof ScopeError) {
// "Insufficient scope: this endpoint requires posts:write."
console.error(`This endpoint requires: ${err.required.join(", ")}`);
return;
}
if (err instanceof RateLimitError) {
await sleep((err.retryAfter ?? 5) * 1000);
return retry();
}
if (err instanceof FloggyError && err.status === 409) {
// A value is already in use. On create, that is the slug: pick another.
return createUnique(input);
}
throw err;
}
ScopeError (403) carries required, which is the endpoint's full requirement list exactly as the server sent it, not the subset your key is short of. The server does not report which one fell short, so report it as what the route asks for rather than as "your key is missing X". A 403 without a required list is not a scope problem and surfaces as a plain FloggyError.
409 is two unrelated things, and the SDK keeps them apart:
| Error | Cause | What to do |
|---|---|---|
FloggyError, status === 409 |
A value is already taken. On posts.create, the slug. |
Pick another value and retry. Nothing to reconcile. |
ConflictError |
An ifVersion mismatch on collections.entries.update. |
Re-read, rebase onto err.currentVersion, retry. |
Never catch ConflictError for a slug collision. It will not fire, and a rebase is the wrong response to a taken name.
ScopeError, RateLimitError and ConflictError all extend FloggyError, so order your checks from specific to general.
posts.draft is the one method that swallows a not-found into null. Transport and auth failures still throw, so a bad key never looks like a missing post.
What to hand the human
An approval request should be short enough to act on in one glance. Two things carry all of it:
- The preview URL.
https://example.com/drafts/<slug>. It is clickable, needs no login, and shows the real rendering rather than a description of it. - The diff of what will change. For a new post: the title, the slug it will occupy, the tags, and whether anything already lives at that slug. For an edit: what the fields were and what they will be. For an entry: the current production version number and the version being proposed.
Then the approval is one call. Keep the id, not the slug, as the thing you act on: slugs can be edited, ids cannot.