# 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 is a complete, accurate description of the RoboWrite Public > REST API v1: how to authenticate, the conventions that apply to every endpoint, > the full endpoint reference with request/response shapes, and a copy-paste > end-to-end workflow. Everything here reflects the live v1 surface. > > The published OpenAPI schema is the machine source of truth for exact field > shapes and is CI-guarded against drift. This guide explains 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 ``` - 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 your organization API key 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 /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. - Other writes (e.g. `POST /briefs`) do **not** dedupe — a dropped-response retry can create a duplicate. - 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**. No further progress happens via the API, so treat it like a stop signal for polling. The draft is ready: `content_item_id` and `content_version_id` are populated, so fetch it with `GET /content/{content_item_id}`. Resolving the review (approve / hand to an editor / dismiss) is done from the dashboard inbox, not the API. > 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]`. With the default `structured_only` mode (omit `citation_mode`), links are stripped from the body — verify citations via `GET /content/{id}?include=sources` instead. `GET /content/{id}?include=scoring,sources` 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 (bound to the org's primary brand) | ### 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) | ### Content items | Method | Path | Purpose | |--------|------|---------| | POST | `/content/items` | Create a content item (**`Idempotency-Key` required**) | | GET | `/content` | List content items (filter by `status`, `content_type`, `property_id`, `brief_id`) | | GET | `/content/{content_item_id}` | Get an item with all versions. `?include=scoring` adds quality scores, `?include=sources` adds cited research sources; 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 | ### 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/{job_id}` | Poll job status | ### Taxonomy (full CRUD) | Resource | Paths | |----------|-------| | Pillars | `GET`/`POST` `/pillars` · `GET`/`PATCH`/`DELETE` `/pillars/{pillar_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` 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`. ### 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" (default) | "inline" } ``` `citation_mode` controls whether the generated body *shows* citations: `structured_only` (default) strips links to clean prose; `inline` keeps inline links and appends a Sources section. 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": ["...", "..."] } ``` ### Create a brief — `POST /briefs` Closed allowlist (unknown keys → `422`); `brand_id`/`status` are server-set. ``` { "title": "...", // required, 1–500 chars "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` ```json { "id": "…", "title": "…", "slug": "…", "status": "draft", "brand_id": "…", "property_id": "…", "author_id": "…", "versions": [ { "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" } ] } ] } ``` `scoring` and `sources` are **opt-in and additive**. Without the `include`, both are `null`. With `?include=sources`, `sources` is `[]` when a version cited nothing (or was generated before source capture existed — no backfill). 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`. `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" | 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"}).json() print(item["versions"][-1]["markdown_body"]) ``` --- ## Not in v1 yet (roadmap — design around these) - **Publishing / scheduling to a connected CMS** — not exposed; publish from the dashboard for now. (This is why headless generation for a no-CMS brand lands in `awaiting_review`.) - **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. - **Pillar-strategy endpoints** — a separate upcoming capability. ## 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.