# Shapeless documentation (full text) > Every page of https://shapelessai.com/docs concatenated in nav order, for one fetch instead of many. > The short index of the whole site is at https://shapelessai.com/llms.txt. > Last verified 2026-09-14. --- # Shapeless for agents URL: https://shapelessai.com/docs Markdown: https://shapelessai.com/docs/index.md Verified: 2026-09-14 An API key, an MCP URL and a CLI. The three moves that get a post out of an agent and onto a real account. Shapeless is a social media team you drive from code. It holds the connected accounts, the Brand Memory, the media pipeline and the publishing rail; your agent sends it a post, or a goal, and gets back a real URL on a real platform. There are three ways in, and they are the same account underneath: | Surface | What it is | Best for | | --- | --- | --- | | **API** | HTTPS + an API key | scripts, cron, your own backend | | **CLI** | `npx shapelessai` | a terminal, a CI job | | **MCP** | `https://shapelessai.com/mcp` | Claude, ChatGPT, Cursor, Codex, VS Code, Gemini CLI | ## The three moves **1. Connect a social account.** Once, in a browser, through the network's own sign-in: [shapelessai.com/studio/accounts](https://shapelessai.com/studio/accounts). Platform OAuth is a browser redirect carrying a platform grant, so it is the one step an agent cannot do for you. **2. Get a credential.** - For the API and the CLI: mint an API key under [Settings, API keys](https://shapelessai.com/studio/api-keys). The plaintext is shown once. Give it only the scopes the caller needs - see [Authentication](/docs/auth). - For MCP: no key. Add `https://shapelessai.com/mcp` to your host and sign in when the Shapeless tab opens. **3. Post.** ```bash curl -s https://shapelessai.com/api/posts \ -H "Authorization: Bearer $SHAPELESS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"connectionId":"","postText":"Shipped the thing.","queue":true}' ``` ```bash shapeless posts create --to --text "Shipped the thing." --queue ``` ```jsonc // MCP { "tool": "posts_create", "arguments": { "connectionId": "", "text": "Shipped the thing.", "queue": true } } ``` `connectionId` comes from `GET /api/connections` (`shapeless connections`, `connections_list`). ## What it costs **Free** is an account with no paid plan, and it is not a countdown: - Manual, Queue, Calendar, per-platform previews, every connected platform, the API, the MCP server, the CLI and the Claude and ChatGPT connectors. - **10 posts a day** through the rail, counted on the UTC day each post goes out on - so a week planned ahead is ten a day, not ten in total. Creating one and approving one both count. - **$5 of credits a month** for the agent team, refilled monthly - enough to have it research, write and render for you. Paid plans (Spark $35, Growth $100, Studio $200) lift the daily cap and raise the credit allowance. See [Pricing](https://shapelessai.com/pricing). The cap answers `402` with `{"code": "free_daily_cap", "limit": 5, "day": "2026-09-22", "resetsAt": ""}` - move the post to a day with room rather than retrying into it. Full refusal table on [Posts](/docs/posts#errors). ## Where to go next - [Posts](/docs/posts) - create, schedule, queue, media, first comment, refusals. - [Platforms](/docs/platforms) - the limits we enforce before a post goes out. - [Authentication](/docs/auth) - scopes, OAuth, rate limits. - [API reference](/docs/api) - every route a key opens. - [Jobs](/docs/jobs) - hand over a goal instead of a post. - [Brand Memory](/docs/brand-memory) - fix the source, not the symptom. ## Reading these docs as an agent Every page here answers Markdown as well as HTML: - Append `.md` to any path: `https://shapelessai.com/docs/posts.md`. - Or send `Accept: text/markdown` to the normal URL. - [`/llms.txt`](https://shapelessai.com/llms.txt) is the index of the whole site. - [`/llms-full.txt`](https://shapelessai.com/llms-full.txt) is every page in these docs concatenated, in nav order, in one fetch. --- # Posts URL: https://shapelessai.com/docs/posts Markdown: https://shapelessai.com/docs/posts.md Verified: 2026-09-14 Create a post, schedule it, drop it in the queue, attach media, add a first comment, and read every refusal the rail can answer with. One route puts a post on a real account: `POST /api/posts`. It takes the post, checks it against the platform's rules before it accepts it, and either publishes it inline or puts it on the schedule. ## The body ```jsonc { "connectionId": "", // required - which connected account "postText": "...", // required - the post body "mediaKeys": ["..."], // optional - media this account owns "documentTitle": "...", // the post's title - YouTube requires one, LinkedIn PDFs use it "scheduledAt": "2026-09-20T09:00:00Z", // optional - omitted or past = publish now "queue": true, // optional - instead of scheduledAt: the next free slot "settings": { }, // optional - per-platform, see below "firstComment": "..." // optional - posted as the first comment/reply } ``` Needs the `publish` scope. `connectionId` comes from `GET /api/connections`. ## The three timings **Now.** Omit `scheduledAt`, or send one in the past. The route publishes inline and answers with the live URL, so you can print it. ```bash curl -s https://shapelessai.com/api/posts \ -H "Authorization: Bearer $SHAPELESS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"connectionId":"c_123","postText":"Shipped."}' # {"id":"p_456","status":"published","publishedLocation":"https://www.linkedin.com/feed/update/..."} ``` ```bash shapeless posts create --to c_123 --text "Shipped." ``` **At a time.** Send `scheduledAt` as an ISO 8601 instant. Use an offset or `Z`; a bare local datetime is ambiguous and we will read it as UTC. ```bash curl -s https://shapelessai.com/api/posts \ -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \ -d '{"connectionId":"c_123","postText":"Tuesday thought.","scheduledAt":"2026-09-22T07:00:00Z"}' # {"id":"p_457","status":"scheduled","scheduledAt":"2026-09-22T07:00:00.000Z"} ``` ```bash shapeless posts create --to c_123 --text "Tuesday thought." --at 2026-09-22T07:00:00Z ``` **In the queue.** Send `queue: true` instead of `scheduledAt` and the post takes the next free slot on that connection's posting times. Nothing collides: a slot another post already holds is skipped. Sending both is a `400`; a connection whose every slot is taken for the next two months is a `409`. ```bash shapeless posts create --to c_123 --text "Next one out." --queue ``` Read and edit the slots per connection: ```bash curl -s https://shapelessai.com/api/connections/c_123/queue -H "Authorization: Bearer $SHAPELESS_API_KEY" # {"slots":["09:00","13:00","17:00"],"timezone":"UTC","next":"2026-09-15T09:00:00.000Z"} curl -s -X PATCH https://shapelessai.com/api/connections/c_123/queue \ -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \ -d '{"slots":["08:30","16:00"],"timezone":"Europe/Berlin"}' ``` `GET` needs `read`, `PATCH` needs `write`, and both answer the same shape. An account nobody has configured starts on `["09:00", "13:00", "17:00"]` UTC, so the queue works before anyone touches it. Slots are 1 to 12 `HH:MM` times, resolved through the IANA zone on each day, so they keep meaning across daylight saving. `next` is `null` when every slot is taken for two months. ## The answer ```jsonc { "id": "p_456", "status": "scheduled" | "published" | "failed" | "handoff", "scheduledAt": "2026-09-22T07:00:00.000Z", "publishedLocation": "https://...", // present when it went out "error": "..." // present when it did not } ``` A row read back with `GET /api/posts/[id]` also carries `firstComment`, `firstCommentUrl` (the comment's own link once it landed) and `firstCommentError` (why it did not). The post going out and its first comment landing are two events, so they are two fields. `handoff` means the platform has no sanctioned API path for this post, so it is waiting for you in the studio with everything prepared. It is not a failure and not a retry. Poll one row with `GET /api/posts/[id]`, or the queue with `GET /api/posts?status=scheduled`. ## Media `mediaKeys` are keys this account already owns - what an earlier job rendered, a brand asset under `workspace-assets/`, or something you uploaded. A key belonging to another account is a `400` naming it, never a post that publishes with no media. ```bash shapeless assets upload ./carousel.pdf # prints the media key shapeless posts create --to c_linkedin --text "Ten lessons." \ --media workspace-assets//carousel.pdf --title "Ten lessons from a year of shipping" ``` Kind comes from the extension, the same rule the engine uses: `.pdf` is a document, `.mp4/.mov/.webm/.m4v` a video, everything else an image. `documentTitle` names a LinkedIn PDF carousel. Counts and formats per platform are on [Platforms](/docs/platforms); breaking one is a `422` before the row is ever created, never a post that dies at publish time. ## First comment `firstComment` posts a second message under the first, from the same account, as soon as the post lands. It is how a link gets shared without the link sitting in the body. | Platform | Shape | Limit | | --- | --- | --- | | `linkedin`, `linkedin_page` | a comment | 1,250 characters | | `x` | a reply in the thread | 280 characters | | `bluesky` | a reply in the thread | 300 characters | Any other platform is a `422` at creation - never a post that publishes and then quietly drops the comment. `PATCH /api/posts/[id]` accepts `firstComment` too; `null` clears it. Whether it landed is on the row afterwards as `firstCommentUrl` or `firstCommentError`. ```bash shapeless posts create --to c_linkedin --text "We wrote up what we learned." \ --first-comment "Full write-up: https://shapelessai.com/notes" ``` ## Per-platform settings `settings` is a free-form object passed through to the platform adapter. `GET /api/platforms` carries a JSON Schema per platform, so an agent can fill it without hard-coding. **TikTok** requires an explicit choice before anything may queue - TikTok's own content-sharing rules, and we refuse rather than guess: ```jsonc { "privacyLevel": "PUBLIC_TO_EVERYONE", // or MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY "disableComment": false, "disableDuet": false, "disableStitch": false, "commercialContent": false, // the disclosure toggle "brandOrganic": false, // "your brand" - promotional content "brandContent": false, // a paid partnership "isAigc": true // AI-generated; our posts default this on } ``` **YouTube**: `{"privacyStatus": "public"}` (or `unlisted`, `private`). The video's title is not a setting - it is `documentTitle`, and YouTube requires one. `titleRequired` in `GET /api/platforms` is the field that says so. **Reddit**: `{"subreddit": "startups", "title": "...", "url": "..."}`. One post goes to exactly one community: whatever arrives is sanitized down to a single subreddit name, so a comma-separated list can never fan out. `url` set makes it a link post; without an explicit `title` a text post takes its first line. Everything else takes no settings today, and `settingsSchema` says so with an empty object schema rather than an absent field. ## Editing and cancelling ```bash curl -s -X PATCH https://shapelessai.com/api/posts/p_457 \ -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \ -d '{"postText":"Tuesday thought, shorter.","scheduledAt":"2026-09-22T08:00:00Z"}' curl -s -X DELETE https://shapelessai.com/api/posts/p_457 -H "Authorization: Bearer $SHAPELESS_API_KEY" ``` `PATCH` needs `write` and works while the post is still scheduled. It also accepts `connectionId` to move the post to another account on the **same** platform; a different platform is a `409`, because the rules it was checked against no longer hold. ## Errors Every refusal is a status and a message that names the rule. None of them are worth retrying unchanged. | Status | Code / shape | What happened | | --- | --- | --- | | `400` | `{error}` | Bad body: missing `connectionId` or `postText`, oversized text, a media key this account does not own, or `scheduledAt` and `queue` together. | | `401` | `{error}` | Unknown or revoked key. | | `403` | `{error}` | The key is missing the `publish` scope. | | `402` | `{error, code: "free_daily_cap", limit: 10, day, resetsAt}` | A Free account already has 10 posts going out on that UTC day. | | `409` | `{error}` | `queue: true` with every slot taken for two months, or a `PATCH` moving the post to a different platform. | | `422` | `{error}` | A platform rule: too many images, images and a video together, a missing video, a first comment on a platform that has none, TikTok without its choices. The message names the rule. | | `429` | `{error}` | Over the rate limit. See [Authentication](/docs/auth#rate-limit). | | `503` | `{error}` | The service has no database. | Worked examples: ```jsonc // 402 {"code":"free_daily_cap", "error":"The Free plan posts 10 a day and 2026-09-22 (UTC) is full. Pick another day, or upgrade for unlimited posts.", "limit":10,"day":"2026-09-22","resetsAt":"2026-09-23T00:00:00.000Z"} // 422 {"error":"Takes at most 4 images (this post has 7)."} {"error":"Needs a video before it can publish."} {"error":"Takes images OR a video in one post, never both - drop one kind."} {"error":"instagram has no first comment: it is supported on LinkedIn, X and Bluesky."} ``` ### The Free day The cap counts **the UTC day a post goes out on**, not the day you created it. So a week planned in advance is five a day, not five in total, and the refusal names the day that is full rather than telling you to come back later. `day` is that date; `resetsAt` is its UTC midnight end. Everything that puts a post on the rail is counted the same way - your own `POST /api/posts` and approving a proposal both land a post on a day - so an agent cannot route around the cap by proposing first. Paid plans have no cap. The `402` is the one to build for. It is not an error in your code: it is the plan. Catch the code, move the post to a day with room, or tell the human the account is on Free. ## The other half of the queue The agent team also *proposes* posts. Those arrive as `status: "proposed"` and never go out on their own: ```bash shapeless posts list --status proposed shapeless posts approve # proposed -> scheduled [publish] shapeless posts dismiss shapeless posts publish # out, now [publish] ``` Approving counts against the Free day the post lands on, exactly as creating one does. See [Jobs](/docs/jobs) for where proposals come from. --- # Platforms URL: https://shapelessai.com/docs/platforms Markdown: https://shapelessai.com/docs/platforms.md Verified: 2026-09-14 Every platform the rail publishes to, with the character limit, media rules, first-comment support and per-platform settings we enforce before a post goes out. These are the rules our publishing engine enforces *before* a post is accepted onto the queue, so a post that breaks one is a `422` at creation rather than a failure hours later. Read them live from `GET /api/platforms` - no auth, no key: ```bash curl -s https://shapelessai.com/api/platforms ``` ```jsonc { "platforms": [ { "id": "linkedin", "label": "LinkedIn", "oauth": true, // connectable through the platform's own sign-in today "live": true, // publishing works end to end; false = built, waiting on the platform "waitingOn": null, // why it is not live, when it is not "text": { "maxChars": 3000 }, "media": { "maxChars": 3000, "maxImages": 20, "minImages": 0, "allowsPdf": true, "maxVideos": 1, "requiresVideo": false, "imagesOrVideoOnly": true }, "titleRequired": false, // true = documentTitle is required (YouTube) "firstComment": { "supported": true, "maxChars": 1250 }, "settingsSchema": { "type": "object", "properties": {}, "additionalProperties": false }, "connectPath": "/studio/accounts" } ] } ``` `settingsSchema` is plain JSON Schema, which is what every MCP host and OpenAPI reader already speaks - so an agent can fill `settings` from it instead of hard-coding field names. The table below is generated from the same engine table that route reads, at build time, so the two cannot drift. | Platform | `id` | Text | Images | Video | PDF | First comment | Images + video | | --- | --- | --- | --- | --- | --- | --- | --- | | LinkedIn | `linkedin` | 3,000 characters | 20 | 1 | yes | 1,250 chars | no | | LinkedIn Page | `linkedin_page` | 3,000 characters | 20 | 1 | yes | 1,250 chars | no | | X | `x` | 280 weighted chars | 4 | 1 | no | 280 chars | no | | Instagram | `instagram` | 2,200 characters | 10 (at least 1) | 1 | no | no | no | | TikTok | `tiktok` | 2,200 characters | none | 1, required | no | no | n/a | | YouTube | `youtube` | 5,000 characters | none | 1, required | no | no | n/a | | Facebook Page | `facebook_page` | 63,206 characters | 10 | 1 | no | no | no | | Threads | `threads` | 500 characters | 1 | 1 | no | no | no | | Bluesky | `bluesky` | 300 graphemes | 4 | not yet | no | 300 chars | n/a | | Reddit (waiting on Reddit's app approval) | `reddit` | 40,000 characters | 1 | 1 | no | no | no | *Reddit: Built - waiting on Reddit's app approval before we switch it on.* **Counting.** X counts weighted characters (a URL always costs 23, most emoji cost 2) using the official twitter-text rules. Bluesky counts grapheme clusters. Everything else counts Unicode code points. The free [character counters](https://shapelessai.com/tools) run exactly these rules in the browser. **Images or video.** Most platforms take images *or* a video in one post, never both. Sending both is a `422` before the row exists. **What we deliver vs what the platform allows.** A few numbers above are ours, not the network's, because our adapter delivers less than the platform would take: Threads publishes one image where Threads itself allows a 20-item carousel, Reddit one image where a gallery allows 20, and Bluesky video needs a service-auth flow we have not built. The per-platform pages under [/specs](https://shapelessai.com/specs) carry each number's source and the date we last checked it. **Reddit** is waiting on Reddit's app approval. The publisher is built and the rules above are enforced, but posting to Reddit is not open yet. **Mastodon** is not offered. ## Settings, per platform `settings` on `POST /api/posts` is passed through to the adapter, and `settingsSchema` is its JSON Schema. Today: TikTok requires an explicit `privacyLevel` (and takes seven more toggles), YouTube takes `privacyStatus`, Reddit takes one `subreddit` plus an optional `title` and `url`, and nothing else takes settings. The shapes are on [Posts](/docs/posts#per-platform-settings). YouTube is the one platform with `titleRequired: true`: its video title rides `documentTitle` on the post, not `settings`. ## Connecting an account Platform OAuth is a browser redirect carrying a platform grant, so it is deliberately the one thing an API key cannot do. Send the human to [shapelessai.com/studio/accounts](https://shapelessai.com/studio/accounts) - `connectPath` in the payload above - and read the result back with `GET /api/connections`. --- # API reference URL: https://shapelessai.com/docs/api Markdown: https://shapelessai.com/docs/api.md Verified: 2026-09-14 Every route an API key opens, the scope each one needs, and the routes that refuse a key on purpose. 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=` for the next page, and keep going until it is `null`. The cursor is the last row's sort key (`~`), 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=`, 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//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::`. 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. --- # CLI URL: https://shapelessai.com/docs/cli Markdown: https://shapelessai.com/docs/cli.md Verified: 2026-09-14 The `shapeless` command: install, sign in, and drive the same account from a terminal or a cron job. One npm package, two faces: the `shapeless` command, and an MCP server (`shapeless mcp`) exposing the same operations as tools. Both are built on the routes in the [API reference](/docs/api), so there is one implementation and one scope policy underneath. ## Install ```bash npx shapelessai --help # one-off npm i -g shapelessai # keeps `shapeless` on your PATH ``` Node 20 or newer. Source and issues: [github.com/FirstClassTree/shapelessai](https://github.com/FirstClassTree/shapelessai). ## Sign in ```bash shapeless login # paste a key from /studio/api-keys; stored 0600 export SHAPELESS_API_KEY=slk_... # beats the stored key - the shape for CI ``` Config lives in `~/.config/shapeless/config.json`. `SHAPELESS_BASE_URL` overrides the API host (default `https://shapelessai.com`). `shapeless logout` forgets the local copy; revoke the key itself in the studio. Every command takes `--json` to print the raw API response, and `--help`. ## Post ```bash shapeless connections # the connected accounts and their ids shapeless platforms # limits and rules, no key needed shapeless posts create --to --text "Shipped the thing." shapeless posts create --to --text "..." --at 2026-09-22T07:00:00Z shapeless posts create --to --text "..." --queue shapeless posts create --to --text "..." \ --media workspace-assets//carousel.pdf --title "Ten lessons" \ --first-comment "Full write-up: https://..." shapeless posts create --to --text "..." --settings '{"subreddit":"startups","title":"..."}' shapeless posts create --to --text "..." --media \ --title "The video title" --settings '{"privacyStatus":"unlisted"}' ``` Flags map one-to-one onto the `POST /api/posts` body: `--to` is `connectionId`, `--text` is `postText`, `--at` is `scheduledAt`, `--queue` is `queue`, `--media` is a comma-separated `mediaKeys`, `--title` is `documentTitle` (YouTube needs one), `--settings` is `settings`, and `--first-comment` is `firstComment`. `--at` and `--queue` are exclusive. Needs a key with `publish`. On the Free plan the sixth post for one UTC day answers `402 free_daily_cap` naming the day that is full - see [Posts](/docs/posts#the-free-day). ## Work the queue ```bash shapeless posts list --status proposed shapeless posts list --status published # LIFT: each post against its channel's median shapeless posts show # what it was made from, and how it did shapeless posts approve # proposed -> scheduled [publish] shapeless posts dismiss shapeless posts publish # out, now [publish] shapeless posts mark-posted --url https://... ``` A published post carries `metrics`: the latest reading, its standing at 24h and 7d, and `lift` - its score over the median of the same channel's last 90 days. Lift stays blank until at least five scored posts stand behind that median, because a two-post median invents findings. ## Jobs ```bash shapeless jobs create draft three posts about our beta launch shapeless jobs create plan this week --label "Weekly plan" --budget 2.50 --watch shapeless jobs list # 200 newest; prints a cursor if older jobs exist shapeless jobs list --before shapeless jobs show shapeless jobs tail shapeless jobs continue keep going, but make the second post shorter --watch shapeless jobs stop shapeless jobs create does this thumbnail work? --attach ./thumb.png --attach ./notes.md shapeless jobs continue and this one --media-key workspace-assets//logo.png ``` See [Jobs](/docs/jobs). ## Actors, Brand Memory, assets ```bash shapeless characters list shapeless characters voice maya --describe "warm documentary narrator, mid-40s, slight gravel" # SPENDS CREDIT shapeless characters voice maya --clone workspace-assets//take.weba --consent shapeless brain ls shapeless brain get positioning.md shapeless brain put voice.md --file ./voice.md shapeless brain import ./pitch-deck.pdf shapeless brain export --out brain.zip shapeless agents list shapeless agents wake # [publish] shapeless assets list shapeless assets upload ./logo.png ``` See [Brand Memory](/docs/brand-memory). ## The local MCP server ```bash shapeless mcp ``` Speaks MCP on stdio using the key from `shapeless login`, and adds the tools that read your disk - `assets_upload`, `brain_import`, and `files` on a job message - which the hosted server cannot have. Most people want the hosted server instead: see [MCP server](/docs/mcp). --- # MCP server URL: https://shapelessai.com/docs/mcp Markdown: https://shapelessai.com/docs/mcp.md Verified: 2026-09-14 The hosted MCP server at shapelessai.com/mcp, the tools it serves, and how each agent host adds it. The hosted MCP server is **`https://shapelessai.com/mcp`**. Add that URL to any host that speaks remote MCP and it opens a Shapeless tab to sign in and allow. OAuth, no API key, revocable by the human. It is the same operations as the [API](/docs/api) and the [CLI](/docs/cli), discovered as tools - so an agent host learns the surface instead of reading this page. ## Add it | Host | How to add it | Steps with screenshots | | --- | --- | --- | | **Claude** | Settings → Connectors → Add custom connector → paste the URL → Connect. | [/connect/claude](https://shapelessai.com/connect/claude) | | **ChatGPT** | Developer mode on (Settings → Security and login) → chatgpt.com/plugins → + → paste the URL, OAuth. | [/connect/chatgpt](https://shapelessai.com/connect/chatgpt) | | **Claude Code** | `claude mcp add --transport http --scope user shapeless https://shapelessai.com/mcp`, then /mcp → Authenticate. | [/connect/claude-code](https://shapelessai.com/connect/claude-code) | | **Cursor** | One-click install link, or add the URL to ~/.cursor/mcp.json → Needs login → sign in. | [/connect/cursor](https://shapelessai.com/connect/cursor) | | **Codex** | `codex mcp add shapeless --url https://shapelessai.com/mcp`, then `codex mcp login shapeless`. | [/connect/codex](https://shapelessai.com/connect/codex) | | **VS Code** | One-click install link, or MCP: Add Server → HTTP → paste the URL → start it and sign in. | [/connect/vscode](https://shapelessai.com/connect/vscode) | | **Gemini CLI** | `gemini mcp add --transport http shapeless https://shapelessai.com/mcp`, then /mcp auth shapeless. | [/connect/gemini-cli](https://shapelessai.com/connect/gemini-cli) | Any other host that speaks remote MCP takes the same URL: `https://shapelessai.com/mcp`. Every step above was read from the vendor's own documentation; the dated sources are on [shapelessai.com/connect](https://shapelessai.com/connect). ## The handshake, for an agent adding it itself - Transport: streamable HTTP at `https://shapelessai.com/mcp` - Auth: OAuth 2.1, dynamic client registration, PKCE - Protected-resource metadata: `https://shapelessai.com/.well-known/oauth-protected-resource/mcp` - Scopes: `read`, `write`, `publish`, `offline_access` An unauthenticated call answers `401` with `WWW-Authenticate: Bearer resource_metadata="..."` pointing at that document. The server is stateless - one server per request, no session id - so nothing breaks when the next request lands on another instance. ## The tools Every tool's description names the scope its credential needs. Tools that put content out say so in capitals and carry a destructive annotation, so a host asks the human first. Read-only tools are annotated read-only and run freely. | Tool | Scope | What it does | | --- | --- | --- | | `me` | `read` | Who am I, plan, credit balance. Call it first to check the credential. | | `connections_list` | `read` | The connected accounts and their ids. | | `platforms_list` | none | Limits, media rules, whether a title is required, first-comment support and the settings schema per platform. Read it before `posts_create`. | | `posts_create` | `publish` | **PUBLISHES.** Put a post you wrote on one connected account: now, at a time, or in the account's next free queue slot. | | `posts_list` | `read` | The queue: proposed, scheduled, published. | | `posts_get` | `read` | One post's live state, what it was made from, how it did. | | `posts_approve` | `publish` | **PUBLISHES.** Proposals to the schedule. | | `posts_dismiss` | `write` | Reject proposals. Nothing goes out. | | `posts_publish` | `publish` | **PUBLISHES.** Push a queued post out now. | | `jobs_create` | `write` | Start a durable run from a goal. | | `jobs_list` / `jobs_get` / `jobs_brief` / `jobs_tail` | `read` | Find, read, digest and follow runs. | | `jobs_continue` | `write` | Another turn on a job - resumes a stuck one. | | `jobs_stop` | `write` | Stop a running job. | | `characters_list` | `read` | The actor roster and how each voice was made. | | `characters_voice` | `write` | Design or clone a voice. **Spends credit.** | | `brain_tree` / `brain_read` | `read` | Brand Memory's file tree and one file. | | `brain_write` | `write` | Edit Brand Memory. | | `assets_list` | `read` | The asset library. | | `agents_list` | `read` | The standing agents. | | `agents_save` | `write`, or `publish` when armed to autopublish | Create or edit one. | | `agents_wake` | `publish` | **PUBLISHES.** Run an agent now. | `posts_create` is `POST /api/posts` in tool clothing: `connectionId`, `text` (the route's `postText`), `mediaKeys`, `title` (the route's `documentTitle`; YouTube requires one), `scheduledAt` **or** `queue: true`, `settings`, `firstComment`. Read `platforms_list` first - it carries the limits and the `settingsSchema` to fill. On the Free plan the sixth post for one UTC day answers `402 free_daily_cap`, which names the day and when it resets. See [Posts](/docs/posts). Two tools exist only on the local stdio server, because they read your disk: `assets_upload`, `brain_import`, and the `files` argument on a job message. Run it with `shapeless mcp` - see [CLI](/docs/cli#the-local-mcp-server). ## Work passes both ways - **Every job result carries `url`** - `https://shapelessai.com/studio/c/` - the conversation the human opens. `jobs_create`, `jobs_list`, `jobs_get` and `agents_wake` all decorate. - **`jobs_brief`** is the token-compact read before replying: the last 30 messages clipped, reasoning and tool-activity dropped, an artifact inventory, post counts per queue status. Deterministic, no model in the loop. - **`jobs_list` pages** 200 at a time with a `nextCursor` the tool takes back as `before`. - **`jobs_tail`** replays from a cursor and follows live, returning when the run ends, 60 seconds pass or 200 events arrive. A run with no stream answers `{live: false}` rather than erroring. There is one **prompt**, `continue` (argument: `id`), which a host like Claude Code surfaces as a slash command: it loads that conversation's brief and tells the agent to reply into the same thread with `jobs_continue`. ## Claude Code plugin The public repo is also a plugin marketplace. The plugin wires up the hosted server and ships a skill that teaches Claude the ropes: ``` /plugin marketplace add FirstClassTree/shapelessai /plugin install shapeless@shapeless ``` Then run `/mcp`, pick shapeless and choose Authenticate. ## Other servers We keep a measured survey of every MCP server that posts to social networks, ours included, at [shapelessai.com/best-social-media-mcp-servers](https://shapelessai.com/best-social-media-mcp-servers): endpoint, auth, which tools publish, networks, and the free tier's honest limit, each read from the vendor's own page. ## Questions ### Is there a free social media MCP server? Yes, this one. `https://shapelessai.com/mcp` is on the Free plan: OAuth, no API key, 10 posts a day across nine networks, no card. The same tools run on every paid plan. ### Can Claude post to social media? Through a server that exposes a publishing tool, yes. Add this server as a custom connector, connect an account under Accounts, and ask. `posts_create` publishes or schedules; it is annotated destructive, so Claude asks you before it runs. ### Does it work in ChatGPT, Cursor and Codex too? Any MCP host that speaks streamable HTTP with OAuth 2.1 can add it; the host table above lists the ones we have verified step by step. Claude Code also has a plugin, and the `shapelessai` CLI runs the same tools over stdio for hosts that cannot reach a hosted server. ### What happens when the free plan's daily cap is hit? `posts_create` answers with the `free_daily_cap` error, the limit and the time it resets, so an agent can wait rather than retry blindly. Nothing is dropped silently. --- # Jobs URL: https://shapelessai.com/docs/jobs Markdown: https://shapelessai.com/docs/jobs.md Verified: 2026-09-14 Durable runs: hand the agent team a goal, walk away, and collect the posts, carousels and video it made. A post you already wrote goes out through [Posts](/docs/posts). A job is the other direction: you hand over a goal and the agent team researches, writes, designs and renders, then leaves the result in the queue for approval. A job is durable. You send a goal, get a `202` with its id, and the run keeps going whether or not you stay connected - which is the only shape that survives a terminal closing, a lambda timing out or a cron window ending. ## Start one ```bash curl -s https://shapelessai.com/api/v1/jobs \ -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \ -d '{"goal":"Draft three posts about our beta launch","label":"Beta launch","budgetUsd":2.5}' # 202 {"id":"j_123","title":"Beta launch","status":"running"} ``` ```bash shapeless jobs create draft three posts about our beta launch --label "Beta launch" --budget 2.50 --watch ``` ```jsonc // MCP { "tool": "jobs_create", "arguments": { "goal": "Draft three posts about our beta launch", "budgetUsd": 2.5 } } ``` Needs `write`. `label` becomes the title; without one the goal's first line is. `budgetUsd` caps what the run may spend. `timezone` tells it what "tomorrow morning" means. ## Watch it, or come back | Method | Path | Scope | Purpose | | --- | --- | --- | --- | | GET | `/api/v1/jobs` | `read` | The list, 200 per page, newest first, with a `nextCursor`. | | GET | `/api/v1/jobs/[id]` | `read` | Transcript, `status`, `live`, and an outputs summary. | | POST | `/api/v1/jobs/[id]/messages` | `write` | Another turn on the same job. | | GET | `/api/studio/tail?conversationId=[id]` | `read` | The live event stream. | | POST | `/api/studio/stop` | `write` | Stop it. | Follow the run rather than polling it: `shapeless jobs tail `, or the `jobs_tail` MCP tool, which holds one bounded call (60 seconds or 200 events) and answers a cursor to resume from. A job with no live stream answers `{live: false}` instead of erroring, and a finished one replays and closes. `jobs_brief` is the cheap read before replying: the last 30 messages clipped, reasoning and tool-activity dropped, an artifact inventory, and post counts per queue status. Deterministic, no model in the loop. `jobs_get` still gives the whole transcript. ## Resume a stuck run Posting another message rebuilds the conversation server-side from the stored transcript, so "continue" is a real resume, not a fresh start: ```bash shapeless jobs continue j_123 keep going, but make the second post shorter --watch ``` ## Attach files `attachments` puts files on the message itself, 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. ```jsonc { "goal": "Does this thumbnail work for the launch post?", "attachments": [ { "name": "thumb.png", "mediaKey": "chat-uploads//thumb-mt1z.png", "contentType": "image/png" }, { "name": "notes.md", "text": "Launch is Thursday. Tone: plain, no hype." } ] } ``` Up to **6** per message. Each entry needs a `name` and either `text` (inlined, 24k characters) or a `mediaKey`. Get a key by uploading to `PUT /api/studio/attachments/[filename]` (10MB images, 30MB video and PDF; filenames are `[A-Za-z0-9._-]`), or name any media key the account already owns - a brand asset, something an earlier run made. 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. The CLI and MCP take local paths directly: ```bash shapeless jobs create does this thumbnail work? --attach ./thumb.png --attach ./notes.md ``` ## Cast an actor An actor is workspace-level identity - a face, a voice, a style, a memory - that posts and jobs cast. `characters` on a job is that cast, by slug or id: ```bash shapeless characters list # who exists, and how each voice was made shapeless jobs create make the explainer short --characters maya,nova ``` The agent is shown the whole roster either way, so it reuses 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. ## Handing work back to the human Every job result carries `url` - `https://shapelessai.com/studio/c/` - the conversation the human opens to see what happened and approve what came out. Hand it over rather than pasting a transcript. ## Money A job spends credits: research, writing, images, video, voice. Free accounts carry $5 a month, refilled monthly. `budgetUsd` caps one run. An empty wallet is a `402` with `code: "trial_exhausted"`, never a run that quietly does less. `GET /api/me` carries the balance. Composing, scheduling and publishing through the rail spend nothing - only the agent team does. --- # Brand Memory URL: https://shapelessai.com/docs/brand-memory Markdown: https://shapelessai.com/docs/brand-memory.md Verified: 2026-09-14 The account's durable knowledge - voice, positioning, product facts - and how an agent reads and edits it. Brand Memory is the account's durable knowledge: who the business is, what it sells, how it sounds, what it has already said. It is a small file tree, not a prompt, and every job reads it before it writes anything. This is the highest-leverage thing an agent can edit. A post with the wrong tone is a symptom; the voice file is the cause. Fix the file and every future post is fixed. ## Read it ```bash curl -s https://shapelessai.com/api/workspace -H "Authorization: Bearer $SHAPELESS_API_KEY" curl -s "https://shapelessai.com/api/workspace/file?path=voice.md" -H "Authorization: Bearer $SHAPELESS_API_KEY" ``` ```bash shapeless brain ls shapeless brain get positioning.md ``` ```jsonc // MCP { "tool": "brain_tree", "arguments": {} } { "tool": "brain_read", "arguments": { "path": "voice.md" } } ``` `GET /api/workspace` answers the tree with its completion ring - which parts of the brand are filled in and which are still empty. ## Write it ```bash curl -s -X PUT https://shapelessai.com/api/workspace/file \ -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \ -d '{"path":"voice.md","content":"Plain sentences. No hype. Never say '\''revolutionary'\''."}' ``` ```bash shapeless brain put voice.md --file ./voice.md # or pipe on stdin shapeless brain import ./pitch-deck.pdf # .md, .txt, .pdf, .docx shapeless brain export --out brain.zip ``` Needs `write`. `POST /api/brain/scan` reads a link and files what it says into the tree (an NDJSON stream). `POST /api/brain/import` takes a document. Both are how you fill a new account in one move rather than typing it. ## Assets The brand's real files - logos, fonts, product shots, footage - live beside the text: ```bash shapeless assets list shapeless assets upload ./logo.png # prints the media key ``` | Method | Path | Scope | Purpose | | --- | --- | --- | --- | | GET | `/api/brain/assets`, `/api/assets` | `read` | Brand uploads, and the whole asset library. | | GET | `/api/brain/assets/[filename]` | `read` | Stream one asset (Range supported). | | PUT/PATCH/DELETE | `/api/brain/assets/[filename]` | `write` | Upload, rename, delete. | | GET | `/api/media/[...key]` | `read` | Stream any media key this account owns. | A media key from here can go straight onto a post (`mediaKeys`) or onto a job message (`mediaKeys`). Uploading an asset puts it in the library for reuse; attaching it to a message is what makes *this* turn's agent look at it. Two different asks - do the one you were asked for. ## Standing agents An agent is a recurring brief the server wakes on its own schedule. ```bash shapeless agents list shapeless agents create --name "Daily reach" --prompt "..." --days mon,thu --hours 9 shapeless agents edit --status paused shapeless agents wake # run it now [publish] ``` `POST /api/strategies` needs `write`, or `publish` if the agent is armed to autopublish - arming something that posts without a human is itself a publishing act. Waking one always needs `publish`. What they propose lands in the queue as `proposed`; see [Posts](/docs/posts). --- # Authentication URL: https://shapelessai.com/docs/auth Markdown: https://shapelessai.com/docs/auth.md Verified: 2026-09-14 API keys and their three scopes, OAuth for the MCP server, rate limits, and what every refusal code means. Two credentials reach the same account. A person in a browser rides a signed session cookie. An agent rides either an **API key** (the API and the CLI) or an **OAuth token** (the hosted MCP server). Both are judged by one scope policy. ## API keys Mint one under [Settings, API keys](https://shapelessai.com/studio/api-keys). Name it, pick its scopes, copy it once: only its sha256 is stored, so we cannot show it again. Revoking it in the same screen kills it on the next request. A key is `slk_` followed by 40 base64url characters. Send it on every request: ```bash curl -H "Authorization: Bearer slk_..." https://shapelessai.com/api/me ``` ```bash shapeless login # paste it; we verify it and store it 0600 export SHAPELESS_API_KEY=slk_... # beats the stored key - the shape for CI ``` ## Scopes A key holds any subset of three. A cookie session is the human at the keyboard, so it implicitly holds all three. | Scope | What it opens | | --- | --- | | `read` | Every GET: posts, agents, runs, analytics, Brand Memory, media, identity. | | `write` | Drafting and editing: chat turns, files, assets, agents, queue slots, dismissals. | | `publish` | Anything that puts content out or marks it out: creating a post, approving, publishing, waking an agent, sending an engagement action. | The line that matters is `write` against `publish`. A `write` key can draft all day and change nothing the world sees. Only `publish` puts something on a real account. Give a key the least it needs. A read-only key handed to a reporting script that leaks is a leak, not a foothold. ## OAuth, for MCP The hosted MCP server at `https://shapelessai.com/mcp` takes no key. It is OAuth 2.1 with dynamic client registration and PKCE, so a host registers itself: - Protected-resource metadata: `https://shapelessai.com/.well-known/oauth-protected-resource/mcp` - Scopes: `read`, `write`, `publish`, `offline_access` - Transport: streamable HTTP An unauthenticated call answers `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` header pointing at that document, which is the handshake every MCP client keys off. Tokens are revocable by the human under Settings, API keys, Connected apps. Setup per host is on [MCP server](/docs/mcp). ## Rate limit **600 requests an hour per key.** Requests that fail the scope check still count. Cookie sessions are not limited by this. Over the limit is a `429`. Back off; do not spin. Polling a job every second for ten minutes is 600 requests, which is the whole hour - use `jobs_tail` (or `GET /api/studio/tail`), which holds one connection open instead. ## Refusals | Status | Meaning | | --- | --- | | `400` | The body is wrong, and the message says how. | | `401` | Unknown or revoked key, or an expired OAuth token. | | `402` | Out of allowance: the Free plan's ten posts for that UTC day (`code: "free_daily_cap"`, with the `day` and its `resetsAt`) or an empty credit wallet (`code: "trial_exhausted"`). | | `403` | The credential is missing the scope this route needs. | | `409` | The request contradicts the row's current state. | | `422` | A platform rule refused the content. The message names the rule. | | `429` | Over the rate limit. | | `503` | The service has no database. | ## What a key deliberately cannot do These refuse a bearer key. It is not an oversight: - **Key management** (`/api/keys`) - a key must never mint itself a wider key. - **Money** (`/api/checkout`, `/api/portal`) - buying and cancelling belong to the person paying. - **Sign-in and platform connections** - browser redirect flows carrying platform grants. Only `GET /api/connections`, the identity list, takes a key. - **Webhooks and internal cron** - they carry their own signatures. The full list is on [API reference](/docs/api#cookie-only-on-purpose).