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.
  • No idempotency keys yet: a retried POST after a network blip may create twice. Have agents check list_content before retrying creates.
  • 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 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
/contentPOSTwriteany member
/content/:idPATCH, DELETEwriteany member
/content/:id/schedulePOSTwriteany member
/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.

Content

GET /content

Cursor-paginated list, newest first.

Query: status (draft | pending | scheduled | publishing | published | failed | cancelled | all, default all), channelId, source (api, manual, campaign, news_reactive, redistribute, daily_generation), cursor, limit (1–100, default 20).

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",
      "content": "…", "source": "api",
      "createdAt": "2026-07-29T01: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 and publishedUrl are omitted rather than sent as null when they do not apply, so check for the key's presence.

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). The draft still passes sensitivity and AI-ism holds.

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.

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.
  • 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 before approving it." 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 a plan-placed item whose planned time is still ahead also queues it to publish at that time. When the planned time has already passed, 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). Nothing picks a time for you: an item with no planned time answers 400 validation_error, and the fix is to send scheduledAt or publishNow. A draft you created through this API has no planned time, so it always needs one of the two.
  • { "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 and try again."), 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.

Workspace reads

GET /channels

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

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, examples } } — 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 prose fields above it are the workspace's own words about itself; treat them as voice, not as licence to assert anything the facts 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, approvalMode, maxPostsPerDay, minHoursBetween, queueBufferDays, sensitivityKeywords, aiIsmAllowlist, voices: { you: { approvalMode, sensitivityKeywords, aiIsmAllowlist }, brand: { approvalMode, sensitivityKeywords, aiIsmAllowlist } }, … }, "slots": [{ id, dayOfWeek, timeOfDay, platform, contentType, topicCategory, recurrenceType, weekOfMonth, enabled }] }. Read-only — other methods answer 405.

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, so the brand account can publish on its own while the founder's posts wait for review.

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 sentSend 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
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 sideRetry 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-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