BorkerBorker Docs
Agent API & MCP

API Reference

Every /api/v1 endpoint — auth, conventions, request and response shapes, scopes, and error codes.

Conventions

  • Base URL: https://borker.xyz/api/v1. Tokens are environment-bound: bork_live_… for production, bork_test_… elsewhere.
  • Auth: every request sends Authorization: Bearer <agent key>. A logged-in browser session also works on v1 routes (handy for poking around), but agents should always use a key.
  • JSON in, JSON out. Send Content-Type: application/json with bodies. Responses use camelCase fields and ISO-8601 UTC timestamps.
  • Errors share one shape: { "error": { "code": "...", "message": "..." } } — see the error codes table.
  • Rate limits: see Rate limits. A 429 tells you exactly how long to wait.
  • Idempotency: POST /content with body takes an optional Idempotency-Key header (1 to 255 characters). A retry with the same key within 24 hours answers 200 with the draft the first call created, same id, and creates nothing; a different key creates a new draft, and keys never match across workspaces. A prompt generation takes no key and is refused with 400 validation_error if it sends one.
  • Versioning: additive changes land in v1 without notice; breaking changes would ship as a /api/v2. One exception, dated and described in the changelog: the v3 release changed GET /brand and GET /schedule in place, made POST /content name its channel in both modes (a prompt generation, and a body draft that used to accept platform instead), and made approving need a connected channel (PATCH /content/:id with status: "pending", and POST /content/:id/schedule).

Scopes at a glance

EndpointMethodScopeRole
/channels, /brand, /schedule, /statsGETreadany member
/content, /content/:idGETreadany member
/contentPOSTwriteeditor+
/content/:idPATCH, DELETEwriteeditor+
/content/:id/schedulePOSTwriteeditor+
/mediaGETreadany member
/media, /media/uploadsPOSTwriteeditor+
/sources/supplyPOSTwriteeditor+
/keys, /keys/:idGETadminany member
/keysPOSTadminadmin/owner
/keys/:idDELETEadminadmin/owner

Scopes cascade (admin ⊃ write ⊃ read). The key owner's workspace role applies on top — both checks must pass. A viewer's key can read but never write: every content write answers 403 forbidden for it, whatever the key's scopes say.

Content

GET /content

Cursor-paginated list, newest first.

Query: status (draft | pending | scheduled | publishing | published | failed | cancelled | all, default all), channelId, source (api, manual, news_reactive, redistribute, daily_generation, build_log for posts written from build-log entries), cursor, limit (1–100, default 20). Any other source value, the retired campaign included, is 400 validation_error.

curl "https://borker.xyz/api/v1/content?status=draft&limit=10" \
  -H "Authorization: Bearer $BORKER_KEY"
{
  "items": [
    {
      "id": "…", "status": "draft", "platform": "x",
      "channelId": "…", "channelName": "My Startup", "voice": "brand",
      "content": "…", "topic": "release notes", "source": "api",
      "createdAt": "2026-07-29T01:00:00Z",
      "scheduledAt": "2026-07-30T09:00:00Z",
      "flagged": false, "sensitive": false,
      "flagReasons": [], "aiIsmMatches": [], "sensitivityReasons": [],
      "createdBy": { "userId": "…", "fullName": "Sven" }
    }
  ],
  "nextCursor": "eyJjcmVhdGVkQXQi…"
}

Pass nextCursor back as cursor for the next page (null = end). createdBy.email appears only for admin-scope keys.

Every endpoint that returns a content item returns this same shape. sourceUrl, scheduledAt, publishedAt, publishedUrl and title are omitted rather than sent as null when they do not apply, so check for the key's presence. voice is the voice of the item's channel (personal or brand, see GET /channels) and null while the item has no channel; topic is what the post is about, the one you set on create or the first line of the text when you set none; title is the Paragraph title when one was set. media lists the images attached to the item, each as { id, url, format, size }, in the order they publish; it is [] when the item carries none.

Once an item publishes it carries publishedAt (when it went live) and, where the provider gives one, publishedUrl (the live post). An item that was attempted and failed has status: "failed" and no publishedAt — that is how you tell a failed publish from one that simply has not run yet.

Why a draft was held

Three fields explain the flagged and sensitive booleans. Unlike the fields above they are always present, and empty when there is nothing to report, so you can iterate them without checking for the key first.

FieldMeaning
flagReasonsWhy flagged is true. Currently only length_violation — the post is over the channel tier's character cap.
aiIsmMatchesDetected AI-isms, as { label, match }. Reported independently of flagged.
sensitivityReasonsWhy sensitive is true — either keyword: <word> or principle: <your rule>.

flagged means length only. A draft can be flagged: false and still carry several aiIsmMatches, so read the array rather than the boolean when you want to know whether the writing needs another pass:

{
  "flagged": false,
  "aiIsmMatches": [
    { "label": "delve", "match": "delve" },
    { "label": "em dash", "match": "—" }
  ]
}

At creation time these are advisory — the draft lands in the pipeline either way. They stop being advisory when the item leaves draft: approving or scheduling an item whose current text still matches AI-ism patterns requires an explicit acknowledgment (see PATCH /content/:id below). The scan runs fresh against the text at transition time, so the cheapest path is to rewrite until aiIsmMatches is empty, then approve. Editing re-runs the deterministic scans, so the annotations on the returned item are always current.

POST /content

Two modes — send exactly one of body or prompt:

Free draft (your text, no credit): { "body": "...", "channelId": "..." } (optional topic, title, scheduledAt, media). The draft still passes sensitivity and AI-ism holds.

Autopilot never covers it. Autopilot approves and publishes only the posts Borker plans itself. A post an agent creates stays a draft until a person approves it or the agent schedules it through the API, within the workspace's checks: the sensitivity hold, the length limit and URL credits.

A draft gets a time. Send scheduledAt (ISO-8601, in the future) to say when the draft should go out once approved, or leave it out and the draft takes its channel's next open time under the workspace's plan, the same rule the app's composer and reactive placement use. Either way the item stays a draft and waits for review, now at a known instant on the calendar, which the returned item reports as scheduledAt. A scheduledAt that has gone by answers 400 validation_error with "Pick a time that has not gone by yet.", and one that is not a timestamp answers "Pick a date and a time."; no draft is created in either case.

Images. media is an array in publish order, each entry { "url": "…" } (a public https image URL Borker fetches and validates) or { "id": "…" } (an upload id from POST /media or GET /media). See Media for what is accepted and how a refusal reads.

Metered generation (Borker writes, one credit per channel): { "prompt": "...", "channelIds": ["..."] }.

contentType is retired on both modes: a request that still sends it gets no error — the field is ignored and not stored, and it no longer appears in responses. What a post is shaped like is decided by the target channel; where it came from is the source field on every returned item.

Paragraph essays need a title. On Paragraph a post's title is a field of its own, not the first line of the body. A body draft with no title publishes as "Untitled", so send one whenever you target a Paragraph channel. It is accepted and ignored on platforms that have no title, so a call posting the same draft to several channels does not have to branch. A prompt generation supplies its own title and needs nothing here.

Naming the channel. Both modes accept either channelId (a string) or channelIds (an array), so you do not have to remember which spelling goes with which mode. A body draft is a single item: pass one id, and a channelIds array with more than one entry is refused rather than silently trimmed. A prompt generation fans out, one post per id. Sending both channelId and channelIds in one request is refused — a precedence rule would quietly target a channel you did not ask for.

Both modes must name their channel, with channelIds or channelId. Borker never picks a channel for you. A prompt generation that names none is refused with 400 validation_error, and nothing is generated or billed. A body draft that names none is refused the same way and no draft is created: platform is no longer read, because a draft with no channel could never be approved or scheduled. The message names every connected channel with the id to pass, and the same list comes back machine-readable inside the error:

{
  "error": {
    "code": "validation_error",
    "message": "Name a channel: X Borker @borkerhq (8c1e...), LinkedIn Sam Rivera @sam-rivera (41d2...). Pass one of these ids in `channelIds`; nothing was generated.",
    "channels": [
      { "id": "8c1e...", "platform": "x", "name": "Borker @borkerhq" },
      { "id": "41d2...", "platform": "linkedin", "name": "Sam Rivera @sam-rivera" }
    ]
  }
}

A body draft's refusal ends "Pass its id in channelId; no draft was created." instead. A workspace with no connected channel is refused the same way, with an empty channels list. The MCP catalog's generate_content makes channelIds required, and create_content_draft no longer offers platform, so schema-driven callers see the rule without reading this page. Duplicate channel ids are collapsed, so a channel is generated for at most once per request. Every channel is validated before any generation runs: if one is unusable, the whole request is refused with 400 validation_error naming it, and nothing is created or billed.

Over quota before anything is created → 403 generation_limit. If the quota runs out partway through a multi-channel request, the response is still 201 and carries a warning alongside the items that were created:

{
  "items": [{ "id": "...", "status": "draft" }],
  "warning": {
    "code": "generation_limit",
    "message": "Monthly generation limit reached: 100/100 on starter.",
    "unfulfilledChannelIds": ["..."]
  }
}

You are never billed for an item the response does not list, so treat items as the authoritative record of what a request created. Retrying after a 400 is safe; retrying after a 201 with a warning would duplicate the items you already received. When the stop was not the quota, warning.code is generation_failed and warning.message is one plain sentence naming the cause (no Anthropic key, the key refused, no credit on the Anthropic account, the writer unavailable) and what to do; the provider's own error text is never forwarded.

Both modes return 201 { "items": [...] } with source: "api".

GET /content/:id · PATCH /content/:id · DELETE /content/:id

PATCH accepts any of:

  • content — replace the text (draft/pending only; published → 409 published_immutable). The deterministic scans re-run against the saved text server-side: AI-ism matches, keyword sensitivity reasons, and length flags all clear or set to match what you saved, and the returned item carries the refreshed annotations.
  • channelId — move the item to another channel (draft/pending only, the same as content). It must be a connected channel of this workspace on the item's own platform; otherwise 400 validation_error names which ("Target channel not found in this workspace", which is also the answer to an empty or malformed id, "Cannot reassign to a disconnected channel", "Target channel must match the content's platform"), and nothing is written. The item's plan slot moves with it, and the scans re-run for the new channel. This is how a draft created with platform before v3, which has no channel, gets one. When the slot had no channel yet, giving the item one places it the way connecting an account does: it approves a draft an auto-approval voice held only for want of a channel (the text passes auto's checks again first, and the approval is recorded as your key), and it queues an approved item whose plan slot had no channel at the slot's planned time. The returned item says where it landed (status, scheduledAt); when the planned time has already passed the item stays approved with nothing queued, and the response carries plannedTimePassed: true. Sent with status: "draft", the un-approve happens first, so the item moves as a draft and nothing is queued.
  • status — "pending" (approve), "draft" (un-approve), "cancelled" (reject, optional rejectionReason). Anything else, including "published", → 400 invalid_status_transition. On a scheduled item, "pending" unschedules it: the time is cleared, a held URL credit is released, and nothing is queued again; to move it, use POST /content/:id/schedule.
  • scheduledAt — with status: "pending" on a draft, the ISO-8601 instant (in the future) to queue the approved post at. A time that has gone by answers 400 validation_error with "Pick a time that has not gone by yet." before anything moves, the text edit in the same call included.
  • acknowledgeAiIsms — true to approve an item whose current text still matches AI-ism patterns. Without it, a status: "pending" on such an item returns 409 with acknowledgmentRequired: true and the exact aiIsmMatches in the error envelope. Rewrite the text until the matches clear (the better fix), retry with acknowledgeAiIsms: true, or add the label to the workspace allowlist in Settings. The acknowledgment binds to the exact text — editing after approval re-asks on the next transition.

Approving needs a connected channel. A draft with no channel answers 400 validation_error with "This post has no channel yet, so give it one before approving it.", and a draft on a disconnected channel answers the same code with "This post's channel is disconnected, so move it to a connected channel first." Nothing is written, a text edit sent in the same call included; the item stays a draft. Send channelId with a connected channel, in the same call or before, and the approval goes through: the channel check reads the channel the item will have once the call is done.

Approving queues the post. It goes out at the scheduledAt you passed, else at the instant the plan gave it, else at the plan's next open time (the rule the app's composer and reactive placement use), and the response carries that instant as scheduledAt beside the item, whose status is then scheduled. When the item's planned time has already passed and no scheduledAt was sent, the approval stands but nothing is queued: the response carries plannedTimePassed: true beside the item, and the fix is POST /content/:id/schedule with a new scheduledAt or publishNow.

DELETE removes any non-published item (published → 409 published_immutable) and best-effort cancels a scheduled external post first.

POST /content/:id/schedule

Approves (if needed) and schedules in one call:

  • {} — the time the item is already planned for (scheduledAt on the item). A draft you created through this API has one: the time it was given at creation. An item with no planned time answers 400 validation_error, and the fix is to send scheduledAt or publishNow.
  • { "scheduledAt": "2026-08-01T15:00:00Z" } — specific time, snapped to 5 minutes, must be in the future
  • { "publishNow": true } — goes out on the next publish tick
  • acknowledgeAiIsms: true — required when the item's current text matches AI-ism patterns, same 409 contract as PATCH /content/:id.

Requires the item to have a connected channel: no channel ("This post isn't on one of your channels yet, so pick a channel and try again."), or a disconnected one ("This post's channel is disconnected, so move it to a connected channel first."), answers 400 validation_error. Give it a connected channel with PATCH /content/:id and channelId, then schedule. X posts with links may return 402 x_url_credit_required when the workspace is out of URL credits.

Approving an item you created (here or via PATCH /content/:id with status: "pending") adds it to the workspace's plan for that week, so it counts toward the weekly posting target and the next planning run writes one fewer post of its own.

Returns { "item": … } in the same shape as every other content endpoint.

Media

An agent attaches images to a draft through media on POST /content and PATCH /content/:id. Each entry is { "url": "…" } or { "id": "…" }, in publish order; on PATCH the list replaces the item's images and [] clears them (draft and pending items only). Every image, whichever way it arrives, is checked the way an upload from the app is: the bytes decide the format (JPEG, PNG, GIF or WebP; AVIF is converted, HEIC is refused), 4.5MB at most. The item's media field lists what is attached, as { id, url, format, size }.

All or nothing. When any image fails, the whole call fails with 400 validation_error, the message names the image and the reason, nothing is created, and any upload the call had already made is removed again:

{ "error": { "code": "validation_error",
  "message": "Image 2 (http://169.254.169.254/x.png) could not be used: that address is not a public web address, so Borker does not read it. No draft was created." } }

Per-platform caps are checked at attach, before anything is fetched: X takes 4 images, LinkedIn 9, a LinkedIn page 1, Farcaster 2. A longer list is refused in one sentence ("Farcaster posts carry at most 2 images and 3 were given. No draft was created.").

By URL. { "url": "https://…/photo.png" } must be a public https or http address. Borker fetches it server-side under its address guard: private, loopback, link-local and cloud-metadata ranges are refused before any connection (also when a hostname resolves there, and on every redirect), the read has a 10-second deadline, and an answer that is not a 2xx image is refused by status.

By id. { "id": "…" } names an upload Borker already holds and no post has taken, from any of the three routes below. An id that is not this workspace's unattached upload answers "no upload with that id is waiting in this workspace; list_media shows the ones that are".

GET /media

{ "media": [{ id, filename, format, size, createdAt, expiresAt }] }: the uploads no post has taken yet, newest first (the MCP tool list_media). An upload nobody attaches within 24 hours of createdAt is removed, object and row, by the daily housekeeping; expiresAt says when.

POST /media

Multipart, one field named file. Answers 201 { "media": { id, filename, format, size, createdAt, expiresAt } }, a ready upload whose id goes into media. For scripts and n8n:

curl -X POST https://borker.xyz/api/v1/media \
  -H "Authorization: Bearer $BORKER_KEY" \
  -F "file=@photo.png"

POST /media/uploads

The MCP tool request_media_upload's route. { "filename": "photo.png" } answers 201 { "mediaId", "uploadUrl", "expiresAt" }: a signed, single-use, workspace-bound link, good for 15 minutes, that a shell-capable agent sends the file to with an HTTP PUT and no header:

curl -T photo.png "$UPLOAD_URL"

The bytes are validated when they arrive; the answer is 200 { "media" } and mediaId is attachable from then on. The same link a second time answers 409 validation_error ("This upload link was already used or has expired. Request a new one with request_media_upload."); a link Borker did not issue answers 401; bytes that are not an accepted image answer 400 with the format sentence; a body over 4.5MB answers 413.

{ "filename": "photo.png", "data": "<base64>" } skips the link for a small picture: the bytes (1MB at most, decoded) are validated and stored now, and the answer is 201 { "media" }. Larger inline data answers 400 validation_error ("Inline data is capped at 1MB; for a larger image request an upload link (leave data out) and send the file to it.").

Attaching and removing images are provenance events on the item (media_attached, media_removed), recorded as the key that did it.

Build log

POST /sources/supply

The MCP tool supply_entry's route. Sends one build-log entry: a piece of work the person did and confirmed is worth sharing. A skill or agent sends it; the person plans entries in Borker, on the Sources page.

{
  "source": "build-log",
  "title": "Day 12: one voice screen",
  "body": "Tone sliders, terminology lists and traits all went. ...",
  "happenedAt": "2026-08-23",
  "externalId": "session:7c1d#12",
  "kind": "reversal",
  "context": "Last month the settings were split across three screens.",
  "skillVersion": "0.3.0",
  "destinations": ["4b0c2e5a-9d1f-4c3e-8a7b-2f6d1e0c9b8a"]
}
FieldRequiredWhat it is
sourceyesThe handle of the source, up to 80 characters. The first entry for a handle creates the source, named from the handle (build-log shows as "Build log"), with the voice You.
titleyesThe entry's title, up to 200 characters.
bodyyesWhat happened, in full: at least 40 characters.
happenedAtyesThe date the work happened (2026-08-23) or a full ISO timestamp. Orders the series.
externalIdnoYour own stable id for the entry, up to 255 characters. Makes a retry safe.
kindnoWhat kind of beat the entry is: finding (something found out), decision (a decision made), ship (something shipped) or reversal (something taken back or changed). Borker shapes the post by it.
contextnoUp to 600 characters: what was going on, why it mattered, who it is for. Background for Borker's writer only, never posted verbatim.
skillVersionnoUp to 20 characters: the version of the skill or agent sending the entry, e.g. 0.3.0. Borker keeps the last one on the source, with when the last entry arrived.
medianoAt most one image, [{url}] or [{id}], the shape POST /content takes. Borker keeps it until the entry's day and puts it on every post written from the entry. An image that cannot be used fails the call with the same 400 validation_error sentence POST /content answers (field: "media"), and nothing is supplied.
destinationsnoThe channel ids from GET /channels the entry goes to, each channel once. Name one by default, several only when the entry belongs on more than one. Borker writes one post per named channel, each on that channel's next free posting day, in that channel's voice. With none named, the entry goes to the channel with the next free day.

It answers 201 { "id", "position", "state", "kind", "context", "planned", "notPlanned", "paces", "destinations" }, with kind and context as stored (null when not sent) and destinations as stored ([] when none was named). position is the entry's place in its series, counted by happenedAt and then by arrival, so an entry sent late for an earlier day takes its place by date.

A supply places nothing: a new entry arrives with state not_yet_planned, and planned and notPlanned are both null. A source paces its new entries by default (the person can switch that off in Settings, Sources), and then paces is true: Borker's night run places each new entry, oldest day first, on the next free day of the workspace's channels, and the channel decides the voice. With pacing off, paces is false and the entry waits on the Sources page for the person to plan it.

An externalId the source already holds answers 200 with that entry, unchanged, its first destinations included, and creates nothing. Once that entry is planned, state is planned and planned is { "date": "2026-10-15", "time": "09:30" }, the workspace's own day and time.

No AI runs and no generation credit is used at supply: Borker writes a post from an entry on the day it is planned for. A handle that a feed of the workspace already uses is refused with 400 validation_error and "field": "source"; a missing title, a short body or a happenedAt that is not a date, a kind outside the four, a context over 600 characters or a skillVersion over 20 characters each answer 400 validation_error naming the field in the message and in error.field. So does a destinations that is not a list of ids, names one channel twice or names an id that is not one of the workspace's channels ("field": "destinations"), and nothing is supplied.

Workspace reads

GET /channels

{ "channels": [{ id, platform, provider, displayName, handle, accountType, voice, subscriptionTier, isDefault, isActive, connectedAt }] }. Optional ?platform= filter.

voice is which of the workspace's two voices the channel writes in: personal speaks as the founder, brand as the company. It is resolved the way generation resolves it (a LinkedIn page is always brand, and a legacy value in accountType does not change the answer), so read voice and treat accountType as the raw stored value. The per-voice settings in GET /schedule apply by it: voices.you governs the personal channels, voices.brand the brand ones.

GET /brand

{ "profile": { name, tagline, motto, mission, whatWeAre, primaryUrl, aboutYou: { facts, inYourWords, you: { facts, inYourWords }, brand: { facts, inYourWords } }, standingRules: [{ name, brief, scope }], voiceConfidence: { pieces, target, thin }, excludedTopics } } — or "profile": null before onboarding creates one. aboutYou.facts is the canonical fact block: the only claims Borker makes about the workspace, and the only ones an agent should make. A workspace has two voices, so each fact belongs to one of them: facts and inYourWords are everything and the paragraph written for both, while aboutYou.you and aboutYou.brand carry the half that belongs to the person and the half that belongs to the brand. A claim listed only under brand is not a claim to make from the founder's own account. The tagline and mission are brand claims too: Borker stores each as a brand fact as well as a profile field, and serves it once, as the field, rather than repeating it inside aboutYou. The other prose fields are the workspace's own words about itself; treat them as voice, not as licence to assert anything the facts, the tagline and the mission do not say. standingRules are the confirmed rules for how the writing sounds, scoped global, you (the founder) or brand; suggestions awaiting confirmation are not served — a rule is suggested, confirmed, or removed, nothing else. voiceConfidence.thin means at least one of the workspace's two voices — its own and its brand's — has less writing on file than Borker wants before it claims to write in that voice; drafting continues either way. excludedTopics is the workspace's live avoid list — the subjects its content strategy tells Borker to stay out of, the same list drafting and source matching obey.

GET /schedule

{
  "config": {
    "timezone": "Europe/Berlin", "approvalMode": "manual", "defaultModel": null,
    "sensitivityKeywords": [], "aiIsmAllowlist": [], "matchThreshold": 70,
    "voices": {
      "you": { "approvalMode": "manual", "sensitivityKeywords": [], "aiIsmAllowlist": [] },
      "brand": { "approvalMode": "auto", "sensitivityKeywords": [], "aiIsmAllowlist": [] }
    }
  },
  "cadence": [
    { "channelId": "…", "platform": "x", "voice": "brand",
      "cadence": "most_days", "cadenceLabel": "Most days", "weeklyPosts": 5, "enabled": true }
  ],
  "planned": [
    { "id": "…", "channelId": "…", "platform": "x", "targetDate": "2026-10-03",
      "targetTime": "09:00", "state": "written", "contentId": "…" }
  ]
}

Read-only — other methods answer 405. config is null until onboarding writes one.

cadence has one row per connected channel: how often Borker writes for it (cadence is one of off, weekly, twice_weekly, most_days, daily, with cadenceLabel as the app spells it and weeklyPosts the number it plans to) and whether that pulse is on. A channel the strategy has not given a pulse yet reads as off. voice is the channel's voice, as in GET /channels.

planned is the plan for the coming 14 days, in the workspace's timezone: each row is one post Borker intends to write or has written, with its channel, target date and time, state (planned until written, then written, skipped when the person took it out) and contentId once the post exists, so you can fetch it with GET /content/:id. An item you create and approve enters this window as a plan item of its own.

matchThreshold is the relevance a source item has to clear, out of 100, before Borker reacts to it the same day.

The three gate settings follow the voice. approvalMode, sensitivityKeywords and aiIsmAllowlist at the top level are the answers that apply to both voices; voices.you and voices.brand carry each voice's own, with approvalMode there resolved already (a voice with no answer of its own reports the workspace one). A draft is judged by the shared settings plus those of the voice on its channel (voices.you for a channel whose voice is personal, voices.brand for brand), so the brand account can publish on its own while the founder's posts wait for review.

Gone with the v3 release, under the one-release exception in Conventions: the slots array (v3 writes no calendar slots; the plan above replaces them) and the knobs maxPostsPerDay, minHoursBetween, queueBufferDays, reactiveMinRelevance and autoApproveTypes, which nothing reads any more. An agent that sized its week from maxPostsPerDay should read cadence instead.

GET /stats

Workspace analytics over ?days= (1–365, default 30):

{
  "windowDays": 30,
  "channelBalance": [
    { "channelId": "…", "platform": "x", "displayName": "My Startup",
      "counts": { "draft": 2, "published": 8 }, "total": 10 }
  ],
  "pipeline": { "byStatus": { "draft": 3 }, "sourceMix": { "api": 2 }, "total": 12 },
  "aiIsm": { "flagged": 1, "total": 12, "rate": 0.083 },
  "usage": { "current": 42, "limit": 500, "plan": "pro", "periodEnd": "…" }
}

Keys

All /keys routes require admin scope on the calling token.

  • GET /keys — { "keys": [...] }, active and revoked, never a secret.
  • GET /keys/:id — one key.
  • POST /keys (admin/owner) — { "name": "...", "scopes": ["read"|"write"|"admin", …] } → 201 including token, the only response that ever contains it.
  • DELETE /keys/:id (admin/owner) — revoke, idempotent. A token revoking itself → 400 cannot_revoke_self_in_request.

Rate limits

Limits are per agent key, counted in a rolling 60-second window.

What you're callingLimit
POST /content (both modes)10 / min
Every other v1 endpoint60 / min
Unauthenticated requests, or a bearer that doesn't resolve200 / min per IP address

POST /content is tighter than the rest because both of its modes can make Borker's AI run — the prompt mode by generating, and the body mode via the sensitivity check when your workspace has principles configured. It is 10/min even when a given call happens not to invoke a model, so the limit is predictable rather than depending on your workspace's configuration.

Over MCP the same limits apply, and a refusal is reported before the tool runs. Discovery calls (initialize, tools/list, ping) get their own 60/min per address so a client can connect without a key.

What a 429 tells you

HTTP/1.1 429
Retry-After: 42
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785445800

{ "error": { "code": "rate_limited",
             "message": "Rate limit exceeded. Retry after 42 seconds." } }

Wait Retry-After seconds and retry. X-RateLimit-Reset is a Unix timestamp for when the window rolls over. Over MCP the same figure arrives as error.data.retryAfterSeconds.

These headers are sent only on a 429, not on successful responses — so you cannot currently watch X-RateLimit-Remaining to pace yourself in advance. Space bursts out, and treat the 429 as the signal to back off.

Error codes

Every failure — from a REST route or from an MCP tool — uses one envelope:

{ "error": { "code": "validation_error", "message": "…" } }

code is stable and safe to branch on; message tells you what to do about it. Some errors add machine-readable detail alongside them inside error, never as a second top-level key.

Over MCP the same envelope arrives as the JSON-RPC error.data.code when the failure is about your credentials, and as the tool result text otherwise — see MCP.

CodeStatusMeaningWhat to do
missing_authorization401No Authorization header was sent, and no browser session eitherSend Authorization: Bearer <key>
invalid_api_key401Token missing, malformed, wrong environment, revoked, or its owner left the workspaceStop retrying; get a fresh key
subscription_inactive402Workspace subscription expiredSurface to the operator; retrying cannot clear it
x_url_credit_required402Scheduling an X post with a link without URL creditsRemove the link and reschedule, or surface the shortfall
forbidden403Key owner's workspace role is below the route's requirementDo not retry; the owner's role must be raised
scope_required403Key's scopes don't cover the routeUse a key with the scope named in the message
generation_limit403Monthly generation quota exhaustedStop generating; free drafts still work. Quota resets at usage.periodEnd
workspace_suspended403Workspace is suspendedStop and surface to the operator
not_found404No such resource in this workspaceRe-list to get current ids
workspace_not_found404The workspace behind this key is goneStop; the key is orphaned
published_immutable409Edit/delete attempted on a published itemCreate a new item instead
validation_error (with acknowledgmentRequired: true)409Approve/schedule on text that matches AI-ism patterns, without acknowledgeAiIsmsRewrite until aiIsmMatches clears, or retry with acknowledgeAiIsms: true
validation_error400Bad input — the message names the parameterCorrect that parameter and retry
missing_input400POST /content needs exactly one of body / promptSend exactly one
invalid_status_transition400Lifecycle move the state machine forbidsRe-read the item's actual status first
cannot_revoke_self_in_request400A token tried to revoke itselfRevoke from Settings, or use a different admin key
rate_limited429Too many requests for this key or addressBack off for Retry-After seconds
rate_limited_key429Too many tool calls on this agent key (MCP only, in error.data.code)Back off for error.data.retryAfterSeconds; the address is not the problem
rate_limited_ip429Too many discovery calls, or calls with no usable key, from this address (MCP only)Back off for error.data.retryAfterSeconds; a valid key moves you to the per-key limit
unknown_tool—No tool of that name (MCP only)Call tools/list and use a name from it
publish_failed502The publishing provider rejected the scheduleRe-read the item; it will be in the failed state
transition_failed500Accepted but could not be completedRe-read the item to establish its actual state
internal_error500Something broke on our side. Every unexpected failure on a v1 route, a thrown exception included, arrives in this envelope with this codeRetry once, then contact support
capacity_unknown503The cap this call is measured against could not be read, so no verdict was reachedRetry after a short pause; this is not a quota refusal and credits would not clear it

Cross-workspace access doesn't get a special error: another workspace's resources are simply 404 not_found, and lists only ever contain the key's own workspace.

Changelog

  • 2026-10-21 — POST /sources/supply and supply_entry take an optional destinations: the channel ids an entry goes to. The night run writes one post per named channel, each on that channel's next free day, and the answer carries destinations as stored. An id that is not one of the workspace's channels answers 400 validation_error naming destinations. Additive.
  • 2026-10-13 — POST /sources/supply places nothing: Borker's night run places new entries, and the answer carries paces (whether it will). planned is set only on a retry of an entry already planned, and notPlanned is always null. Additive.
  • 2026-10-13 — POST /sources/supply and supply_entry take an optional skillVersion, kept on the source. A source paces new entries by default, and the answer carries planned (the day and time) or notPlanned (why not). Additive.
  • 2026-10-13 — POST /sources/supply and supply_entry take two optional fields, kind and context, and answer them back. Additive.
  • 2026-10-13 — POST /sources/supply and the MCP tool supply_entry: a skill or agent sends build-log entries, which arrive unplanned on the workspace's Sources page, each with at most one image in media, kept for the entry's day. GET /content and list_content accept source=build_log. Additive.
  • 2026-10-02 — GET /schedule returns the v3 shape. This is a breaking change to a v1 payload, under the same one-release v3 exception as the GET /brand change below: slots, maxPostsPerDay, minHoursBetween, queueBufferDays, reactiveMinRelevance and autoApproveTypes are gone; cadence, planned and config.matchThreshold are new. Additive in the same release: voice on channels and items; topic and title on items; scheduledAt on a body draft, which otherwise takes its channel's next open time; media on POST /content and PATCH /content/:id, plus GET /media, POST /media and POST /media/uploads; a v1 request with no Authorization header and no session answers 401 missing_authorization, and an unexpected failure inside a v1 route answers 500 internal_error, both in the v1 envelope; GET /brand drops examples and stops repeating the tagline and mission inside aboutYou; the campaign source filter is refused; rate_limited_key and rate_limited_ip join the catalogue. See the release note.
  • 2026-09-25 — A body draft must name its channel, and a draft can be given one. This is a breaking change to a v1 request, shipping with the v3 release under the same one-release exception as the GET /brand change below. Before, POST /content with body and platform but no channelId wrote a draft with no channel. Now it is refused with 400 validation_error, naming the connected channels and their ids in the message and in error.channels, and no draft is created; platform is no longer read. The MCP tool create_content_draft no longer offers platform. Additive in the same release: PATCH /content/:id (and MCP update_content) takes channelId, so a draft created the old way is given a connected channel and then approved.
  • 2026-09-25 — Approving needs a connected channel. This is a breaking change to a v1 request, under the same v3 exception. PATCH /content/:id with status: "pending" on a draft with no channel, or on a disconnected one, answers 400 validation_error with a sentence naming the cause, and POST /content/:id/schedule refuses a disconnected channel the way it already refused a missing one. Before, such a draft was approved and waited with nowhere to go out. The MCP tools update_content and schedule_content answer the same. The fix is a connected channel through PATCH /content/:id with channelId.
  • 2026-09-25 — POST /content with a prompt must name its channels. This is a breaking change to a v1 request, shipping with the v3 release under the same one-release exception as the GET /brand change below. Before, a prompt with no channelIds or channelId was written for the workspace's single active channel per platform. Now it is refused with 400 validation_error, naming the connected channels and their ids in the message and in error.channels, and nothing is generated or billed. The MCP tool generate_content now lists channelIds as required. An agent that sent a prompt alone should call list_channels and pass the ids.
  • 2026-09-01 — GET /brand returns the v3 voice profile. This is a breaking change to a v1 payload, and the versioning rule above says breaking changes ship as /api/v2. The v3 release is a single hard cutover of code and data together, so there is no version of the product in which both shapes are true and no /api/v2 to move to; the rule is suspended for this one release and resumes after it. What changed: voice (the five 1-10 attributes), terms, traits, principles and industry are gone, because the settings that produced them are gone from the product. aboutYou (the canonical fact block), standingRules and voiceConfidence are new and carry what those fields were for. name, tagline, motto, mission, whatWeAre, primaryUrl, excludedTopics and examples are unchanged. An agent reading profile.voice.formality should read profile.standingRules instead.
  • 2026-08-16 — AI-ism acknowledgment gate (BORK-496): approving or scheduling an item whose current text matches AI-ism patterns returns 409 with acknowledgmentRequired: true + the match list unless the request carries acknowledgeAiIsms: true. Edits via PATCH /content/:id and update_content re-run the deterministic scans and return refreshed annotations.
  • 2026-07-29 — v1 launched: agent keys, content lifecycle, workspace reads, stats, and the hosted MCP endpoint.

On this page