# RoboWrite Public API — v1 Integration Guide (for coding agents) > This file is written to be pasted directly into a coding agent (Claude, Cursor, > Copilot, etc.). It covers the RoboWrite Public REST API v1: how to > authenticate, the conventions that apply to every endpoint, the endpoint > reference with request/response shapes, and a copy-paste end-to-end workflow. > > Precedence, so an agent knows what to trust. The published OpenAPI schema is > the machine source of truth for exact field shapes and is CI-guarded against > drift. `API.md` is the source of truth for behaviour, and its Changelog is the > full record of what changed and when. This guide is hand-maintained and no CI > gate compares its behavioural text against either, so it can lag a change — > it has before. Where it disagrees with the schema or with `API.md`, they win. > What it adds is how the surface fits together, so an agent can integrate > without reading the code. ## What RoboWrite is RoboWrite turns a topic or a brief into publish-ready, SEO-aware long-form content. The Public API lets you drive the whole content lifecycle programmatically — **discover → create → generate → poll → read** — without the dashboard. Every call is scoped to the single organization that owns your API key; there is no cross-tenant access. --- ## Base URL ``` https://api.roboad.ai/api/public/v1 ``` - **The hostname is not a typo.** RoboWrite's Public API is served from api.roboad.ai, the shared RoboAd platform host: RoboAd and RoboWrite are the same platform, and there is no robowrite.ai API hostname to call instead. Use the base URL exactly as written above. - Path-versioned (`/v1`). Breaking changes ship under a new version prefix. - JSON request and response bodies. - All timestamps are UTC ISO-8601. ## Authentication Every request needs an organization API key or an authorized agent OAuth access token as a Bearer token: ``` Authorization: Bearer YOUR_API_KEY ``` Create, list, and revoke keys from the RoboWrite dashboard (Settings → API keys). Each key is bound to one organization; all reads and writes are automatically scoped to it. A missing or malformed `Authorization` header returns `401`. Verify a key in one call: ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.roboad.ai/api/public/v1/ping" ``` --- ## Conventions (apply to every endpoint) ### 1. Send a real User-Agent header `api.roboad.ai` sits behind Cloudflare bot protection. Non-browser clients **must send a normal, non-empty `User-Agent` header.** Bare default agents such as Python's `urllib` (`Python-urllib/3.x`) are blocked at the edge: you get an HTTP `403` with an **empty body** (Cloudflare error `1010`) *before* the request reaches the API, so there is no `{"detail": ...}` envelope to read. - `curl`, `httpx`, `requests`, and browsers send an acceptable UA by default. - If you build raw `urllib` requests, set one explicitly, e.g. `User-Agent: my-app/1.0`. ### 2. Pagination List endpoints return a uniform envelope: ```json { "items": [ ... ], "total": 137, "limit": 50, "offset": 0, "has_more": true } ``` - Control with `?limit=` (1–200, default 50) and `?offset=`. - `total` is the full filtered count; `has_more` says whether another page exists after this window. Page deterministically off those fields. - **Exception:** a few endpoints return their own documented shape instead of this envelope — `GET /brands/{brand_id}/keywords` returns `{ "brand_id", "clusters", "counts" }`, `GET /topics/{topic_id}/scores` returns a bare `PublicTopicScore[]`, and `GET /briefs/{brief_id}/sections` returns a bare `PublicBriefSection[]`. ### 3. Errors Failures return the documented status code with a JSON body: ```json { "detail": "Human-readable explanation" } ``` | Code | Meaning | |------|---------| | `400` / `422` | Invalid or malformed input (unknown body keys are rejected) | | `401` | Missing/invalid API key | | `404` | Resource not found, or not owned by your org | | `409` | Conflict — resource is in the wrong state for the operation | | `429` | Rate limit exceeded — back off and retry | | `503` | Backend temporarily unavailable — retry | Treat any non-2xx defensively and read `detail`. (Remember the Cloudflare `403` above is the one error with **no** JSON body.) ### 4. Idempotency Spend-sensitive write endpoints accept an `Idempotency-Key` request header. A retry with the same key **replays the original response** instead of repeating the operation, and the replay carries an `Idempotency-Replayed: true` response header. - **Required** on `POST /brand-setups`, `POST /brand-setups/{setup_id}/confirm`, `POST /content/items`. - **Optional** on `POST /brands/{brand_id}/topics`, `POST /content/generate`, and `POST /content/items/{item_id}/generate`. Supplying a key on these paths is recommended so a dropped connection never double-creates or double-charges. - Creates including `POST /briefs` and `POST /properties` accept optional keys; supply one for retry protection. Without one, a retry can create a duplicate. - `POST /pillar-strategies` and publish reject the header; follow each route's documented rule. There is no blanket idempotency header. - A malformed key returns `422`. - A same-key retry replays the *original* result, so changing the body under the same key does **not** start a new operation with the new values — use a fresh key when you intend to change generation options. ### 5. Rate limits Fixed-window per organization, default **120 requests / 60 seconds**. Exceeding it returns `429`; back off and retry. (There are no per-plan request tiers.) ### 6. Asynchronous generation — poll the job AI generation is asynchronous. Generation endpoints return **`202 Accepted`** with: ```json { "job_id": "…", "workflow_id": "…", "status": "…" } ``` Then **poll `GET /jobs/{job_id}`** until the status stops polling. The polled job has this shape: ```json { "job_id": "…", "status": "…", "content_item_id": "…", "content_version_id": "…", "error": "…" } ``` Status values: - **In-flight (keep polling):** `pending`, `queued`, `running` - **Terminal (stop):** `completed`, `completed_with_errors`, `failed`, `cancelled` - **Needs human review (stop):** `awaiting_review` — see below Key rules: - **`content_item_id` and `content_version_id` arrive on the polled job, not on the `202`.** The `202` carries only `job_id` / `workflow_id` / `status`. For `POST /content/generate` (which creates a *new* item), read the new item's id from the polled job's `content_item_id` once populated — typically by the time the job reaches a terminal (or `awaiting_review`) status. - **`error`** carries the human-readable failure reason on `failed` / `completed_with_errors`. - A just-finished run may briefly still read `running` until its completion callback lands — poll until the status is terminal or `awaiting_review` rather than trusting a single non-terminal read. The public poll does no live workflow sync (it stays cheap and rate-limited). - Under rare concurrent-submit timing you may receive `202` with `status: "queued"` and an empty `workflow_id`. This is normal — the job exists; just poll it. ### `awaiting_review` — a stop-polling, needs-review outcome `awaiting_review` is a distinct third state (it is **neither** in-flight **nor** terminal). It happens on `POST /content/generate` (topic autopilot) when the run produced a draft but the quality/publish gate routed it to a **human review inbox** rather than auto-publishing — most commonly **because the brand has no connected CMS**. Stop polling the job and hand off to a human. The draft is ready: `content_item_id` and `content_version_id` are populated, so fetch it with `GET /content/{content_item_id}`. Recover its durable editorial review with `GET /reviews?content_item_id=…`, then retain the review/item/version identifiers. `GET /reviews/{review_id}` reads decisions after the original job ends. A parked job may omit `review_id` when that draft is empty or no longer current; `POST /reviews` with `content_version_id` admits the current nonempty saved version and recovers the same review on retries. Historical jobs are not backfilled. `GET /reviewers` lists current members. Members self-claim unassigned reviews; admins assign or reassign a current member. `POST /reviews/{review_id}/assignments` takes `content_version_id`, `reviewer_id`, UUID `request_id`, and `expected_assignment_revision` from the review. Replay is owned by that body `request_id`; do not send `Idempotency-Key` — the header is rejected with `422`. Reuse identical input and the request ID on retries. Stale assignment revisions conflict; refresh before another assignment. The review records `assignment_revision` and append-only `assignments` alongside its decisions. `POST /reviews/{review_id}/decisions` takes `content_version_id`, UUID `request_id`, `action` (approve/reject/request_revision) and `feedback`. Rejection and revision requests require feedback. The assigned current member or an organization admin may decide. Replay is owned by that body `request_id`; do not send `Idempotency-Key` — the header is rejected with `422`. A successful response is the applied decision; retry with the same request ID and identical input. Stale versions and attempts to replace decisions conflict. Approval permits editorial use/export of the exact version/body hash; check `is_current` before using it. It never publishes. Revision requests record feedback and stop, requiring a separate explicit rewrite action. Expired jobs remain terminal. The dashboard's publish-oriented inbox action remains a separate operation. Saving Markdown always creates a new immutable version. `POST /content/{content_item_id}/edits` takes `expected_base_version_id` and `request_id`. Send `markdown_body` to replace, or a unique `target` plus `replacement` for an exact patch. Do not send `Idempotency-Key`. A stale base returns `409`. `POST /content/{content_item_id}/restores` copies an earlier version into a new current revision. `GET /content/{content_item_id}/diff` compares two versions. `GET /content/{content_item_id}/sections` lists parser-derived H2 ids. `POST /content/{content_item_id}/rewrites` requires `Idempotency-Key`, counts as one revision, and keeps outside-section bytes identical unless `allow_broader_repair` is true. `POST /content/{content_item_id}/checks` starts an explicit quality check of the current saved version without editing Markdown; replay with the same body `request_id` and do not send `Idempotency-Key`. `GET /content/{content_item_id}/review-card` reads those persisted checks and does not start work. Publishing that exact version (draft/live/schedule) returns `409` until the hard checks exist and pass (`EDITORIAL_CHECKS_REQUIRED`, `EDITORIAL_CHECKS_STALE`, `EDITORIAL_CHECK_FAILED`, `EDITORIAL_CHECK_UNKNOWN`). Soft brand and score warnings do not block. Unpublish and recall do not require a check card. Approval still records when hard checks fail. > Practical note: a brand with **no connected CMS will essentially always land in > `awaiting_review`** on `POST /content/generate`, even for a perfect draft. If > you are integrating headless and just want the generated draft, that is > expected — poll to `awaiting_review`, then read the item. ### Expected latency A full generation run — brief creation, multi-agent article drafting/editing, and self-scoring — typically takes **10–17 minutes** end-to-end (wider variance during content-quality repair passes). Treat anything under **25 minutes** as normal; set poll ceilings/timeouts to at least **30 minutes** before considering a run stalled (a stalled run self-heals or escalates via the reconciler shortly after). Sub-minute completion is not representative and should not be assumed. Polling every 15–30 seconds is reasonable. ### Inline citations (notation) With `citation_mode=inline` on `POST /content/items/{item_id}/generate`, cited claims appear in the prose as **markdown hyperlinks** — `[descriptive anchor](https://source…)` — plus a numbered `## Sources` section at the end of `markdown_body`. These links are **not** numeric `[n]` markers; a numeric marker appears only in the rare case where a link's anchor text had to be replaced. To verify inline body citations programmatically, look for `](http` in `markdown_body`, not `[n]`. On an ordinary content item, omitting `citation_mode` gives `structured_only` — links are stripped from the body, so verify citations via `GET /content/{id}?include=sources` instead. On a program-owned item (one produced by the Programs `pages:generate` verb), omitting `citation_mode` keeps `inline`, so a regenerate does not strip the links that stage wrote. Programs itself is parked: every Programs verb, including `GET /program-templates` and `pages:generate`, returns `403` `Programs is not enabled for this organization.` unless the organization is allowlisted. See `API.md` § Programs. `GET /content/{id}?include=scoring,sources,assertion_provenance,compliance` fields are populated together per version, but scoring is only written once self-scoring completes — a version fetched **before** its generation run finishes may show `sources` populated with `scoring: null` briefly. Poll the job to a terminal/`awaiting_review` status before treating a missing `scoring` field as an error. --- ## Endpoint reference ### Health | Method | Path | Purpose | |--------|------|---------| | GET | `/ping` | Liveness check | ### Brands (read) | Method | Path | Purpose | |--------|------|---------| | GET | `/brands` | List brands you own | | GET | `/brands/{brand_id}` | Get a brand | | GET | `/brands/{brand_id}/keywords` | Keyword inventory + market data (volume, difficulty, CPC). **Custom shape**, not the list envelope | ### Properties (publishing targets) | Method | Path | Purpose | |--------|------|---------| | GET | `/properties` | List properties | | GET | `/properties/{property_id}` | Get a property | | POST | `/properties` | Create a property (brand_id required, including single-brand organizations) | ### Topics | Method | Path | Purpose | |--------|------|---------| | GET | `/topics` | List topics — **`?brand_id=` is required** (omitting it → `422`) | | GET | `/topics/{topic_id}` | Get a topic | | GET | `/topics/{topic_id}/content` | List content generated from a topic | | GET | `/topics/{topic_id}/scores` | Cached opportunity scores (bare array) | | POST | `/brands/{brand_id}/topics` | Create a topic from your own input | `GET /topics` supports filters: `pillar_id`, `keyword`, `status`, `min_score`, `min_volume`, `max_difficulty`, `intent`, `decision_band`, plus `sort_by` (`total_score` | `search_volume` | `keyword_difficulty` | `created_at`) and `sort_order` (`asc` | `desc`). Each returned topic carries `decision_band` and an `eligible_for_autopilot` boolean. ### Briefs | Method | Path | Purpose | |--------|------|---------| | POST | `/briefs` | Create a brief | | GET | `/briefs` | List briefs | | GET | `/briefs/{brief_id}` | Get a brief | | GET | `/briefs/{brief_id}/sections` | Section breakdown (bare array) | | PATCH | `/briefs/{brief_id}` | Edit a brief (every edit is auto-versioned) | On `PATCH /briefs/{brief_id}` an omitted key is left unchanged. Explicit `null` clears `primary_keyword`, `target_word_count`, `audience_details`, `tone`, `search_intent`, `funnel_stage`, `pillar_id`, `description`, `summary` and `content_goal`. It is a `422` on `title` and `format` (published as non-nullable). Empty the keyword set with either `[]` or `null`. ### Content items | Method | Path | Purpose | |--------|------|---------| | POST | `/content/items` | Create a content item (**`Idempotency-Key` required**) | | GET | `/content` | Search named drafts with brand, property, status, content_type, brief, pillar, creation-date and keyword filters | | GET | `/content/items` | List content items in the create/update shape (filter by `brief_id`, `property_id`, `status`). Same listing as `GET /content`, but each row carries the fuller detail shape returned by `POST`/`PATCH` | | GET | `/content/{content_item_id}` | Get an item with a **page** of its versions (`versions` is a `PublicPage` envelope — read `versions.items`; `?latest_only=true` or `?version=N` for one). `?include=scoring` adds quality scores, `?include=sources` adds cited research sources, `?include=assertion_provenance` adds the per-assertion provenance receipt, `?include=compliance` adds regulated-content findings; combine with a comma | | PATCH | `/content/items/{item_id}` | Update title / status / format / author / slug | | DELETE | `/content/items/{item_id}` | Soft-delete a content item | | POST | `/content/{item_id}/publish` | Publish / schedule / unpublish to the org's connected CMS. `mode` is `draft` / `live` / `schedule` / `unpublish`; `schedule_at` is required only for `schedule` → `202` + job. A retry of an already-enqueued publish replays the original `job_id` with `was_duplicate_lookup: true`. `409` on a `draft_only` property (except `unpublish`, which is allowed there — the page may be live from a CMS-side path), on zero targets with no human-selected org default, or on an ambiguous multi-target; `unpublish` also `409`s when there is no target to tear down. `Idempotency-Key` is **rejected** with `422` | ### Generation & jobs | Method | Path | Purpose | |--------|------|---------| | POST | `/content/generate` | Generate from a topic (autopilot). Body **`{ "topic_id": "" }` only** → `202` + job | | POST | `/content/items/{item_id}/generate` | Generate / rewrite / vary an item (supports `direction` + `citation_mode`) → `202` + job | | GET | `/jobs` | Recover recent jobs with status, type, date and linked-resource filters | | GET | `/jobs/{job_id}` | Read job status, errors, timing and content references | ### Pillar strategy (clusters-first engine) | Method | Path | Purpose | |--------|------|---------| | POST | `/pillar-strategies` | Generate a clusters-first pillar strategy for a brand. Dedupe is derived from the request (org, brand, seeds, `num_pillars`), not from a client key: a matching completed snapshot returns `200` with the snapshot and an in-flight one returns `202` + `run_id` + `poll_url`, both free; any fresh run (including `force_refresh: true`) returns `202` and charges the pillar-strategy allowance once. `Idempotency-Key` is **rejected** with `422` | | GET | `/pillar-strategies/{run_id}` | Poll a strategy run — always `200`; `status` is `generating` until the run settles, then `failed` / `cancelled` with an `error`, or the finished snapshot. A finished snapshot carries its OWN quality status, which is `completed` or `completed_with_errors` (degraded enrichment — inspect `warnings` and proceed only with usable `clusters` / `pillars`; `NO_CLUSTERS` means no clusters could be resolved). Research warnings distinguish unavailable research (`KEYWORD_RESEARCH_UNCONFIGURED`), a failed request (`KEYWORD_RESEARCH_CALL_FAILED`), and a request without results (`KEYWORD_RESEARCH_ZERO_RESULTS`). `404` for a run this org does not own | ### Taxonomy (full CRUD) | Resource | Paths | |----------|-------| | Pillars | `GET`/`POST` `/pillars` · `GET`/`PATCH`/`DELETE` `/pillars/{pillar_id}` | | Audiences | `GET`/`POST` `/audiences` · `GET`/`PATCH`/`DELETE` `/audiences/{audience_id}` | | Authors | `GET`/`POST` `/authors` · `GET`/`PATCH`/`DELETE` `/authors/{author_id}` | | Categories | `GET`/`POST` `/categories` · `GET`/`PATCH`/`DELETE` `/categories/{category_id}` | | Tags | `GET`/`POST` `/tags` · `GET`/`PATCH`/`DELETE` `/tags/{tag_id}` | `POST /categories`, `/tags`, `/pillars`, `/audiences` take `brand_id` in the body; an unknown or foreign `brand_id` → `404`. Renaming a category/tag to a `slug` already used in the same brand → `409`. On PATCH, an omitted key is left unchanged. Explicit `null` clears a field only when that is a real operation; otherwise it is a `422`. It clears `email`/`bio`/`avatar_url` (authors), `description`/`parent_id`/`icon`/`color` (categories), `description`/`keywords`/`target_audience`/`business_goal`/`target_audience_ids` (pillars; send `[]` or `null` to empty keywords or ids), `description`/`decision_role`/`sophistication_level`/`technical_depth`/`goals`/`pain_points` (audiences), and is rejected on `name`/`slug`/`status` (authors), `name`/`slug`/`sort_order`/`status` (categories), `name`/`goal`/`status` (pillars), `name`/`status` (audiences), and `name`/`slug` (tags). Those keys are published as non-nullable. Pillar `goal`/`status` reject `null` because both are required and non-nullable on the pillar response. ### Documents & imported content (read) | Method | Path | Purpose | |--------|------|---------| | GET | `/documents` | List the document library (filter `category`, `status`, `q`) | | GET | `/documents/{document_id}` | Document metadata + summary | | GET | `/imported-content` | List CMS-imported / external content | | GET | `/imported-content/{content_item_id}` | Get an imported item | --- ## Request/response shapes for the common calls ### Create a content item — `POST /content/items` Brief-driven (not topic-driven); the brand is derived from the property. The `Idempotency-Key` header is **required**. ``` POST /content/items Authorization: Bearer YOUR_API_KEY Content-Type: application/json Idempotency-Key: { "brief_id": "", // required — the brief this item is built from "property_id": "", // required — publishing target; must belong to the brief's brand "title": "", // required, 1–500 chars "format": "blog_post" // optional, defaults to blog_post } ``` Unknown keys are rejected with `422` (e.g. sending `topic_id` or `brand_id` here is invalid — the brand comes from the property). A brief and property whose brands differ → `422`; a missing/foreign `brief_id` or `property_id` → `404`. ### Generate from a topic — `POST /content/generate` Runs the topic autopilot. Body is **exactly** `{ "topic_id": "" }` — it does **not** accept `direction` or `citation_mode` (sending either → `422`). ``` POST /content/generate Authorization: Bearer YOUR_API_KEY Content-Type: application/json Idempotency-Key: { "topic_id": "" } ``` Response `202`: `{ "job_id", "workflow_id", "status" }` — then poll `GET /jobs/{job_id}`. **Autopilot eligibility:** `POST /content/generate` only runs for topics whose decision band is autopilot-eligible; ineligible topics → `409`. - **Eligible:** `auto_brief`, `light_review`, and topics with **no band yet** (`decision_band: null` — e.g. a topic you just created). - **Ineligible:** `expert_review`, `reject_or_hold`. A topic's band reflects a demand/fit quality gate, so freshly-seeded, not-yet-enriched topics (low/zero search volume) often score into the ineligible bands — a large fraction of a brand-new topic bank may be ineligible until keyword/SEO enrichment runs. **Select eligible topics up front** with `GET /topics?brand_id=…` (read `eligible_for_autopilot`) rather than firing blindly into a `409`. The fastest reliable path to a generation is `POST /brands/{brand_id}/topics` (a fresh topic is `null`-band → eligible) then `POST /content/generate`. ### Generate / rewrite / vary an item — `POST /content/items/{item_id}/generate` ``` POST /content/items/{item_id}/generate Authorization: Bearer YOUR_API_KEY Content-Type: application/json Idempotency-Key: { "direction": { // all optional styling knobs "tone": "...", "reading_level": "...", "point_of_view": "...", "content_length": "...", "content_structure": "..." }, "citation_mode": "structured_only" // optional: "structured_only" | "inline" } ``` `citation_mode` controls whether the generated body *shows* citations: `structured_only` strips links to clean prose; `inline` keeps inline links and appends a Sources section. Omitting the field gives `structured_only` on an ordinary content item, but `inline` on a program-owned item (one produced by the Programs `pages:generate` verb), so a regenerate does not strip the links that stage wrote. An explicit value overrides either default — subject to the same-key replay rule above. Either way the cited sources are captured and readable via `?include=sources`. Unsupported values → `422`. ### Create a topic from your own input — `POST /brands/{brand_id}/topics` ``` { "title": "...", // required, 1–500 chars "primary_keyword": "...", // required, 1–200 chars — the pipeline seeds from this "premise": "...", // optional "intent": "informational", // optional "funnel_stage": "tofu", // optional "suggested_format": "blog_post", "secondary_keywords": ["...", "..."] } ``` ### URL brand setup `POST /brand-setups` with `{ "website_url": "https://example.com" }` and a required `Idempotency-Key` returns `202 {setup_id, job_id}`. Poll `GET /brand-setups/{setup_id}` until ready, review suggestions with the user, then `POST /brand-setups/{setup_id}/confirm` with an idempotency key and bounded optional edits. Retries return the same brand; a conflicting input or active setup returns 409. Use the returned brand_id explicitly for property and brief creation. Pick draft_only and item generation for a draft. If dashboard research is rerun, an earlier setup reports running while a different research attempt owns the session, and confirming its earlier suggestions returns 409. Poll again before reviewing or confirming. Enabled MCP installations use browser sign-in and organization consent. The client stores tokens and handles reconnect/disconnect; credentials never belong in chat. OAuth requires user:org:read and robowrite:content, with current membership and normal quotas. This content grant includes category and tag list, detail, create, update and delete operations under the consented organization and the selected brand. ### Recover existing drafts and jobs Use `GET /content` / `GET /briefs` before repeating work after a lost session. `q` searches titles/content slugs with a case-insensitive literal substring; `keyword` matches whole primary/secondary brief targets and latest-version SEO keywords, including targets for pending drafts. Combine `brand_id`, `property_id`, `pillar_id`, `status`, `format`, `created_from` (inclusive) and `created_before` (exclusive) with AND. Dates use creation time in UTC. Content lists retain the existing `content_type` selector as well. Follow `has_more`, `offset` and `limit`; all filters apply before pagination and counting. Recover jobs with `GET /jobs`, then inspect `GET /jobs/{job_id}`. Job status, errors, timing and resource IDs are separate from the saved version's producing-job evidence: a later failed attempt does not erase an earlier draft. Unknown historical producing jobs remain null. Use `GET /topics/{topic_id}/content` or `GET /content?brief_id=` to follow stored relationships. MCP names: `list_content`, `list_briefs`, `get_brief`, `get_content`, `list_jobs`, `get_job`, `get_topic_content`. Taxonomy MCP names: `list_categories`, `get_category`, `create_category`, `update_category`, `delete_category`, `list_tags`, `get_tag`, `create_tag`, `update_tag`, `delete_tag`. Lists/creates require brand_id; read the object and verify its brand before updates/deletes. Creates require a stable idempotency_key; updates/deletes forbid it. Deletes require explicit user intent and return a status:204 receipt. Refresh the client tool catalog after upgrading. `get_content` accepts the version selectors and all four quality includes. `read_draft` remains the latest saved Markdown shortcut. MCP property creation defaults omitted policy to `draft_only`; REST retains `cms_publish`. Both MCP and REST require explicit brands on property/brief creation. ### Create a brief — `POST /briefs` Closed allowlist (unknown keys → `422`); `brand_id` is required (missing or null → `422`); `status` is server-set. ``` { "title": "...", // required, 1–500 chars "brand_id": "", // required "source_topic_id": "", // optional same-brand topic provenance "primary_keyword": "...", // optional "secondary_keywords": ["..."], "target_word_count": 1200, // optional, 100–10000 "audience_details": "...", // optional, ≤5000 chars "tone": "...", // optional "format": "blog_post", "search_intent": "...", "funnel_stage": "...", "pillar_id": "", "description": "...", // optional, ≤5000 chars "summary": "...", // optional, ≤5000 chars "content_goal": "..." // optional, ≤5000 chars } ``` ### Read a content item — `GET /content/{content_item_id}?include=scoring,sources,assertion_provenance,compliance` ```json { "id": "…", "title": "…", "slug": "…", "status": "draft", "brand_id": "…", "property_id": "…", "author_id": "…", "versions": { "items": [ { "id": "…", "version_number": 2, "title": "…", "markdown_body": "…", "excerpt": "…", "seo_title": "…", "seo_description": "…", "seo_keywords": ["…"], "tags": ["…"], "categories": ["…"], "word_count": 1240, "created_at": "2026-01-01T00:00:00Z", "scoring": { "geo_score": 87, "draft_score": 0.82, "warnings": [] }, "sources": [ { "marker": 1, "source_name": "Stanford WFH Study", "source_url": "https://stanford.edu/wfh" } ] } ], "total": 2, "limit": 50, "offset": 0, "has_more": false, "next_cursor": null } } ``` `versions` is a **pagination envelope**, not a bare array — read `versions.items`. It is newest-first and takes the standard `?limit=` (1–200, default 50) and `?offset=`. For a single version prefer `?latest_only=true` or `?version=N`. This replaced a bare array on 2026-08-11; `/v1` response shapes are not frozen, so check the Changelog at https://robowrite.ai/api before pinning a client. `scoring`, `sources`, and `assertion_provenance` are **opt-in and additive**. Without the `include`, they are `null`. With `?include=sources`, `sources` is `[]` when a version cited nothing (or was generated before source capture existed — no backfill). With `?include=assertion_provenance`, the field is `[]` when the version carries no receipt. Guard on presence, not on `null` vs `[]`: ```js const cited = version.sources ?? []; // null = not requested; [] = requested, none/legacy ``` --- ## Typical end-to-end workflow 1. **Discover** — `GET /brands` to pick a brand; optionally `GET /brands/{brand_id}/keywords` for opportunities. 2. **Choose a topic** — `GET /topics?brand_id=…` (filter to `eligible_for_autopilot: true`) or `POST /brands/{brand_id}/topics` to create your own (fresh topic is eligible). 3. **Generate** — `POST /content/generate` with `{ "topic_id": … }` and an `Idempotency-Key`. (Or, for full brief control: `POST /briefs` → `POST /content/items` → `POST /content/items/{id}/generate`.) 4. **Poll** — `GET /jobs/{job_id}` until `status` is terminal **or** `awaiting_review`. 5. **Read** — `GET /content/{content_item_id}?include=scoring,sources,assertion_provenance,compliance`. `PATCH` the item to adjust title/status/etc. ### Copy-paste: topic → generate → poll → read (bash) ```bash BASE="https://api.roboad.ai/api/public/v1" AUTH="Authorization: Bearer YOUR_API_KEY" # 1. Pick a brand BRAND_ID=$(curl -s -H "$AUTH" "$BASE/brands" | jq -r '.items[0].id') # 2. Create an (immediately-eligible) topic TOPIC_ID=$(curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"title":"Remote work productivity","primary_keyword":"remote work productivity"}' \ "$BASE/brands/$BRAND_ID/topics" | jq -r '.id') # 3. Kick off generation JOB_ID=$(curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d "{\"topic_id\":\"$TOPIC_ID\"}" \ "$BASE/content/generate" | jq -r '.job_id') # 4. Poll until terminal or awaiting_review while :; do JOB=$(curl -s -H "$AUTH" "$BASE/jobs/$JOB_ID") STATUS=$(echo "$JOB" | jq -r '.status') echo "status: $STATUS" case "$STATUS" in completed|completed_with_errors|failed|cancelled|awaiting_review) break ;; esac sleep 5 done # 5. Read the generated draft ITEM_ID=$(echo "$JOB" | jq -r '.content_item_id') curl -s -H "$AUTH" "$BASE/content/$ITEM_ID?include=scoring,sources,assertion_provenance,compliance" | jq ``` ### Copy-paste: same flow (Python, httpx) ```python import time, uuid, httpx BASE = "https://api.roboad.ai/api/public/v1" # httpx sends a real User-Agent by default; raw urllib does NOT and Cloudflare 1010-blocks it. client = httpx.Client(base_url=BASE, headers={"Authorization": "Bearer YOUR_API_KEY"}) brand_id = client.get("/brands").json()["items"][0]["id"] topic = client.post( f"/brands/{brand_id}/topics", headers={"Idempotency-Key": str(uuid.uuid4())}, json={"title": "Remote work productivity", "primary_keyword": "remote work productivity"}, ).json() job = client.post( "/content/generate", headers={"Idempotency-Key": str(uuid.uuid4())}, json={"topic_id": topic["id"]}, ).json() STOP = {"completed", "completed_with_errors", "failed", "cancelled", "awaiting_review"} while True: job = client.get(f"/jobs/{job['job_id']}").json() if job["status"] in STOP: break time.sleep(5) item = client.get( f"/content/{job['content_item_id']}", params={"include": "scoring,sources", "latest_only": "true"}, ).json() # `versions` is a PublicPage envelope, NOT a bare array. Read `.items`. # Indexing the envelope itself (item["versions"][-1]) raises KeyError. print(item["versions"]["items"][0]["markdown_body"]) ``` --- ## Not in v1 yet (roadmap — design around these) - **Public CMS connection / target management** — connecting a CMS and choosing the default publish destination stay in the dashboard; the API exposes neither connection setup nor collection discovery. Publishing itself **is** exposed — see `POST /content/{item_id}/publish` under **Content items**. - **Completion webhooks** — generation completion is **poll-based** today (`GET /jobs/{job_id}`); push webhooks are planned. - **Direct document file-download URLs** — metadata and summaries are available; original-file signed URLs are not yet exposed. ## Notes - Internal billing/cost fields are never returned. Keyword/market data (search volume, difficulty, CPC) is included where relevant. - Generate a typed client from the published OpenAPI schema for exact field shapes — it is the source of truth and CI-guarded against drift. ## Organization settings | Method | Path | Purpose | |--------|------|---------| | GET | `/org/settings` | Read organization-level generation settings | | PATCH | `/org/settings` | Update organization-level generation settings | `regulated_industries` controls the org-wide regulated-content policy (`finance`, `healthcare`, `legal`; empty array = off). The field is **required** on PATCH. ## Brand voice `PATCH /brands/{brand_id}` updates allow-listed voice fields (`description`, `tone`, `phrases_to_avoid`, `dos`, `donts`, `mission_statement`). Omitted keys stay unchanged; explicit `null` is rejected on non-nullable fields (use `[]` to clear arrays). ## Compliance projection `GET /content/{id}?include=compliance` returns per-version findings `{kind, message, excerpt}`. Without the include, `compliance` is `null`; with it, `[]` means clean/unregulated. Source-controlled brand sync: GET /brands/{id}/projection with website_url returns revision, source and the currently usable rollback_receipt_id. POST to the same path plus /preview compares without writes; /apply requires expected_revision and returns a receipt; /rollback rejects subsequent edits. Prefix all paths with /api/public/v1. Owned field edits conflict. See API.md for source coordinates and the closed field list. These operations do not generate or publish content.