ShapelessAI← Back
Interfaces

API reference

Every route an API key opens, the scope each one needs, and the routes that refuse a key on purpose.

Verified
View as Markdown

Everything an agent can reach over HTTPS, and the scope each route needs. The studio in a browser rides a signed session cookie; an agent outside that browser rides an API key - the same account, the same routes, a second credential.

curl -H "Authorization: Bearer slk_..." https://shapelessai.com/api/me

Minting a key, the three scopes and the refusal codes are on Authentication. Putting a post out is on Posts. This page is the whole surface.

Machine-readable

curl -s https://shapelessai.com/api/openapi.json

OpenAPI 3.1 for every key-accepting route, no auth. Point a generator at it rather than reading this page, if that is what you are doing. The rendered operation list is at the bottom of this page.

Paging

The thread lists - GET /api/conversations and GET /api/v1/jobs - answer 200 rows per page, newest first, alongside a nextCursor. Non-null means older rows exist: send it back verbatim as ?before=<nextCursor> for the next page, and keep going until it is null. The cursor is the last row's sort key (<updatedAtISO>~<id>), so a thread touched while you page is never skipped or served twice; anything else is a 400. ?origin= on the jobs list filters the page it answers, so a page can carry fewer than 200 jobs and still have a cursor - follow the cursor, not the count.

Routes that accept a key

Every row is enforced in the codebase by src/server/auth/route-scopes.test.ts: if a route and this table disagree, the build fails.

Identity and accounts

MethodPathScopePurpose
GET/api/mereadWho am I, plan, credit balance.
GET/api/connectionsreadConnected social accounts (identity only, no tokens).
GET/api/ad-accountsreadConnected ad accounts (identity only, no tokens) + whether connecting one is configured.
DELETE/api/ad-accounts?id=publishUnlink an ad account. Costs publish: it ends our ability to spend.

Chat

MethodPathScopePurpose
POST/api/studiowriteOne agent turn, NDJSON stream of StudioEvents.
GET/api/studio/tailreadRe-attach to a detached run's event log.
POST/api/studio/stopwriteStop a detached run.
PUT/api/studio/attachments/[filename]writeUpload a chat attachment, returns its mediaKey.
GET/api/conversationsreadThread list, one page (see Paging). ?origin=chat,agent,job keeps only those provenances, ?q= searches titles, ?strategyId= narrows to one agent's wakes, ?limit= shrinks the page. unread counts threads that finished since they were last opened.
GET/api/conversations/[id]readOne transcript, with each post artifact's queue state. Reading it marks the thread opened.
PATCH/DELETE/api/conversations/[id]writeRename or delete a thread.
POST/api/conversations/[id]/openedwriteMark a thread opened without reading it (clears its unread state).

Jobs

The durable alternative to stream plumbing. A job is a conversation whose ids the server mints: you send a goal, get a 202 with the job's id, and the run keeps going whether or not you stay connected. Poll the job for its transcript and outputs, or tail the live stream. Posting another message to a job builds the conversation history server-side from the stored transcript, so sending "continue" to a failed or stuck job resumes it.

MethodPathScopePurpose
POST/api/v1/jobswriteStart a job: {goal, label?, budgetUsd?, timezone?, attachments?, characters?} returns 202 {id, title, status}.
GET/api/v1/jobsreadList jobs with liveness, one page (see Paging). ?origin=job|chat|agent filters by who started the thread.
GET/api/v1/jobs/[id]readOne job: enriched transcript, status/live, and an outputs summary (posts with queue state, media).
POST/api/v1/jobs/[id]/messageswriteAnother turn on the job: {text, budgetUsd?, timezone?, attachments?} returns 202 {id, status}.

A running job streams into the same event log chat uses: follow it with GET /api/studio/tail?conversationId=<id>, stop it with POST /api/studio/stop. The label becomes the job's title; without one the goal's first line is. Refusals are the plan turn's: 402 when the month's free credits are spent, 429 when planning too fast, 400 for an attachment the account cannot use.

Attaching files

attachments puts files on the message itself - the same thing the web composer's paperclip does, and the agent sees them: an image's pixels are inlined for that turn, video and PDF go over by storage URI, and the media key is named in the text so it survives in history after the pixels are gone. Up to 6 per message; each entry needs a name and either text (inlined, 24k characters) or a mediaKey:

{
  "goal": "Does this thumbnail work for the launch post?",
  "attachments": [
    { "name": "thumb.png", "mediaKey": "chat-uploads/<account>/thumb-mt1z.png", "contentType": "image/png" },
    { "name": "notes.md", "text": "Launch is Thursday. Tone: plain, no hype." }
  ]
}

Get a mediaKey by uploading the bytes to PUT /api/studio/attachments/[filename] (10MB images, 30MB video/PDF; filenames are [A-Za-z0-9._-]). Any media key the account owns also works - a brand asset from workspace-assets/, media an earlier run produced - because the server copies it into this account's chat-uploads/ prefix before the turn, which is the only prefix the engine reads. A key belonging to another account is a 400 naming the file, not a run that dies halfway. A malformed entry is a 400 too: attachments are never dropped silently.

Posts

MethodPathScopePurpose
GET/api/postsreadThe queue: proposed, scheduled, published. ?status= and ?limit= narrow it.
POST/api/postspublishPut a post on the rail: at scheduledAt, in the account's next free slot (queue: true), or now.
GET/api/posts/[id]readOne row's live state.
PATCH/DELETE/api/posts/[id]writeEdit or cancel a still-scheduled post (time, text, media, settings, firstComment, connectionId on the same platform).
GET/api/platformsnoneEvery platform the rail posts to: limits, media rules, settingsSchema, first-comment support.
GET/api/openapi.jsonnoneThe OpenAPI 3.1 document for every route in this file.
GET/PATCH/api/connections/[id]/queueread / writeThe account's posting slots and timezone, and the next free slot.
POST/api/posts/[id]/approvepublishApprove one proposal: proposed -> scheduled.
POST/api/posts/[id]/publishpublishPublish a queued post now.
POST/api/posts/[id]/mark-postedpublish"I posted it myself".
POST/api/posts/[id]/revisewriteRework a draft from feedback.
POST/api/posts/[id]/boostpublishPropose a paid boost of a published Meta post.
GET/api/posts/[id]/commentsreadThe thread under a published post, with each drafted reply.
POST/api/posts/[id]/commentspublishStage your own reply to one comment.
POST/api/posts/[id]/comments/refreshpublishRead the thread from the platform now.
POST/api/posts/resolvepublish (approve) / write (dismiss)Resolve a batch of proposals.
GET/api/inboxreadEverything waiting on a human decision.

POST /api/posts takes {connectionId, postText, mediaKeys?, documentTitle?, scheduledAt?, queue?, settings?, firstComment?}. scheduledAt omitted or in the past publishes now; queue: true takes the account's next free posting slot instead (GET /api/connections/[id]/queue shows the slots). settings follows the platform's settingsSchema from GET /api/platforms (TikTok privacyLevel, Reddit subreddit, YouTube privacyStatus); documentTitle is required on YouTube. firstComment is posted as the post's own first reply the moment it is live: LinkedIn (1250 chars), X (280), Bluesky (300); other platforms answer 422. A platform rule that refuses the post answers 422 with the rule in error. The Free plan posts five a day, counted on the UTC day of the slot: the sixth answers 402 {code: "free_daily_cap", limit, day, resetsAt}. Paid plans have no cap. The row's firstCommentUrl / firstCommentError say what became of the comment.

GET /api/posts returns the newest 50 rows by slot (?limit= up to 200), narrowed server-side by ?status= (any of proposed, scheduled, publishing, published, handoff, failed, canceled). GET /api/posts/[id] returns the same shape for one row. Two fields close the loop between what an agent meant and what happened:

  • inspiration - what the post was made from: {kind, id, lane?, title?, url?} where kind is format-card, topical-story or reference-post. Set it on a delivery through the artifact's inspiredBy meta (the object, or a JSON string of it); anything malformed is dropped, never an error. null when nobody said.
  • metrics - how a published post did, null for anything not published and for a published post we hold no reading for yet: {latest, at24h, at7d, lift, baseline: {platform, n, median, scoredBy}}. Each sample is {fetchedAt, impressions, reactions, comments, shares}; at24h and at7d are the first readings taken at or after that age, null until one exists. Sampling slows as a post ages, so on a sparsely sampled post the "24h" reading can be days late - fetchedAt on the sample is what says how late, and it is on every sample for exactly that reason. Lift is this post's score divided by the median score of the same account's published posts on the same platform over the trailing 90 days
    • 2 means it did twice its channel's median. It is null when fewer than five scored posts stand behind that median (baseline.n says how many do), because a two-post median invents findings. baseline.scoredBy is impressions where the platform reports reach and engagements where it does not - the same rule the Performance screen ranks by.

/api/posts/[id]/boost takes {budgetCents, days, objective} (engagement or traffic) and spends nothing: it creates a proposed boost action, and only POST /api/actions/[id]/send on that action creates the Meta campaign. The budget is the campaign's lifetime cap, billed by Meta to the connected ad account. It refuses (402) without an active paid plan (the Free plan does not qualify), and (409) when the post is not a published Facebook Page or Instagram post with a known platform id, or when no active Meta ad account is connected.

GET /api/posts/[id]/comments answers {postId, platform, comments[], lastReadIso} from our own ledgers - it never calls a platform. Each comment carries {id, authorHandle, text, url, atIso, isOwn, replyVerdict, reply}, where reply is the drafted action bound to it ({actionId, text, status, verdict, error, url, sentAtIso}) or null. POST with {commentId, text} stages that reply as a proposed reply action - the same row the approvals inbox holds - which POST /api/actions/[id]/send then sends; it refuses (409) when the platform has no sanctioned reply path for that comment (replyVerdict: "task"). /comments/refresh runs the same engagement poll Cloud Tasks fires after publishing, and only spends credits when there is a new comment to draft a reply to.

Agents (strategies) and the manager

MethodPathScopePurpose
GET/api/strategiesreadThe agent roster.
POST/api/strategieswrite, or publish with autoPublishPostsCreate an agent.
PATCH/api/strategies/[id]write, or publish with autoPublishPostsEdit an agent.
POST/api/strategies/[id]/runpublishWake an agent now.
POST/api/strategies/suggestwriteOne engine-studied agent seed for the editor.
POST/api/manager/runpublish"Plan now" through the first active agent.
GET/api/manager/runs, /api/manager/runs/[id]readWake runs and their proposals.
POST/api/manager/runs/[id]/approvepublishApprove a run's proposals.
POST/api/manager/runs/[id]/dismisswriteDismiss a run with feedback.
GET/PUT/api/manager/settingsread / writeCadence and targeting.
GET/api/manager/budgetreadSpend, allowance, paused state.

Characters (actors)

An actor is workspace-level identity - a face, a voice, a style, a memory - that posts and agents CAST. characters on a job is that cast, by slug or id; the agent is shown the account's whole roster either way, so it can reuse an actor that fits instead of inventing another one. A ref that names nobody is a 400 naming it, never a run that silently stars nobody.

MethodPathScopePurpose
GET/api/charactersreadThe roster: identity, voice (kind + the direction a designed one was written from), locks.
POST/api/characterswriteCreate one: {name, bio?, isUser?}.
GET/api/characters/[id]readOne actor.
PATCH/api/characters/[id]writeEdit it, including the three locks.
DELETE/api/characters/[id]writeArchive it (posts and agents may still cast it).
POST/api/characters/[id]/voicewriteDesign, clone, or speak - see below.

POST /api/characters/[id]/voice takes one of:

  • {action: "design", voicePrompt} - writes a voice from casting direction. The direction is KEPT on the character and shown to the agent on every run.
  • {action: "clone", sampleMediaKey, consent: true} - clones a 5-10s sample already in the account. consent is the caller attesting they hold the rights; it is stored.
  • {action: "speak", text} - reads text in the actor's current voice, returns a mediaKey.

A creation SPENDS CREDIT: the answer carries costUsd (what the provider billed us) and the account is debited for it at the tier's rate, ledgered under voice:<design|clone>:<id>. An empty wallet is a 402 with the same trial_exhausted code a run gives - never a silent no-voice. speak is not a creation and is not billed here.

Engagement actions

MethodPathScopePurpose
POST/api/actions/[id]/sendpublishSend a drafted comment or reply, or run an approved boost.
POST/api/actions/[id]/donewriteMark a task card done.
POST/api/actions/[id]/skipwriteSkip a proposed action.

Brand Memory, assets and media

MethodPathScopePurpose
GET/api/workspacereadThe brain's file tree with its completion ring.
GET/api/workspace/file?path=readOne file.
PUT/DELETE/api/workspace/filewriteWrite or delete one file.
GET/api/workspace/exportreadThe whole brain as a zip.
POST/api/brain/scanwriteRead a link and file what it says into Brand Memory (NDJSON stream).
POST/api/brain/importwriteImport a .md/.txt/.pdf/.docx document.
GET/api/brain/assets, /api/assetsreadBrand uploads, and the full asset library.
GET/api/brain/assets/[filename]readStream one asset (Range supported).
PUT/PATCH/DELETE/api/brain/assets/[filename]writeUpload, rename, delete an asset.
GET/api/media/[...key]readStream any media key this account owns.

Analytics

MethodPathScopePurpose
GET/api/metricsreadPublished posts with their latest samples and the 90-day series.
GET/api/metrics/history?post=readOne post's accrual curve against the account median.
GET/api/metrics/followersreadFollower counts and the weekly delta.
POST/api/metrics/refreshwriteRefresh stale samples (calls the platforms).

These refuse a bearer key. It is not an oversight:

  • Key management (/api/keys, /api/keys/[id]) - a key must never be able to mint itself a wider key. A leaked read key stays a leak, not a foothold.
  • Money (/api/checkout, /api/portal) - buying and cancelling belong to the person paying.
  • Sign-in (/api/auth/*) and platform connections (every /api/connections/* OAuth start, callback, delete, and the tracked list) - these are browser redirect flows carrying platform grants. Only GET /api/connections, the identity list, takes a key.
  • Founder metrics (/api/metrics/agent-lanes*) and /metrics - gated on FOUNDER_EMAILS.
  • Webhooks and platform callbacks (/api/polar/webhook, /api/meta/*, /api/subscribe) - they carry their own signatures.
  • Internal cron (/api/internal/*) - x-internal-key, unchanged.
  • Dev (/api/dev/*) and the daily-brief routes (/api/brain/brief/*), which are browser surfaces today.

The other two faces

The CLI and the MCP server are this same contract, wrapped. There is one implementation of every operation underneath and one scope policy judging it, so anything you can do here you can do there, and the refusals are identical.

OpenAPI

62 paths, version 1. Point a generator at /api/openapi.json rather than reading this page.

  • /api/meGET
  • /api/platformsGET
  • /api/openapi.jsonGET
  • /api/connectionsGET · DELETE
  • /api/connections/{id}/queueGET · PATCH
  • /api/postsGET · POST
  • /api/posts/{id}GET · PATCH · DELETE
  • /api/posts/{id}/approvePOST
  • /api/posts/{id}/publishPOST
  • /api/posts/{id}/mark-postedPOST
  • /api/posts/{id}/revisePOST
  • /api/posts/{id}/boostPOST
  • /api/posts/{id}/commentsGET · POST
  • /api/posts/{id}/comments/refreshPOST
  • /api/posts/resolvePOST
  • /api/inboxGET
  • /api/v1/jobsGET · POST
  • /api/v1/jobs/{id}GET
  • /api/v1/jobs/{id}/messagesPOST
  • /api/studioPOST
  • /api/studio/tailGET
  • /api/studio/stopPOST
  • /api/studio/attachments/{filename}PUT
  • /api/studio/uploadsPOST
  • /api/studio/uploads/{sessionId}PUT
  • /api/studio/uploads/completePOST
  • /api/conversationsGET
  • /api/conversations/{id}GET · PATCH · DELETE
  • /api/conversations/{id}/openedPOST
  • /api/strategiesGET · POST
  • /api/strategies/{id}PATCH
  • /api/strategies/{id}/runPOST
  • /api/strategies/suggestPOST
  • /api/manager/runPOST
  • /api/manager/runsGET
  • /api/manager/runs/{id}GET
  • /api/manager/runs/{id}/approvePOST
  • /api/manager/runs/{id}/dismissPOST
  • /api/manager/settingsGET · PUT
  • /api/manager/budgetGET
  • /api/charactersGET · POST
  • /api/characters/{id}GET · PATCH · DELETE
  • /api/characters/{id}/voicePOST
  • /api/actions/{id}/sendPOST
  • /api/actions/{id}/donePOST
  • /api/actions/{id}/skipPOST
  • /api/ad-accountsGET · DELETE
  • /api/ads/performanceGET
  • /api/ads/refreshPOST
  • /api/workspaceGET
  • /api/workspace/fileGET · PUT · DELETE
  • /api/workspace/exportGET
  • /api/brain/scanPOST
  • /api/brain/importPOST
  • /api/brain/assetsGET
  • /api/brain/assets/{filename}GET · PUT · PATCH · DELETE
  • /api/assetsGET
  • /api/media/{key}GET
  • /api/metricsGET
  • /api/metrics/historyGET
  • /api/metrics/followersGET
  • /api/metrics/refreshPOST