> **Shapeless documentation index**
> Fetch https://shapelessai.com/llms.txt to discover every page before exploring further.
> Every page also answers Markdown: append `.md` to its path, or send `Accept: text/markdown`.

# API reference

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

*Verified 2026-09-14. Source: https://shapelessai.com/docs/api*

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.

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

Minting a key, the three scopes and the refusal codes are on [Authentication](/docs/auth).
Putting a post out is on [Posts](/docs/posts). This page is the whole surface.

## Machine-readable

```bash
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

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/me` | `read` | Who am I, plan, credit balance. |
| GET | `/api/connections` | `read` | Connected social accounts (identity only, no tokens). |
| GET | `/api/ad-accounts` | `read` | Connected ad accounts (identity only, no tokens) + whether connecting one is configured. |
| DELETE | `/api/ad-accounts?id=` | `publish` | Unlink an ad account. Costs `publish`: it ends our ability to spend. |

### Chat

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| POST | `/api/studio` | `write` | One agent turn, NDJSON stream of StudioEvents. |
| GET | `/api/studio/tail` | `read` | Re-attach to a detached run's event log. |
| POST | `/api/studio/stop` | `write` | Stop a detached run. |
| PUT | `/api/studio/attachments/[filename]` | `write` | Upload a chat attachment, returns its mediaKey. |
| GET | `/api/conversations` | `read` | Thread list, one page (see [Paging](#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]` | `read` | One transcript, with each post artifact's queue state. Reading it marks the thread opened. |
| PATCH/DELETE | `/api/conversations/[id]` | `write` | Rename or delete a thread. |
| POST | `/api/conversations/[id]/opened` | `write` | Mark 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.

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| POST | `/api/v1/jobs` | `write` | Start a job: `{goal, label?, budgetUsd?, timezone?, attachments?, characters?}` returns `202 {id, title, status}`. |
| GET | `/api/v1/jobs` | `read` | List jobs with liveness, one page (see [Paging](#paging)). `?origin=job\|chat\|agent` filters by who started the thread. |
| GET | `/api/v1/jobs/[id]` | `read` | One job: enriched transcript, `status`/`live`, and an outputs summary (posts with queue state, media). |
| POST | `/api/v1/jobs/[id]/messages` | `write` | Another 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`:

```jsonc
{
  "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

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/posts` | `read` | The queue: proposed, scheduled, published. `?status=` and `?limit=` narrow it. |
| POST | `/api/posts` | `publish` | Put a post on the rail: at `scheduledAt`, in the account's next free slot (`queue: true`), or now. |
| GET | `/api/posts/[id]` | `read` | One row's live state. |
| PATCH/DELETE | `/api/posts/[id]` | `write` | Edit or cancel a still-scheduled post (time, text, media, settings, `firstComment`, `connectionId` on the same platform). |
| GET | `/api/platforms` | none | Every platform the rail posts to: limits, media rules, `settingsSchema`, first-comment support. |
| GET | `/api/openapi.json` | none | The OpenAPI 3.1 document for every route in this file. |
| GET/PATCH | `/api/connections/[id]/queue` | `read` / `write` | The account's posting slots and timezone, and the next free slot. |
| POST | `/api/posts/[id]/approve` | `publish` | Approve one proposal: proposed -> scheduled. |
| POST | `/api/posts/[id]/publish` | `publish` | Publish a queued post now. |
| POST | `/api/posts/[id]/mark-posted` | `publish` | "I posted it myself". |
| POST | `/api/posts/[id]/revise` | `write` | Rework a draft from feedback. |
| POST | `/api/posts/[id]/boost` | `publish` | Propose a paid boost of a published Meta post. |
| GET | `/api/posts/[id]/comments` | `read` | The thread under a published post, with each drafted reply. |
| POST | `/api/posts/[id]/comments` | `publish` | Stage your own reply to one comment. |
| POST | `/api/posts/[id]/comments/refresh` | `publish` | Read the thread from the platform now. |
| POST | `/api/posts/resolve` | `publish` (approve) / `write` (dismiss) | Resolve a batch of proposals. |
| GET | `/api/inbox` | `read` | Everything 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

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/strategies` | `read` | The agent roster. |
| POST | `/api/strategies` | `write`, or `publish` with `autoPublishPosts` | Create an agent. |
| PATCH | `/api/strategies/[id]` | `write`, or `publish` with `autoPublishPosts` | Edit an agent. |
| POST | `/api/strategies/[id]/run` | `publish` | Wake an agent now. |
| POST | `/api/strategies/suggest` | `write` | One engine-studied agent seed for the editor. |
| POST | `/api/manager/run` | `publish` | "Plan now" through the first active agent. |
| GET | `/api/manager/runs`, `/api/manager/runs/[id]` | `read` | Wake runs and their proposals. |
| POST | `/api/manager/runs/[id]/approve` | `publish` | Approve a run's proposals. |
| POST | `/api/manager/runs/[id]/dismiss` | `write` | Dismiss a run with feedback. |
| GET/PUT | `/api/manager/settings` | `read` / `write` | Cadence and targeting. |
| GET | `/api/manager/budget` | `read` | Spend, 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.

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/characters` | `read` | The roster: identity, voice (kind + the direction a designed one was written from), locks. |
| POST | `/api/characters` | `write` | Create one: `{name, bio?, isUser?}`. |
| GET | `/api/characters/[id]` | `read` | One actor. |
| PATCH | `/api/characters/[id]` | `write` | Edit it, including the three locks. |
| DELETE | `/api/characters/[id]` | `write` | Archive it (posts and agents may still cast it). |
| POST | `/api/characters/[id]/voice` | `write` | Design, 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

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| POST | `/api/actions/[id]/send` | `publish` | Send a drafted comment or reply, or run an approved boost. |
| POST | `/api/actions/[id]/done` | `write` | Mark a task card done. |
| POST | `/api/actions/[id]/skip` | `write` | Skip a proposed action. |

### Brand Memory, assets and media

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/workspace` | `read` | The brain's file tree with its completion ring. |
| GET | `/api/workspace/file?path=` | `read` | One file. |
| PUT/DELETE | `/api/workspace/file` | `write` | Write or delete one file. |
| GET | `/api/workspace/export` | `read` | The whole brain as a zip. |
| POST | `/api/brain/scan` | `write` | Read a link and file what it says into Brand Memory (NDJSON stream). |
| POST | `/api/brain/import` | `write` | Import a .md/.txt/.pdf/.docx document. |
| GET | `/api/brain/assets`, `/api/assets` | `read` | Brand uploads, and the full asset library. |
| GET | `/api/brain/assets/[filename]` | `read` | Stream one asset (Range supported). |
| PUT/PATCH/DELETE | `/api/brain/assets/[filename]` | `write` | Upload, rename, delete an asset. |
| GET | `/api/media/[...key]` | `read` | Stream any media key this account owns. |

### Analytics

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| GET | `/api/metrics` | `read` | Published posts with their latest samples and the 90-day series. |
| GET | `/api/metrics/history?post=` | `read` | One post's accrual curve against the account median. |
| GET | `/api/metrics/followers` | `read` | Follower counts and the weekly delta. |
| POST | `/api/metrics/refresh` | `write` | Refresh stale samples (calls the platforms). |

## Cookie-only, on purpose

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](/docs/cli) and the [MCP server](/docs/mcp) 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.
