Complete reference for the SignalTower REST API. Use these endpoints to submit content for review, read and maintain your brand context, check the calendar, and hear back when a person decides.
Base URL: https://app.signaltower.ai/api/v1
Two ways to create content
This API is for bringing your own agent: your agent generates content and submits it here for human review. If you'd rather not run an agent, SignalTower can also generate content for you in-app from a brief (Studio plan). That flow lives entirely in the dashboard and needs none of these endpoints. Both paths share the same review queue, brand context, and publishing handoff.
Every request carries a Bearer token. Three kinds, all sent the same way:
stk_…workspace keySettings → API & Webhooks (admins). Acts as "Workspace automation" with manager rights, for scripts and servers.
stp_…personal tokenSettings → MCP Server (any member). Acts as you, with your role, in the workspace it was made in.
sto_…sign-in tokenIssued by SignalTower's OAuth 2.1 sign-in when you connect an AI app (see MCP). Acts as you; expires and refreshes.
Authorization: Bearer stp_your_personal_token
Roles gate writes exactly like the app: client can read and comment, manager and above can create and edit. Whatever a person adds is attributed to them and to the app they used ("Maria via ChatGPT"). No token spends credits or takes a post down, and the workspace key never approves. A personal or sign-in token approves only when its person allowed it (see Approving for your user).
GET /me
Catch up in one call: GET /briefing?since= (MCP get_briefing, with all_workspaces for every brand a sign-in reaches) returns what's waiting for the person (each with the link where they act, including this week's weekly-rhythm series with no post yet), what happened since (including last week's missed series), and the next days. When a channel is switched off for everyone (a network has shut out outside apps, as Reddit did in October 2026), unavailable names it: skip it until it's back. Approved posts on it wait and go out on their own, and reply-by-link answers platform_down there. When sending_paused is there, several of the workspace's posts came back from the networks as spam or against their rules and SignalTower is checking it: sends answer 423 outbound_paused, so don't publish or send anything until it's gone. Every write that leaves something for a person returns that link as url, with next when a post waits for review. The prompts people run in their AI app (catch me up, plan the week, plan a launch, weekly review, set up my brand, fix feedback notes, and more) are listed on the Workflows page under "Run by your AI".
Everything a person sees is readable too, shaped by role as in the app. GET /workspace/overview (MCP get_workspace) returns the team, SignalTower's agent settings, connected social accounts and outages, second-life reposts, repeats and getting-started progress; managers also get each drive with its shared folders and file count, media credits and plans (best_times=1 adds the networks' best posting times). GET /posts/:key carries each channel's publish state (live link, error), every version's words, its follow-up, changes waiting for a person and the agent's thread on the post. GET /agent/conversation (MCP get_agent_conversation) is the team's talk with SignalTower's agent, GET /calendar?from= reads past days, and GET /data/activity?feed= (MCP get_data_activity) shows a feed's or source's calls. When a file search comes back empty, its hint names the connected drive and folder and when it was last checked.
GET /briefing?since=2026-09-27T08:00:00Z
→ { "workspace": "DropIn", "summary": "3 things waiting for you, some needed soon.",
"waiting": [ { "kind": "approve", "what": "Snow day · instagram", "needed_by": "2026-09-29", "url": "…" },
{ "kind": "briefs", "what": "6 briefs to approve (2026-10-05 to 2026-10-18)", "url": "…/calendar" } ],
"since": { "published": { "instagram": 4 }, "best": { … }, "decisions": { … } },
"coming_up": { "days": [ … ], "campaigns": [ … ] }, "next_since": "…",
"unavailable": [ { "channel": "reddit", "since": "2026-10-19T…", "waiting_posts": 1, "note": "Reddit is unavailable for now…" } ] } // only while one is offGET /me
→ { "actor": { "kind": "person", "name": "Maria", "role": "manager", "client": "ChatGPT", "label": "Maria via ChatGPT" },
"workspace": { "id": "…", "name": "Fuel Pilot", "content_language": "en", "content_locale": "en-US", "content_locales": [ "en-US" ] } }
PUT /me { "workspace_id": "…" } // change the default workspace of a personal/sign-in token
GET /workspaces // the workspaces this token can work in, and its default
// Any request can name its workspace (id or exact name); otherwise the token's default is used
X-SignalTower-Workspace: DropIn
// Every answer says which workspace it came from: in the body and in headers
→ { "workspace": { "id": "…", "name": "DropIn" }, … }
X-SignalTower-Workspace-Id: …
X-SignalTower-Workspace-Name: DropIn
// Several of your workspaces share that name (an agency's clients often do): name it by id
→ 409 { "error_code": "ambiguous_workspace", "matches": [ { "id": "…", "name": "DropIn" }, … ] }A sign-in covers the workspace you connect from, the ones you pick, or all of yours. A personal token covers the workspace it was made in. Removing it from a workspace's Settings, MCP Server takes only that workspace away. It never reaches a workspace you aren't on, and leaving a team ends its access there.
Every call is kept for 30 days with the workspace it ran in and how that was chosen (named, the connection's only workspace, or its default because nothing was named), the tool or route and the result. Each person sees their own connections' calls in Settings, MCP Server.
Connect Claude, ChatGPT, Gemini, Cursor or any MCP client to https://mcp.signaltower.ai/mcp. Apps that support sign-in are sent to SignalTower to log in and choose which workspaces the app may use (all, or only some); the app then works as you. Every tool takes an optional workspace (name or id), and every result names the workspace it ran in (a first line and a workspace field). When a connection covers more than one workspace, every call names its workspace, reads too, and something that lives in another workspace is never read from there unasked: the error says where it is. The server's title and instructions name its workspace (“SignalTower · Memory.fit”), so an app with several SignalTower connections can tell them apart. Apps that can't sign in use a personal token header. Discovery follows the MCP authorization spec:
GET https://mcp.signaltower.ai/.well-known/oauth-protected-resource GET https://app.signaltower.ai/.well-known/oauth-authorization-server POST https://app.signaltower.ai/api/oauth/register (dynamic client registration) GET https://app.signaltower.ai/oauth/authorize (authorization code + PKCE S256) POST https://app.signaltower.ai/api/oauth/token (authorization_code, refresh_token)
What the AI learns: on connect the server sends short instructions (the one rule, how to start, which tools to reach for), which clients like Claude put in the model's context. whoami returns the same guide plus can: what this person may do in the workspace (their role, the brand's plan, what's switched on, and the tools for each), and can.approve: whether this app may approve and publish for them on their yes (with approve_note saying why not). The full brief is the resource signaltower://guide/agent-brief, the same text as signaltower.ai/agent.md.
GET /me
→ { "actor": { "name": "Maria", "role": "manager", "client": "Claude", … }, "workspace": { … },
"can": { "role": "manager", "plan": "studio", "own_publishing": true, "connected_tools": ["klap"],
"approve": true, "approve_note": null,
"you_can": ["Approve and publish FOR your user: they let this app do it. …", "Make posts: `create_project`, `create_post`, `write_post`; …", …],
"not_here": ["Spend credits: a person does that in SignalTower, always."] } }The MCP server exposes every endpoint below as a tool (whoami, projects, posts, asks, recipes, The Source, visual kit, campaigns, briefs, comments, feedback notes, activity) plus prompts such as "Fill my open asks", "Fix feedback notes", "Clip a video into Shorts", "Repurpose an article" and "Set up my data" (an interview about the business's data, then the simplest way in: a sheet, a feed link, a push, your API or your MCP server). Point your agent at the brief it should read before acting: signaltower.ai/agent.md.
Two nouns, as in the app. A project (prj_…) is the posts about one thing on one day (its day, or none yet), seen together on the calendar before anything publishes. A post is one message across channels (asg_…, grp_… or a post id), the unit people approve; each channel has its own time that day (HH:MM, the brand's timezone), a plan until a person approves it. A post for another day goes into a project of its own on that day. A campaign spans days: its posts sit in one project per day, each carrying it (project_count in GET /campaigns). Several apps and people can build a post: one writes the copy, another adds images, another the video. Everything goes to human review, and moving an approved channel moves its scheduled send with it (a channel that's already out stays where it went out).
Projects
POST /projects { "name": "Ladybug opening day", "subject": "The new center in Northside", "day": "2026-10-05" }
→ { "id": "prj_…", "name": "…", "day": "2026-10-05", "url": "…/projects/prj_…", "posts": [] }
POST /projects { "campaign_id": "cmp_…", "day": "2026-10-06" } → a new project carrying the campaign
GET /projects?search=fall&campaign_id=cmp_… → projects, most recently worked on first, each with
day, post_count, channels, waiting_for_review
and its posts [{ "key", "name", "date", "channels": [{ "platform", "content_id", "status", "time" }] }]
GET /projects/prj_… → the project and every post in it, each channel's time, current copy, status and image
PUT /projects/prj_… { "name": "Ladybug opening day" } → rename it (manager+)
PUT /projects/prj_… { "day": "2026-10-07" } → move the whole project (null = no day yet);
409 when any of its posts is approvedPOST /posts (create)
POST /posts
{ "project": "prj_…", // or "cmp_…" (the campaign's project); omit = a new project of its own
"name": "Electrolyte mix launch", // optional: how people will find it again
"brief": "Launch teaser for the electrolyte mix, for marathoners",
"channels": ["linkedin", "instagram"],
"date": "2026-10-02", // optional in a project (it takes the project's day); another day = a project of its own
"times": { "linkedin": "12:30", "instagram": "09:00" }, // optional: each channel's time (HH:MM, brand's timezone)
"format": "image",
"writer": "outside", // "signaltower" = SignalTower's agent writes it (counts toward the plan's posts)
"campaign_id": "cmp_…", // the campaign it serves: its brief leads, and its project carries it
"audience": "Parents", // who it's for (name or id), see Audiences
"pillar": "Educate", // its content pillar (name or id)
"asks": [{ "kind": "image", "body": "Clean product shot" },
{ "kind": "video", "platform": "instagram", "body": "15s vertical video" }] }
→ { "key": "asg_…", "url": "…/posts/asg_…", "project": { "id": "prj_…", "name": "…", "url": "…" }, "asks": [ … ] }GET /posts · GET /posts/:key
GET /posts?from=2026-10-01&to=2026-10-31&has_open_asks=true (+ the posts with no date yet)
GET /posts?project=prj_… → every post of the project, whatever its day
GET /posts?search=chrome%20extension → find a post by its name (looks a year back)
PUT /posts/asg_… { "name": "Chrome extension launch" } → rename it (manager+)
POST /posts/asg_…/move { "project": "prj_…" } // it takes that project's day; or { "new_project": true, "name": "…" }
POST /posts/asg_…/schedule { "day": "2026-10-07", "time": "18:00", "channel": "instagram_story" }
→ one post (or one channel) to another day: a project of its own there when others stay
behind, the whole project when it's alone ({ "project": "prj_…" } joins one instead;
{ "time": "09:00" } only sets the time). 409 on approved channels. A clash (same
channel within 30 minutes) comes back as "clash"
PUT /content/:id/time { "time": "12:30" } → one channel's time (null clears it)
PUT /posts/asg_…/details { "audience": "Parents", "pillar": "Educate", "slot": "Feature of the week",
"topic": "QR check-in", "campaign_id": "cmp_…", "format": "carousel" }
→ what the post is classified as, at any stage (names or ids; "none" clears one;
format only while it isn't written). Never its words, never a decision.
A brief still waiting for approval changes with PUT /briefs/:key
GET /posts/asg_… → its project, the brief, each channel's live copy + status + decision note,
variants, media (added_by), versions (by), comments, notes, asks,
byline ("Copy by SignalTower, edited by Maria · image by Alex via Claude")
and history [{ "who", "what", "at" }] (newest first)Ready-to-go posts are written and approved ahead of time for something that will happen but can't be dated (a snow day, a school closure, spots opening). Create one with ready_label and no date; approving it never dates or publishes it. When the moment comes, POST /posts/:key/use makes a dated copy of its approved words and image, with the day's small edits, for a person to review (they see only what changed). The original stays for next time.
POST /posts { "brief": "Snow day: centers open, book a spot", "channels": ["instagram", "facebook"],
"ready_label": "snow day", "writer": "signaltower" }
GET /posts?ready=true → { "ready": [ { "key": "asg_…", "label": "snow day", "approved": ["instagram"], "used": 2 } ] }
POST /posts/asg_…/use { "date": "2026-12-03", "time": "07:30", "edits": { "{day}": "Thursday" } }
→ 201 { "key": "asg_…", "channels": ["instagram"], "url": "…" } // a dated copy, waiting for reviewBefore projects held several posts, a project was one post. Those calls still work: GET and PUT /projects/asg_… read and rename that post (the response adds its project), and POST /projects with a brief creates a post in a new project of its own.
Write & change posts
POST /content { "post": "asg_…", "platform": "linkedin", "title": "…", "content": "…", "reason": "…" }
// a channel of that post (one per channel); no media: it takes the post's image,
// with media: channels without one get it too. "post": "prj_…" = a new post in that project
PUT /content/:id { "content": "revised copy", "note": "tighter hook" } // a new VERSION of the same post
PUT /content/:id { "suggested_date": "2026-10-03" }
POST /content/:id/variants { "label": "Question hook", "content": "…" }
DELETE /content/:id/variants/:variantId
POST /content/:id/media { "media_id": "med_…", "role": "main" | "carousel" | "thumbnail",
"position": 0, "all_channels": true, "ask_id": "ask_…" }
// all_channels omitted: every open channel showing the same image (or none) gets it
// all_channels false: this channel only
PUT /content/:id/media { "role": "carousel", "media_ids": ["med_b", "med_a"] } // reorder
DELETE /content/:id/media/:mediaId
POST /content/:id/visual-request { "kind": "image", "direction": "…", "only_this_channel": false } // a blog article's inside image: "article_spot": N
// a proposal a person approves; one visual for the whole post by defaultAll of these work only while the post is open (waiting for review, or sent back with changes). A decided post returns 409. None of them is a decision: no status change, no callback.
A post is one message on several channels: the same message and the same image everywhere. Write the first channel, then write the others from it (same hook and key sentences, only what each channel needs changes). Images follow the post: a visual attached or requested on one channel lands on every open channel that shares its image or has none, in a shape all of them can show. Ask for a different image on one channel with all_channels: false or only_this_channel: true.
What SignalTower can make for a brand: starter recipes by outcome (repurpose an article, react to news, Story series, quote cards, product shots, translate a post, clip a video…) and the team's own saved recipes. A person runs one in SignalTower by typing /name in a prompt box.
Most recipes run on an outside tool with the brand's own account. An admin connects the tool in SignalTower (Recipes, Tools) with that account's API key, and the brand pays the tool directly:
Every run is a proposal a person approves first. It shows what goes to the tool and where the result lands: new posts in the project (they count toward the plan's posts a month), the image or video it replaces on a post (no credits; a layered design keeps its words and logo), or a document in the brand's files (a transcript, a research note). ready means the recipe can run once its tool is connected; runnable means it runs for this brand now.
GET /tools // the brand's connected tools and what each does
GET /tools/:tool/choices?for=avatar|voice|template|captions
POST /tool-runs { "action": "clip", "file_id": "fil_…", "count": 5, "channels": ["instagram","tiktok"] }
POST /tool-runs { "action": "cutout", "post": "asg_…", "background": "white" }
POST /tool-runs { "action": "research", "query": "plant-based menus this week" }
→ { "proposal_id": "prp_…", "status": "waiting_for_approval", "lands_as": "…", "credits_if_approved": 10, "url": "…" }
GET /tool-runs?project=prj_… // waiting_for_approval | running | ran (made) | failed (error)Actions: clip, dub, avatar, transcribe, cutout, upscale, template, captions, slideshow (with voiceover + voice_id for a narrated one), quote_video, research. An edit (cutout, upscale, template, captions) and a slideshow need the post. Send a url only for the person's own video, with own_video: true after they confirm. Asking never runs or spends anything: a person approves in SignalTower. Your app can also do the same work with its own tools and put the results in a project for review (MCP prompts clip_video and repurpose_article).
GET /recipes
→ { "starter": [ { "slug": "repurpose", "category": "repurpose", "name": "Repurpose an article",
"makes": "…", "inputs": ["link", "document"], "built_in": true, "ready": true, "runnable": true,
"tools": [], "also_with": [] },
{ "slug": "clip", "built_in": false, "ready": true, "runnable": false,
"tools": [ { "id": "heygen", "name": "HeyGen", "status": "ready", "connected": false }, … ] }, … ],
"saved": [ { "slug": "tuesday-tip", "name": "…", "makes": "…", "channels": ["linkedin"], "format": "text", "scheduled": true } ] }Comments on the posts SignalTower publishes for the brand (its own publishing, Studio) come into the Inbox. SignalTower's agent sorts each one (question, complaint, lead, praise, spam) and drafts a reply from the brand's Source pages. A person sends it: Send is the approval, and the exact words shown go out from the brand's account. Your app can read the inbox and leave a draft. It sends, hides or marks a comment only for a person who lets it approve for them (see Approving for your user), and never deletes anything.
GET /inbox?filter=needs_reply // needs_reply | open | spam | handled; &post=<content id>; &before=<next_before>
→ { "comments": [ { "comment_id": "inc_…", "channel": "instagram", "post": "<content id>", "author": "Ana Pop",
"text": "What time does it start?", "kind": "question", "language": "en-US", "needs_reply": true, "status": "needs_reply",
"draft": { "text": "Thursday, 6pm.", "by": "SignalTower's agent" }, "replied": [] } ],
"counts": { "needs_reply": 3, "open": 7, "spam": 1 }, "next_before": null }
POST /inbox/inc_…/replies { "text": "Thursday at 6pm, see you there!" } // a draft; a person sends it, in the comment's language
→ 201 { "status": "draft_waiting" }
// language = the comment's language as detected (null when unclear): a reply follows it, else the post's locale, else the brand'sThe brand can also join a conversation that isn't on its own post: paste the link to someone else's post or comment (Reddit, YouTube, Bluesky, LinkedIn, and X where the post mentions the brand). Instagram only lets the brand answer where it was @mentioned: those comments, and posts that mention the brand's LinkedIn page, arrive in the Inbox on their own. It opens in the Inbox like a comment, and the same rule holds: a person approves the exact words, or your app sends them only for a person who lets it approve for them and said yes. Be useful to the thread first, and say plainly that you're with the brand whenever it or its product comes up.
POST /inbox/links/resolve { "url": "https://www.reddit.com/r/AppleNotesGang/comments/1x3paw4/…" }
→ { "can_reply": true, "platform": "reddit", "account": { "account_id": "…", "name": "brand-account" },
"reply_to": "post", "post": { "title": "Evernote x Apple Notes", "text": "…", "author": "…" }, "comment": null }
// can_reply false comes with reason + message (not_yet, platform_refuses, not_found, cant_reply, read_refused, rate_limited, platform_down, no_account…);
// platform_down: the network is switched off for everyone right now (see unavailable on /briefing); skip it until it's back
// several accounts on that platform: { "pick_account": true, "accounts": [ … ] }, then send account_id
POST /inbox/links { "url": "…", "text": "Try one notebook first: export it as .enex…" }
→ 201 { "status": "draft_waiting", "reply_id": "inr_…", "comment_id": "inc_…", "text": "Try one notebook first: export it as .enex…" }
POST /inbox/links/inr_…/send { "text": "Try one notebook first: export it as .enex…" } // the draft's text, byte for byte
→ { "status": "sent", "url": "https://www.reddit.com/r/…/comment/…/" }
// same permission and daily limit as /inbox/:comment_id/send; a workspace sends at most 50 link replies a dayFor 14 days after a reply, answers to it come back into the Inbox as their own items (origin: "link", with answering_our_reply holding what the brand said), sorted and drafted like any comment.
Messages people send the brand's accounts (Instagram, Messenger, TikTok, X, Bluesky, Reddit) arrive as conversations, each with SignalTower's draft. Managers and above only. The brand answers; it never starts a conversation. Instagram and Messenger take an answer within 24 hours of the person's last message, and a late one up to 7 days. Same rule as replies: a person sends, or your app does for a person who lets it approve for them and said yes to the exact words.
GET /inbox/conversations?filter=needs_reply // needs_reply | open | handled; &before=<next_before>
→ { "conversations": [ { "conversation_id": "dmc_…", "channel": "instagram", "with": "Ana Pop", "last_message": "Do you ship to Romania?",
"kind": "question", "needs_reply": true, "reply_window": { "state": "open", "ends_at": "…" }, "draft": { "text": "…", "by": "SignalTower's agent" } } ] }
GET /inbox/conversations/dmc_… // the messages, oldest first: { "from": "them" | "brand", "text", "at" }
POST /inbox/conversations/dmc_…/replies { "text": "…" } // a draft; a person sends it
POST /inbox/conversations/dmc_…/send { "text": "…" } // live, for a person who allowed it; same daily limit as repliesPosts published through SignalTower's own publishing carry their numbers, refreshed about hourly: every published channel in GET /posts/:key and GET /projects/prj_… has numbers, and GET /performance lists the brand's published posts best first, with what's already set up on each. Posts made elsewhere on the same accounts are listed too, with post: null. Each post says its weekly-rhythm series; group_by=slot adds series: per series its published posts, median impressions and likes plus comments, and its best post, strongest first.
GET /performance?days=30&channel=linkedin&sort=impressions&limit=20 // sort: impressions | likes | comments | engagement_rate
→ { "window_days": 30, "sorted_by": "impressions",
"posts": [ { "post": "asg_…", "content_id": "…", "name": "Race week fueling", "channel": "linkedin",
"published_at": "2026-09-20T08:00:00Z",
"numbers": { "impressions": 4200, "reach": 3100, "likes": 88, "comments": 12, "engagement_rate": 2.4, "updated_at": "…" },
"followup": null, "second_life": null, "via_signaltower": true } ] }The Analytics tab, for the last 30 days (manager and up, Studio): totals across the brand's accounts, each channel, each account's followers, and posts through SignalTower against the accounts' own posts.
GET /analytics[?channel=instagram]
→ { "period": "last 30 days", "channel": null, "available_channels": ["facebook", "instagram", "tiktok"],
"summary": { "posts": 24, "impressions": 61200, "reach": 40100, "engagement_rate": 3.1, "followers_now": 18450, "followers_gained": 610 },
"channels": [ { "channel": "instagram", "posts": 10, "impressions": 30100, "reach": 21000, "likes": 900, "comments": 71, "engagement_rate": 4.6 } ],
"accounts": [ { "channel": "instagram", "account": "@halden", "followers_now": 9200, "followers_gained": 340,
"weekly": [ { "date": "2026-09-10", "followers": 8860 }, … ] } ],
"via_signaltower_vs_own": { "signaltower": { "posts": 18, "avg_impressions": 2900, "avg_engagement_rate": 3.4 }, "own": { … } },
"daily": [ { "date": "2026-09-10", "seen": 1800, "engagements": 64 }, … ], // the brand's days; impressions, or views for video
"daily_by_channel": [ { "channel": "instagram", "seen": [ 900, 0, … ] }, … ], // all channels only
"site": { "domain": "halden.com", "search_console": true, "google_analytics": true, "synced_at": "…",
"articles": [ { "content_id": "…", "name": "How to choose a running shoe", "published_at": "…",
"url": "https://halden.com/blog/running-shoes",
"google_search": { "clicks": 412, "impressions": 9800, "ctr": 0.042, "position": 7.8,
"top_searches": [ { "query": "best running shoes", "clicks": 120, "impressions": 3100, "position": 6.2 } ],
"since": "2026-09-02" },
"on_site": { "views": 1630, "visitors": 1210, "avg_engaged_seconds": 94,
"sources": { "search": 0.71, "social": 0.18, "direct": 0.11 }, "visits_from_promo_posts": 140 },
"indexed": "indexed" } ], // indexed | not_indexed | unknown
"searches": [ { "query": "trail shoes for wide feet", "clicks": 3, "impressions": 640, "position": 11.4 } ] } }site is the brand's blog articles in Google Search and on its site, read daily from its Search Console and Google Analytics 4 (2 to 3 days behind at Google). A person connects it on Analytics, Your website: they show the site is theirs with a DNS record or a tag on its home page, then add SignalTower's Google address to both. Without it, site is { "connected": false }. A search the site shows up for around position 5 to 20, searched often, is a good article to write. Post reads (GET /posts/:key, GET /projects/:id) carry the same numbers per channel as site: an article's, or site_visits for a post whose tracked links brought people to the site.
One channel of an open post can carry a follow-up that goes out once if the post reaches a number within some days after it's published: a reply under the post (X, Threads, Instagram, Facebook, LinkedIn, YouTube) or a repost (X, and a reshare by the brand on LinkedIn). A person approving the post approves its exact words and arms it; nothing goes out from your app. It fires only for posts published through SignalTower's own publishing.
PUT /content/:id/followup { "action": "reply", "text": "Signups are open: acme.com/webinar",
"metric": "likes", "threshold": 50, "within_days": 7 }
GET /content/:id/followup → { "followup": { "status": "draft" | "armed" | "fired" | "expired" | "failed", … } }
DELETE /content/:id/followupEach channel published through SignalTower's own publishing carries what goes to its network: Instagram collaborators, tagged people, a location, the paid partnership label, a Reel's sound and alt text; Facebook Reels, countries, link cards and other Pages; TikTok's who can watch, interactions, commercial labels, location and music; YouTube's video title, who can watch, playlist, category, tags, made for kids and the altered or synthetic content label; X's who can reply, polls, quotes and media labels; LinkedIn polls, reposts and link previews; a Threads topic; a Pinterest board, title and link; a Reddit subreddit, title, link, flair and marks; Telegram's silent and protected messages. Changing them is an edit, never a decision: no status change, no callback, and the board marks what your app set. A channel already scheduled with the network is updated there. still_needed lists what a network requires before a person can approve it (TikTok: who can watch; YouTube: the video's title, which SignalTower writes from the description when your app doesn't set one; X and LinkedIn: a poll or a quote with images or a video, which neither network takes). get_post shows each channel's network_settings.
GET /content/:id/settings → { "platform": "instagram", "fields": [ … ], "settings": { … }, "still_needed": [ ],
"images": [ { "media_id": "…", "type": "image" } ], "tiktok": { "privacyLevels": [ … ] } | null,
"facebook_pages": [ … ] | null, "places": [ … ], "partners": [ … ],
"youtube": { "playlists": [ { "id": "PL…", "title": "How-to", "privacy": "public", "count": 12 } ] | null,
"shape": "short" | "video" | null, "seconds": 45, "cover": false, "categories": [ … ] } | null,
"pinterest_boards"?: [ { "id": "1022…", "name": "Recipes", "privacy": "PUBLIC" } ] | null,
"reddit"?: { "subreddits": [ { "name": "socialmedia", "title": "…" } ] | null, "defaultSubreddit": "…" } }
PUT /content/:id/settings { "settings": { "collaborators": ["acme"], "paid_partnership": true, "sponsors": ["acme"] },
"write_alt_text": true } // null clears a field
GET /content/:id/sounds?q=summer&type=music|original_sound (Instagram Reels) ?country=US (TikTok)
→ { "sounds": [ { "id": "…", "title": "…", "artist": "…", "durationSec": 30, "previewUrl": "https://…", "listenUrl": "https://www.instagram.com/reels/audio/…/" } ],
"unavailable"?: "facebook_login" | "tiktok_business" | "needs_video" | … }
Fields instagram: collaborators (≤3), user_tags [{username, x, y, media_index}], place_id, paid_partnership, sponsors (≤2),
share_to_feed, trial_reel (MANUAL | SS_PERFORMANCE), audio_id + audio_title, audio_name, comments_off,
alt_text { media_id: text }
instagram_story: user_tags
facebook: as_reel, title, countries (≤25), link_cards [{link, name, description}] (one per image, 2 to 10), cards_link,
also_pages [Page ids]
tiktok: privacy (everyone | followers | friends | only_me), allow_comment, allow_duet, allow_stitch,
commercial (none | your_brand | branded | both), place_id, music {id, title, volume}, auto_add_music, draft
youtube: title (≤100, the video's title; needed before approval), visibility (public | unlisted | private),
category (YouTube's id, e.g. "26" Howto & Style, "27" Education), playlist {id} or {title} (found, or made on
the channel), also_playlists [ … ] (up to 4 more, added once it's out), tags [ … ] (≤500 characters in all),
made_for_kids, synthetic
x: reply_settings (following | mentioned | subscribers | verified; unset: everyone), poll {options [2 to 4, ≤25],
minutes (5 to 10080)} (the post is the question), quote (a post's link), countries (the media only),
sensitive [adult | violence | other], paid_partnership
linkedin: poll {question (≤140), options [2 to 4, ≤30], minutes (1440 | 4320 | 10080 | 20160)},
quote (a LinkedIn post's link, reposted with this text), no_link_preview, countries (company pages)
threads: topic (≤50, no . or &)
pinterest: board {id} or {name} (found, or made on the account), title (≤100), link (https), cover_offset_ms
reddit: subreddit (without r/), title (≤300), link (a link post), flair {id} or {text}, nsfw, spoiler
telegram: silent, protect, no_link_preview
bluesky: alt_text { media_id: text } (the post's language goes by itself)
also: instagram mute_audio, audio_volume, own_volume (0 to 100) · tiktok title (a photo post's, ≤90), own_volume,
ads_only, music.start_ms · facebook draft
GET /places POST /places { "name": "Main Street store", "instagram_location_id": "1234567890",
"tiktok_location": { "id": "…", "name": "…" } }
PATCH /places/:id DELETE /places/:id
GET /partners POST /partners { "name": "Acme", "instagram": "acme", "tiktok": "acme", "collab": true, "paid": true }
PATCH /partners/:id DELETE /partners/:id
GET /publishing/defaults PUT /publishing/defaults { "account_id": "…", "defaults": { "privacy": "everyone" } }YouTube has no Short switch: it makes a vertical or square video of 3 minutes or less a Short by itself, and youtube.shape says which one this video will be. A custom cover (use_file as cover, or a thumbnail attachment) isn't used on a Short. Chapters are timestamps in the description; the first comment is the copy after a line with only ---. Once a YouTube channel is out, get_post's publish has its url, a short_url when YouTube made it a Short, and playlists_added (or playlists_failed) for also_playlists.
A post that names a saved partner (by name, another name or handle) gets what the partner means set by SignalTower: an Instagram collaborator invite (collab) and the paid partnership label with them as sponsor, branded content on TikTok (paid). Instagram's sound catalog and paid partnership need the account connected through Facebook (a person does it on Publishing with Connect through Facebook); TikTok's music library and locations need it connected through TikTok's Business app.
A TikTok photo post goes out with its whole caption under a title of up to 90 characters (title, or the caption's first line). A TikTok video with a music library track keeps its own sound at 50 unless own_volume says otherwise. A Bluesky post is tagged with the channel's language, so language feeds show it. A video's cover goes to Facebook, LinkedIn and Reddit too.
A workflow is one of the team's saved recipes with a trigger: a new item in a feed (RSS or Atom), a post published, a post reaching a number of likes, comments or impressions, a comment matching a kind or words, a new file in the brand's files, a campaign starting, or new data in one of the brand's data feeds (each new item, a batch of them, or on a schedule with the fresh ones). When it happens, SignalTower's agent runs the recipe in a project with what happened in hand; everything it makes (posts, images, tool runs, reply drafts) waits for a person. Each runs a few times a day at most and pauses itself after 3 failures in a row. People set triggers in the app (Workflows); GET /recipes shows each saved recipe's trigger and status (on, paused or proposed). Your app can propose one: it's saved turned off, the team is told, and it runs only once a person turns it on (for that person). Each run writes posts that count toward the plan's posts a month, so propose one only when the person asked for it.
POST /recipes { "name": "Blog post to posts",
"instructions": "Read the new article and draft ONE post for LinkedIn and X. Leave it undated.",
"trigger": { "kind": "rss", "url": "https://acme.com/blog/feed" }, // or published | metric | comment | file | campaign | data
"channels": ["linkedin", "x"], "runs_per_day": 3 }
// New data: every new item, a batch, or a schedule (the brand's timezone) with the items ready to use then
"trigger": { "kind": "data", "feed_id": "dfd_…", "mode": "each", "filter": { "where": [ { "field": "rating", "op": "=", "value": 5 } ] } }
"trigger": { "kind": "data", "feed_id": "dfd_…", "mode": "batch", "batch_size": 10 }
"trigger": { "kind": "data", "feed_id": "dfd_…", "mode": "schedule", "days": [1], "time": "08:00" }
→ 201 { "workflow": { "slug": "blog-post-to-posts", "status": "proposed", … }, "url": "https://app.signaltower.ai/workflows" }
GET /recipes/runs?recipe=blog-post-to-posts&limit=20
→ { "runs": [ { "workflow": { "name": "Blog post to posts", "slug": "blog-post-to-posts" }, "trigger": "rss",
"what_happened": "Carb loading, explained", "status": "started", "reason": null,
"project": { "id": "prj_…", "url": "…" }, "at": "…" } ] } // status: started | skipped | failedA post that did well (published through SignalTower's own publishing on LinkedIn, X, Facebook, Instagram or Threads) can get a second life in the app: the agent writes variations with new hooks, a person approves each one, and the next approved one goes out every few weeks until the runs are used or one does worse than half the original. Variations published this way arrive as approved content items with metadata.source: "evergreen" and metadata.evergreen_of (the original post). Your app can write the variations itself (no credits): the same message with a new hook each, nothing tied to a day. They join the post's set as drafts; a person approves each one and turns the set on.
POST /content/:id/second-life { "variations": ["Three days out, carbs matter more than…", "The race is won at dinner…"],
"every_days": 21, "times": 3 } // every_days / times are suggestions; the person decides
→ 201 { "second_life": { "status": "draft", "written_by": "Maria via Claude", "variations": [ { "status": "draft", … } ] } }
GET /content/:id/second-life → { "second_life": { "status": "draft" | "on" | "stopped" | "done", … } | null }When SignalTower publishes a post (its own publishing) whose image or video came from its image and video models, it sets the platform's AI-made label where there is one: Instagram (feed, Reels, Stories), Pinterest, X, YouTube, and TikTok for videos. A person can turn it off for a post, with a reason kept on it (metadata.ai_label_off). Photos people upload or pick from their files are never labeled.
Hand-offs between apps and teammates: a to-do left on a post ("needs 3 carousel images"). Attaching a matching image/video closes the ask you name (ask_id) or the single matching open one.
GET /asks?status=open&kind=image&project=prj_…&mine=true // or post=asg_…
POST /asks { "post": "asg_…", "kind": "video", "platform": "instagram", "body": "15s vertical", "assignee_email": "alex@…" }
PUT /asks/:id { "status": "done" | "cancelled", "note": "…" }POST /content: Submit content for human review.
titlestringrequiredShort descriptive title for the content item.
contentstringrequiredThe full content text (post copy, article body, caption). Link to the brand's own pages without tags: when the workspace has tracked links on (Publishing → After approval; link_tagging in GET /brand/pages), UTM tags (utm_source = channel, utm_medium, utm_campaign = the campaign the post serves, utm_content = post id) are added when a person approves the post, shown to them first. A link that already has utm_source is left alone; approved content in callbacks carries the tagged links.
platformstringrequiredTarget platform: x, instagram, linkedin, blog, facebook, tiktok, reddit, threads, youtube, pinterest, bluesky, telegram, snapchat, whatsapp, mastodon, discord, medium, newsletter, other, instagram_story, facebook_story, or one of the brand's own channels (c-Nextdoor, see Source pages). A Story (instagram_story, facebook_story) is a channel of its own: one full-screen 9:16 image or video, gone after 24 hours, and no caption is shown, so its content is the words on the frame (a headline line of 8 words at most, an optional second line of 14 at most, no hashtags or links; a link sticker is added by hand in the app). It needs its own image or video: in a content group, a Story doesn't take the feed channels' image. A Story sequence is 2 to 6 frames published in order a minute apart: pass one image per frame as carousel media in order, and write each frame's words in order with a line containing only --- between frames. It publishes only through SignalTower's own publishing (a connected Instagram or Facebook account), and callbacks carry its channel as platform. A blog post starts with its headline as # Headline: on a connected WordPress site or Shopify store it goes out as an article, the headline its title and the first paragraph its summary.
content · first comments and threadsstringOn linkedin, facebook, instagram and youtube a post can have one first comment, posted by the brand right after the post; on x, threads and bluesky a post can be a thread of up to 10 posts, each within the channel's limit. Write them in content: the post, then a line containing only ---, then the first comment (or the thread's next post, and so on). On LinkedIn, put a link in the first comment rather than the post. A person approves the post and its parts together. Reads return them as parts ({ kind: "comment" | "thread", items }); callbacks carry the whole content. Publishing tools that can't post them (Buffer; Post Bridge on LinkedIn and YouTube, or past a thread's second post) publish the post alone and the post's status says so. Other channels never split: a --- line in a blog post stays in the text.
reasonstringrequiredWhy this content was created. Reference the strategy or brief that prompted it.
agent_namestringYour agent's name. Defaults to "AI Agent".
localestring | string[]Only for a post deliberately in another language than the brand's: a BCP 47 tag like "ro-RO", or a list of up to 3 for both languages in one caption. Omit it to follow the channel's playbook, the audience or the workspace (see Content locale). language is the older name and still works.
audiencestringWho the post is for: an audience name or id (see Audiences). A post in a project takes the project's audience; otherwise, when omitted, its channel decides when it belongs to one audience only.
accountstringTarget account handle (e.g. @brand). For workspaces managing multiple accounts on a platform. Max 100 characters.
suggested_datestringSuggested publish date in YYYY-MM-DD format. In a project on another day, the post becomes a project of its own on this day.
timestringOptional HH:MM (24-hour, the brand's timezone): when it goes out that day. A plan until a person approves it. In items, each item may carry its own.
callback_urlstringURL to receive the review decision webhook. Overrides the workspace default.
metadataobjectArbitrary JSON returned unchanged in callbacks. Use for revision tracking (see Revisions section). Set metadata.visual_brief (max 2000 chars) to pass art direction for the post's visual. SignalTower can generate the image or video in-house from it during review. Never append art direction (e.g. "Image suggestion: …") to the content text; a trailing labeled paragraph like that is stripped and captured as the visual brief automatically.
mediaarrayArray of media attachments: [{ "id": "med_xxx", "role": "main" }]. Upload media first, then reference IDs here. Roles: main, carousel, thumbnail, attachment. A main or carousel entry may carry a composition: an editable layered design over that image (see Layered Designs).
variantsarraySubmit 2-10 content alternatives for A/B testing. See Variants section below.
Example: basic submission:
POST /content
Content-Type: application/json
Authorization: Bearer stk_...
{
"title": "Month-end invoice guilt",
"content": "Month-end is here. That means three things...",
"platform": "linkedin",
"reason": "Monthly pain-point content targeting SMB owners",
"agent_name": "Content Bot",
"suggested_date": "2026-04-10"
}One post on several platforms, published together. Keep it the same post everywhere: the same message, hook and key sentences, changing only what each channel needs (length, line breaks, hashtags, where the link goes), and the same image (give every item the same media). Pass an items array to POST /content, one entry per platform, and the siblings are created atomically sharing a group_id. Each sibling has a fully independent lifecycle: its own status, feedback, edits, and revision thread. The reviewer decides each platform separately; revise only the sibling that was sent back. An items array of length one creates a plain item, no group.
titlestringrequiredShared title for the group (per-item title optional).
reasonstringrequiredWhy this content was created (shared).
itemsarrayrequiredOne entry per platform: { platform, content, title?, media? }. Max 8, platforms unique.
metadataobjectShared across the group; returned in callbacks.
POST /content
{
"title": "Month-end invoice guilt",
"reason": "Monthly pain-point push",
"agent_name": "Content Bot",
"items": [
{ "platform": "linkedin", "content": "Month-end is here. Three things..." },
{ "platform": "x", "content": "Month-end again. A thread on the 3 things..." },
{ "platform": "instagram", "content": "Month-end doesn't have to hurt..." }
]
}
→ { "group_id": "grp_ab12cd34ef", "items": [
{ "id": "...", "platform": "linkedin", "status": "pending_review" }, ...
] }Callbacks fire per sibling decision. Every callback in a group carries the group context, so simple agents can ignore it and sophisticated ones can react per platform immediately:
{
"event": "content.approved",
"content_id": "...",
"group_id": "grp_ab12cd34ef",
"group_status": "2/3 approved",
"group": {
"total": 3, "approved": 2, "changes_requested": 1, "rejected": 0, "pending": 0,
"siblings": [
{ "content_id": "...", "platform": "linkedin", "status": "approved" },
{ "content_id": "...", "platform": "x", "status": "changes_requested" },
{ "content_id": "...", "platform": "instagram", "status": "approved" }
]
},
...
}Groups and variants are different things: variants are alternatives (the reviewer picks one), group siblings are adaptations (all publish, each decided). A sibling may itself carry variants.
Submit 2-10 alternative versions of the same content. Reviewers see all variants in a tabbed interface and select which one(s) to approve. Variants count as a single submission toward your monthly limit.
Each variant object:
labelstringrequiredShort label for this variant (max 60 chars). E.g. "Casual tone", "Question hook".
contentstringrequiredThe full content text for this variant.
media_idsstring[]Media IDs specific to this variant. Different variants can have different media.
Example: submission with variants:
POST /content
{
"title": "Spring campaign tweet",
"platform": "x",
"reason": "Testing three different hooks for Q2 campaign",
"agent_name": "Content Bot",
"variants": [
{
"label": "Casual hook",
"content": "Spring cleaning your routine? Start with the miles."
},
{
"label": "Question hook",
"content": "What if your training plan adapted to you?"
},
{
"label": "Bold statement",
"content": "Your next PR starts with a better plan."
}
]
}When using variants, the top-level content field is optional (defaults to the first variant's content). The reviewer sees tabs for each variant and can select one or more to approve.
POST /media: Upload images or videos before attaching to content.
Option 1: multipart form data:
POST /media Content-Type: multipart/form-data Authorization: Bearer stk_... file: <binary> alt_text: "Description of the image"
Option 2: URL reference (SignalTower downloads the file):
POST /media
Content-Type: application/json
Authorization: Bearer stk_...
{
"source_url": "https://example.com/image.jpg",
"alt_text": "Description of the image"
}Option 3: inline base64 (images only, for agents without a public URL):
POST /media
Content-Type: application/json
Authorization: Bearer stk_...
{
"data": "iVBORw0KGgoAAAANSUhEUg...",
"mime_type": "image/png",
"alt_text": "Description of the image"
}data accepts raw base64 or a data: URI. mime_type is optional: the file type is detected from the bytes when it's missing or generic (application/octet-stream is fine). Images only, 10MB max after decoding. Upload videos via multipart or source_url. A source_url must be a public https:// link; a private or local address is refused (unsafe_url).
Response returns a media ID to use in content submissions:
{ "id": "med_abc123", "type": "image", "url": "https://files.signaltower.ai/..." }Storage: each workspace stores up to 1 GB with no plan, 25 GB on Standard and 100 GB on Studio (uploads, generated images and videos, designs, brand files). When a new file doesn't fit, the upload returns 402 with error_code: "storage_full"; nothing already stored is touched, and reviewing, approving and publishing keep working.
Media roles: main (primary image), carousel (multi-image posts),thumbnail (video thumbnails), attachment (supplementary files).
Instead of uploading actual media, agents can submit a text prompt describing the desired image or video. The prompt appears as a placeholder in the review UI, where a reviewer can upload the media or ask SignalTower's agent to make it (shown as a costed proposal before anything is generated).
Submit a media prompt:
POST /media
Content-Type: application/json
Authorization: Bearer stk_...
{
"prompt": "Runner checking watch at mile 20, golden hour, shot from behind",
"prompt_type": "image",
"alt_text": "Runner at golden hour"
}promptstringrequiredText description of the desired image or video. Max 2000 characters.
prompt_typestring"image" or "video". Defaults to "image".
alt_textstringOptional alt text for the future generated media.
Response returns a media ID with generation_status: "pending":
{
"id": "med_abc123",
"type": "image",
"url": null,
"prompt": "Runner checking watch at mile 20...",
"prompt_type": "image",
"generation_status": "pending"
}Use this media ID in content submissions the same way as uploaded media. The reviewer sees the prompt text as a placeholder where the image would normally appear.
Media is always optional. An agent can submit content with no media, with uploaded files, with prompts, or any combination. If content is approved with unfulfilled prompts, the callback includes a media_prompts array so the receiving agent knows what visuals were envisioned.
Instead of baking text and your logo into the pixels, submit the image without text and describe the words as layers. Reviewers can then move, resize, reword, and restyle them in SignalTower before approving. SignalTower renders the layers to an ordinary image, and that rendered image is what publishes and what your callback's media[].url points at.
Attach a composition to a media entry. The entry's id is the uploaded background, and the first layer must reference it:
POST /content
{
"title": "Launch day",
"content": "...",
"platform": "instagram",
"reason": "...",
"media": [{
"id": "med_background",
"role": "main",
"composition": {
"v": 1,
"width": 1080,
"height": 1080,
"layout": "headline_top",
"layers": [
{ "id": "bg", "type": "image", "media_id": "med_background", "fit": "cover", "locked": true },
{ "id": "headline", "type": "text", "role": "headline", "text": "Ship on Fridays",
"font": "geist", "weight": 700, "size": 92, "color": "#FFFFFF",
"align": "left", "valign": "top", "x": 65, "y": 65, "w": 950, "h": 300 },
{ "id": "logo", "type": "logo", "variant": "auto", "x": 890, "y": 980, "w": 150 }
]
}
}]
}width / heightnumberrequiredFixed per platform: 1080×1080 (instagram, threads), 1600×900 (x, linkedin, facebook, youtube, reddit, bluesky, telegram, blog, newsletter, medium), 1080×1920 (tiktok, snapchat, instagram_story, facebook_story; text is kept out of the top 14% and bottom 20%, where the app's own buttons sit), 1000×1500 (pinterest), and 1080×1350 (4:5, the portrait feed post, when a post is made from a template's 4:5 shape). Coordinates are pixels in that space.
layoutstringrequiredOne of headline_top, headline_bottom, center_scrim, split_panel, clean. The starting layout the reviewer can switch from.
layersarrayrequiredBottom to top, max 24. The first layer is always the image background. Other types: text (role headline, subline, one extra, or any number of text; max 200 chars; optional case: "upper", tracking (letter spacing in em, -0.15 to 0.5), line_height (0.8 to 2.4), bg { fill, opacity, pad, radius } (a chip behind the words), suffix and color_on_dark (see below), rotation and blend), logo (the workspace brand-kit logo; variant auto, logo_light, logo_dark, or logo_mark), element (one of the brand's icons and elements, by element_id; a logo or element takes fit, opacity, color, rotation and blend, see the graphics field below), and shape (rect with fill + opacity, for scrims and panels; an optional gradient paints it instead of the flat fill, see below).
gradientobjectOn a shape, with CSS's geometry so a design written in CSS carries over: { kind: "linear", angle, stops } where angle is in degrees like linear-gradient (180 = top to bottom, the default; 0 = bottom to top), or { kind: "radial", center?, stops }, an ellipse from center (default [0.5, 0.5]) to the farthest corner, like radial-gradient(ellipse farthest-corner). stops: 2 to 8 of { at (0 to 1), color, opacity (0 to 1) }. The layer's opacity applies on top. Full-frame gradient boxes right above the photo give it the cinematic grade (a vignette, a shade at the top, a deep fade under the words).
fontstringOne of: geist, inter, space-grotesk, dm-serif-display, playfair-display, archivo-black, bricolage-grotesque, fraunces, ibm-plex-sans, manrope, sora, syne. Weight 300 to 900 (the nearest the font has).
color / fillstringHex, #RRGGBB. A text, logo or element color may also be "auto": white over a dark area and near-black over a light one, measured from everything drawn under it. color_on_dark (a hex) replaces the color where the area under it is dark.
suffixobjectOn a text layer: { text, color, color_on_dark? }, 1 to 4 characters (no spaces) drawn right after the last letter in their own color, measured with the words so the font's kerning holds. A brand's colored period stays flush whatever the words are.
graphicslogo / elementWithout fit a logo is kept small (at most 8% of the image's height; an element 60%). With fit: "none" it's drawn exactly w wide, up to 3 times the canvas width, its height from its own proportions (it's never stretched; an h is ignored), and it may hang off any edge, clipped only by the image. color tints the whole graphic one color, opacity is 0.05 to 1, rotation turns it around its center, blend is normal, multiply, screen or overlay. Out-of-range values are refused with the range.
Text shrinks to fit its box (down to 60% of size). An invalid composition is rejected with 400 before anything is created. Not supported on variant submissions. Approved callbacks include the final composition on each composed media entry, so your agent can see how the reviewer adjusted your layout.
A template is a layered design the brand reuses: its fields take each post's words, while the layout, colors, logo and fixed text stay the same. Filling one renders on SignalTower's own server, so it costs no credits. People make templates in The Source, Templates (or save any post's design as one); your app can draft them, change any of them in place (active ones too; posts already made from one keep their own design), preview them and put active ones on open posts. A template your app makes is a draft until it's turned on: by a person, or by your app for them when they let it approve for them (POST /templates/:id/status, the same permission as approving; 403 otherwise).
GET /templates?status=active|draft|archived
GET /templates/:id (id or exact name; shapes_detail has every shape's layers)
POST /templates { name, description?, shapes, replaces? } → a draft
PUT /templates/:id { name?, description?, shapes? } (drafts and active ones)
POST /templates/:id/status { status: active|archived } (with the approve permission)
POST /templates/:id/preview { shape?, values?, outline? } → { image: { mime_type, base64 }, warnings }
(outline: true = a full-size PNG with each layer's box and id drawn over it)
POST /content/:id/template { template, values?, file_id? }A template has one document per shape (1:1, 4:5 at 1080×1350, 16:9, 9:16, 2:3, the same canvases as layered designs). A post takes the shape its channels show; a post for Instagram, Facebook, Threads or LinkedIn takes 4:5 over 1:1 when the template has it, and a Story only ever takes 9:16:
{
"name": "Trivia",
"description": "A question with four answers, on the brand's color",
"shapes": {
"1:1": {
"background": { "kind": "color", "color": "$primary" },
"layers": [
{ "id": "label", "type": "text", "text": "Trivia", "case": "upper", "tracking": 0.12,
"font": "geist", "weight": 700, "size": 30, "color": "$ink",
"bg": { "fill": "$accent", "opacity": 1, "pad": 14, "radius": 10 },
"align": "left", "x": 80, "y": 90, "w": 300, "h": 70 },
{ "id": "q", "type": "text", "slot": "question", "label": "Question", "max_chars": 90,
"text": "Which film is this frame from?", "font": "geist", "weight": 700, "size": 64,
"color": "$paper", "align": "left", "x": 80, "y": 200, "w": 920, "h": 260 },
{ "id": "a", "type": "text", "slot": "option_a", "label": "A", "text": "A. Heat", "font": "geist",
"weight": 400, "size": 40, "color": "$paper", "align": "left", "x": 80, "y": 520, "w": 920, "h": 90 }
]
}
}
}backgroundobjectrequired{ kind: "photo", media_id, treatment?, zoom?, focal? } (a sample photo from POST /media; each post uses its own photo there) or { kind: "color", color }. treatment: { grayscale 0 to 1, contrast 0.5 to 2, brightness 0.5 to 1.5, grain 0 to 1 } is drawn on every post's photo (a gritty black and white world: grayscale 1, contrast 1.35, grain 0.35). zoom (1 to 4) crops into every post's photo around focal [x, y] (0 to 1); the subject grows with it.
subjectlayer{ id, type: "subject", treatment? } on a photo background: the person or product cut out of each post's photo, drawn at the photo's own crop. Put it above a headline to set the words behind them; its own treatment (none by default) keeps it in color over a black and white photo. Each post's photo is cut out once, through a proposal a person approves (free with the brand's connected Photoroom or remove.bg, else 1 credit), and only charged when a subject was found. Until then the layer draws nothing. Or your app cuts it out itself: upload a PNG of the whole sample photo with its background removed (POST /media) and put its id in the background as cutout: { kind: "photo", media_id, cutout: "med_…" }. Only its outline is used, filled with the photo's own pixels, so it must be the photo's shape (uncropped and unpadded) and have real transparency; free, and the preview shows the subject.
slotstringMakes a text layer a field each post fills (a key like question, a-z0-9_). headline and subline are the post's own on-image words. Without a slot the layer is fixed text, the same on every post; its text is otherwise a sample and never reaches a post.
max_charsnumberA value longer than this is cut at a word.
typetext stylingfont one of the included fonts or the brand's own (brand:bf_… from GET /brand/visual brand_fonts; a person uploads them in the app, and another brand's font draws in Inter), weight 300 to 900 (the nearest the font has), tracking in em from -0.15 (tight display type) to 0.5, case: "upper", line_height, bg (a chip). fit: "none" makes a bleed: the words keep their size, are never shrunk or broken, and may run off the image on purpose (give the box room past the edge; no warning). mark: { kind: "strike" | "underline" | "highlight", color, thickness, extend? } draws a bar as long as each line of the words, so it follows whatever a field holds. suffix: { text: ".", color: "$accent" } puts a colored period flush after the last letter of whatever words a post puts there. rotation turns the box around its center (90 or -90 for vertical text).
graphicslogo / elementThe brand symbol huge, white and bleeding off the bottom-left, in front of the subject: { id: "symbol", type: "logo", variant: "logo_mark", x: -200, y: 640, w: 1500, fit: "none", color: "#FFFFFF" }. The size, color, opacity, rotation and blend rules are the ones in Layered compositions. An SVG logo or element stays sharp at any size. Add a new graphic with POST /brand/elements.
colorsstringHex, or the brand kit's $primary, $accent, $ink, $paper (resolved when a post is made, so a brand change never restyles an approved post). Text and graphics also take "auto", measured on each post's own photo (after its treatment, overlays and subject), and color_on_dark, used where that photo is dark: color: "$accent" with a lighter color_on_dark for an accent that has to be lighter on a dark photo.
warningsstring[]Returned on every save and preview, never blocking: text boxes on top of each other, text past the edge or in a tall image's unsafe bands, a sample that doesn't fit its box, a color the brand kit lacks.
POST /content/:id/template works on an open post: the fields take values (a headline or subline you leave out keeps what the image says; any other empty field is left off, listed in left_out), the post's own photo stays under it unless you pass a file_id from GET /files, and every open channel sharing the image changes with it. It's recorded as your app's change, never a decision. MCP: list_templates, get_template, create_template, update_template, set_template_status, preview_template (returns the image itself), use_template.
Posts written from a template. Name an active template when you start the post and SignalTower's writer fills its fields as it writes: template on POST /posts, on each brief of POST /briefs, and on a proposed workflow (POST /recipes); a weekly series takes template_id (with feed_id, every post in it is written from the feed's next item). The image is made at no cost when the template has its own color, when the post's data item has an image, or from files attached to the brief; otherwise an image is proposed as usual and the template goes on when a person approves it. A brief for an outside writer shows its template on GET /posts/:key (brief.template): write the post, then call POST /content/:id/template. While a brief waits for approval, PUT /briefs/:key takes template (an id or name; "none" drops it).
Carousel templates. A shape may add frames: { slide: { background, layers }, end?: { background, layers } }: the document itself is the cover, every middle slide repeats the slide frame with its own words, and the end is the last image. A post made from one is a carousel; fill it with values: { cover: {…}, slides: [{…}, …], end: {…} } on POST /content/:id/template(each image of the post takes its frame), and preview a frame with frame on POST /templates/:id/preview.
GET /content: List content items with optional filters.
statusquery paramFilter: pending_review, approved, rejected, changes_requested
platformquery paramFilter by platform (x, instagram, linkedin, etc.)
accountquery paramFilter by account handle
ai_review_statusquery paramFilter by AI review status: none, pending, completed
needs_actionquery paramSet to true to return only rejected / changes-requested items you haven't revised yet (no later submission references them via revision_of). Use it to act on feedback, then resubmit.
fromquery paramStart date filter (YYYY-MM-DD) on suggested_date
toquery paramEnd date filter (YYYY-MM-DD) on suggested_date
limitquery paramResults per page (1-100, default 20)
cursorquery paramPagination cursor from previous response's next_cursor
GET /content?status=pending_review&ai_review_status=none&limit=10
GET /content/:id: Get full content item with media, variants, decision, and AI review.
Response includes:
{
"id": "ct_abc123",
"title": "...",
"content": "...",
"platform": "linkedin",
"status": "approved",
"media": [
{ "id": "med_x", "type": "image", "url": "...", "role": "main" },
{ "id": "med_y", "type": "image", "url": null, "role": "main",
"prompt": "Runner at golden hour...", "prompt_type": "image",
"generation_status": "pending" }
],
"decision": {
"status": "approved",
"edited_content": "revised text or null",
"feedback_tags": [],
"feedback_note": null,
"post_action": "publish_now",
"scheduled_date": null
},
"ai_review": {
"status": "completed",
"decision": "approve",
"confidence": 0.92,
"critique": ["..."],
"suggestion": "...",
"flags": []
},
"variant_group": true,
"variants": [
{ "id": "ct_var_x", "label": "Casual", "content": "...", "selected": true }
]
}GET /calendar?days=14: The workspace's publishing calendar for the next days (1–60): scheduled content, posts SignalTower's agent has planned, gap days, density, and campaigns. Use it to time your submissions: fill real gaps and cover active campaign windows. When the brand set its posting times, posting_times lists them (weekdays and times, in the brand's timezone): prefer those days. An audience can have a cadence (posts_per_week) and room it keeps free for things that come up (keep_free_per_week, set with upsert_audience): audience_weeks gives each week's planned posts and room_left, and POST /briefs warns when a week goes past it. ready_to_go lists the ready-to-go posts.
Weekly rhythm. An audience can have a weekly rhythm: named series on fixed weekdays (“Feature of the week” on Mondays), each with a pillar, a purpose the writer reads, default channels and format, topics that rotate, optionally the audience's active launches in turn, every week or odd/even weeks. Set it with PUT /audiences/:id and rhythm (the whole list; a slot keeps its id when you send it back). With a rhythm, the audience's posts a week are its series plus the room kept free. rhythm on this read gives, per audience per week, each series' state (filled, empty, off_rhythm: another pillar's post sits on its day, missed), the moment on its day, and for empty upcoming ones suggested: the least recently used topic and the next launch in turn. Name the series on POST /briefs, POST /posts and POST /content with slot (an id or name) and topic: it sets the audience, and the pillar, channels and format you leave out. A post naming a series fills it for its week on any day; one that names none fills it on its day when it has the series' pillar. Responses warn (never block) when a series is already filled that week or a post's pillar differs from its series. GET /posts?slot= lists a series' posts. When the person asks, POST /series/:series (the slt_ id or its name) with { "action": "skip", "day": "2026-10-13" } skips it on one of its days, this week or later (on that day the series isn't there: nothing plans, counts or reports it; a day that already has its post can't be skipped), unskip puts the day back, and remove ends the series (its posts keep their series label). Each series lists its skips.
{
"window": { "from": "2026-10-01", "to": "2026-10-14", "days": 14 },
"days": [
{
"date": "2026-10-02",
"items": [{ "id": "…", "title": "…", "platform": "linkedin", "status": "approved" }],
"assignments": [{ "id": "asg_…", "format": "carousel", "platforms": ["instagram"], "status": "scheduled" }],
"gap": false
}
],
"summary": { "total_scheduled": 6, "open_assignments": 3, "gap_days": ["2026-10-04"], "density": 0.93 },
"campaigns": [ … ],
"posting_times": { "timezone": "Europe/Bucharest", "weekdays": ["Tuesday", "Thursday"], "times": ["09:00", "17:30"] },
"rhythm": [
{
"audience": "Parents",
"weeks": [
{
"week_of": "2026-10-12",
"slots": [
{ "slot": "Feature of the week", "slot_id": "slt_…", "day": "2026-10-12", "time": "09:00", "state": "empty", "post": null, "moment": null,
"suggested": { "topic": "booking in minutes", "launch": null } },
{ "slot": "At the center", "slot_id": "slt_…", "day": "2026-10-16", "time": null, "state": "filled", "post": "asg_…", "moment": null }
]
}
],
"recent_topics": { "Feature of the week": ["QR check-in", "live updates"] }
}
]
}Read and manage brand context pages. Agents should read brand guidelines before creating content.
GET /brand/pages: List all pages and campaigns
GET /brand/pages
→ {
"pages": [
{ "id": "uuid", "title": "Brand Voice", "category": "voice", "platform": null, "updated_at": "..." },
{ "id": "uuid", "title": "X playbook", "category": "playbook", "platform": "x", "updated_at": "..." }
],
"campaigns": [
{ "id": "cmp_x", "name": "Summer launch", "status": "active",
"starts_on": "2026-07-01", "ends_on": "2026-07-14",
"brief": "Full campaign brief while active..." },
{ "id": "cmp_y", "name": "Back to school", "status": "upcoming",
"starts_on": "2026-08-20", "ends_on": "2026-09-05", "brief": null }
]
}Campaigns are time-bound briefings. Active campaigns include their full brief and apply to any content whose date falls inside the window. Upcoming and recently ended campaigns are listed with windows only, so date-aware agents can prepare ahead or avoid referencing an expired push.
GET /brand/pages/:id: Read a page
GET /brand/pages/uuid-here
→ { "title": "Brand Voice", "category": "voice", "content": "## Tone
..." }POST /brand/pages: Create a page
POST /brand/pages
{ "title": "Content Pillars", "category": "content", "content": "## Pillar 1
..." }
{ "category": "playbook", "platform": "linkedin", "content": "## Voice on LinkedIn
..." } // one per channel
{ "category": "playbook", "channel_name": "Nextdoor", "content": "## What this channel is
..." } // the brand's own channel → platform "c-Nextdoor"PUT /brand/pages/:id: Update a page (whole or one section)
PUT /brand/pages/uuid-here
{ "content": "## Tone
...", "base_updated_at": "<updated_at from your read>" }
{ "section": "Tone", "section_content": "Warm, direct.", "mode": "replace" | "append", "base_updated_at": "…" }
→ 409 { "error": "The page changed since you read it…", "current": { "updated_at": "…", "content": "…" } }Categories: voice, audience, product, strategy, content,visual, playbook (with platform, or channel_name for a channel no platform covers: Nextdoor, flyers, billboards. That adds the channel itself; its id is the page's platform (c-Nextdoor), and posts, projects and asks can target it. Nothing publishes it: a person posts it by hand). Every save is kept in the page's history with its author.
GET /brand/pages/:id/history[?version_id=bpv_…]
POST /brand/pages/:id/proposal { "section": "Emoji", "section_content": "…", "rationale": "…" } // a person applies it
GET /brand/visual → style_description, style_written_by_hand, references, brand_kit (colors, palette, fonts, audience_note, elements, logos)
PUT /brand/visual { "style_description": "…", "brand_kit": { "colors": { "primary": "#1A2B3C" }, "palette": ["#F4E9D8"] } }
// a style_description sent here is kept when references change
PUT /brand/visual { "rewrite_style": true } → writes the style again from the references and the Visual pages (replaces a hand-written one)
POST /brand/references { "media_id": "med_…" } → style_kept: true when a hand-written style was kept · DELETE /brand/references/:mediaId
POST /brand/elements { "media_id": "med_…" } | { "svg": "<svg …>…</svg>", "name": "Symbol" } → one of the brand's icons & elements (manager+)
// PNG or WebP from POST /media, or an SVG's text (drawing only: scripts, event handlers and links are refused)
DELETE /brand/elements/:elementId
GET /learnings → core page readiness, the Learnings page (distilled from reviews and numbers) and the team's notes, numbered
POST /learnings { "kind": "performs", "text": "Carousels of the full outfit get about twice the reach of single photos on Instagram.",
"evidence": "get_analytics, last 30 days: 8 carousels avg 4,100 impressions vs 14 photos avg 2,000" } // works | avoid | performs | note (manager+)
DELETE /learnings/notes/:index → takes one of the team's notes off the page (manager+)
POST /brand/import { "url": "yourbrand.com", "replace": false } → 202, reads the site in the background (manager+, 3 a day)
GET /brand/import[/:id] → status, steps, and what landed: logo, colors, fonts, images, page proposals,
detected_locale (the site's language and region, from its html lang and text; a suggestion a person confirms, never applied on its own)
DELETE /brand/pages/:id → archives the page with its history (manager+)
GET /brand/archive → archived pages, newest first
POST /brand/archive/:archiveId/restore → puts it back with its history (409 if a page took its place)Audiences are the groups a brand talks to (for example parents and childcare centers). Each has a description, which goes to the writer with every post for it, and its usual channels. A post is for an audience when it says so (audience on projects, briefs and content) or when all its channels belong to that audience only. A channel shared by several audiences (a blog) needs audience: projects and briefs without it get a 400 listing the audiences. Workspaces without audiences work as before.
GET /audiences[?include_archived=true]
POST /audiences { "name": "Parents", "description": "Busy parents who need a trusted drop-in…", "channels": ["instagram", "facebook", "c-Nextdoor"] }
PUT /audiences/:id { "channels": [...] } | { "archived": true }
GET /channels → the brand's channels first (connected, used, with a playbook, its own c-… ones), each with its audiencesContent pillars are the topics a brand wants to own, each for an audience (or every audience) with a target share of that audience's posts. Projects, briefs and content take pillar (name or id); a project's posts inherit it. GET /calendar returns pillar_mix: per audience, posts per pillar against target over the window (a post is a project or a group, not a channel).
GET /pillars[?include_archived=true]
POST /pillars { "name": "Educate", "audience": "Parents", "target_share": 40, "description": "…" }
PUT /pillars/:id { "target_share": 30 } | { "archived": true }
GET /projects?audience=Parents&pillar=EducateYour backend tells SignalTower when a visit from a tracked link turns into a click, a sign-up or a conversion. Authenticate with the send-only events key (Settings → API & Webhooks → Conversion events; it can do nothing else) or with an API key. Send the UTM tags as they arrived: utm_content is the post id, so the event counts for that post, its campaign, pillar, audience and channel when it happens within 30 days of approval; otherwise utm_campaign matches a campaign by its slug. Only the fields below are accepted: any other field is refused with a 400, so no personal data is ever stored. Repeats of your own id are ignored.
POST /events
Authorization: Bearer ste_...
{ "type": "signup", // click | signup | conversion
"occurred_at": "2026-10-08T14:02:00Z",
"utm_source": "linkedin", "utm_medium": "organic",
"utm_campaign": "fall-push", "utm_content": "<post id>",
"value": 49, "currency": "EUR", // optional, conversions
"id": "booking_8812" } // your id: repeats are ignored
// or { "events": [ ... ] }, up to 100
→ 201 { "received": 1, "attributed": 1, "duplicates": 0 }The counts show on published posts, campaign cards and in list_campaigns / get_post, and the weekly learning run compares what converts per channel, pillar and audience.
A campaign's brief reaches the posts dated inside its window that are for its audience (omit for everyone) and on its channels (omit for all). A post that names its campaign (campaign_id) gets that brief as its focus; other campaigns reaching it come in as background. Campaigns are resolved when a post is written, so a post moved to another day follows that day.
A campaign's kind says what it controls. A launch carries facts about one thing (a partner, a location, a product, an offer) and reaches only the posts linked to it (campaign_id, or made in its project), so several launches can run at once without mixing. A theme sets the angle for its days; a moment (a holiday, a closure) overrides that angle on its own days. When they meet in one post the writer is told: facts from the launch, the angle from the moment, else the theme. general (the default) works as campaigns always have. A theme or moment overlapping another for the same audience is saved with a warnings entry, and so is a brief that names a launch it isn't linked to. GET /posts/:key shows, per channel, the campaigns that reached its writer and their role.
A campaign can carry facts: named values the writer uses exactly (a rate, an address, an offer, a booking link), each with an optional valid_until. Only posts the campaign reaches get them, with their age. Each channel keeps the facts it was written against; when one changes, the approve step shows old → new, and approved posts not yet published that still carry the old value are flagged for a person (nothing is unscheduled). GET /posts/:key returns each channel's facts and fact_changes. No personal data: values that look like an email address or a phone number are refused (the rest of the update saves, with a warning).
PUT /campaigns/cmp_… { "facts": [ { "label": "Full-day rate", "value": "$220" },
{ "label": "Trial offer", "value": "First day free", "valid_until": "2026-10-31" },
{ "key": "old_address", "value": null } ] } // merged by key; null removesGET /campaigns?include_past=true → campaigns with status, audience, channels + what they produced
POST /campaigns { "name": "Fall push", "starts_on": "2026-10-05", "ends_on": "2026-10-11", "brief": "…",
"audience": "Parents", "channels": ["instagram", "c-Nextdoor"], "kind": "theme" } // general | launch | theme | moment
→ 201 { …, "kind": "theme", "warnings": ["Overlaps the theme "Flex days explained" (2026-10-05 to 2026-10-11) for Parents. …"] }
PUT /campaigns/:id { "brief": "…" } | { "audience": "everyone", "channels": null } | { "archived": true }
POST /briefs { "briefs": [ { "brief": "…", "date": "2026-10-05", "channels": ["linkedin"], "format": "text", "why": "…",
"audience": "Centers", "campaign_id": "cmp_…", "pillar": "Prove" } ],
"writer": "outside" | "signaltower", "note": "…" }
→ { "plan_id": "pln_…", "status": "waiting_for_approval", "url": "…/calendar?plan=pln_…",
"briefs": [ { "post": "asg_…", "project": "prj_…", "date": "2026-10-05", "brief": "…", "url": "…" } ] }Each brief is a post in its own project from the moment it's proposed (a campaign brief sits in the campaign's project), at stage proposed. A person shapes it there (its words, day, channels, format, series, who writes it) and approves it; only then is it written. While a brief waits, the app that proposed it (or any app with manager rights) can change it; the change is attributed to the app in the brief's history. Nothing here approves or removes a brief. Posts and projects carry a stage:proposed, approved (waiting for its writer or its writing day), writing, written (then each channel's review status). GET /posts?stage=proposed lists the briefs still waiting. An approved brief with writer: "outside" that nobody has written by its lead time is flagged to the managers, who can have SignalTower write it instead.
PUT /briefs/asg_… { "brief": "…", "date": "2026-10-06", "channels": ["linkedin"], "format": "carousel",
"audience": "Parents", "pillar": "Moments", "slot": "Moment of need", "topic": "Sitter canceled",
"campaign_id": "cmp_…" | "none", "writer": "outside" | "signaltower" } // only while proposed
→ { "post": "asg_…", "stage": "proposed", "brief": "…", "date": "2026-10-06", …, "url": "…" }
409 not_proposed when it was already approved or removed
GET /plans/pln_…
→ { "plan_id": "pln_…", "status": "proposed" | "committed" | "discarded", "proposed_by": "Maria via Claude",
"briefs": [ { "post": "asg_…", "project": "prj_…",
"home": { "kind": "campaign" | "named" | "own", "name": "Ladybug launch" | null },
"stage": "proposed" | "approved" | "writing" | "written",
"date": "…", "channels": [ … ], "writer": "outside", "changed": true, "url": "…" } ] }home says where each brief lives: the project of the campaign it serves, a project someone named, or a project of its own made for it. People review a plan across those projects, with the whole plan shown above the brief they're on.
GET /content/:id/comments
→ { "comments": [ … ], // the team thread
"notes": [ { "id": "cmt_…", "status": "open" | "requested" | "question" | "addressed" | "resolved",
"pinned_to": { "type": "text", "quote": "on its own" }
| { "type": "picture", "media_id": "med_…", "x": 0.42, "y": 0.18, "layer": "headline" },
"outdated": false, "author": "Dana", "author_role": "client", "body": "…",
"assigned_to": "Maria", "replies": [ … ],
"working": null, "addressed_by": "SignalTower", "resolved_by": null } ] }
POST /content/:id/comments { "body": "Image is in" } // as you (a workspace key can't comment)
GET /activity?since=2026-09-24T10:00:00Z&limit=40 → who did what (and via which app)Notes are feedback people pin to words in a post's copy or a spot on its image. Read them for context. An outdated note points at words or an image that has since changed. A note is requested once someone hands it to an AI (with an @mention, or "Fix with SignalTower"),addressed when an AI made the change, and resolved only when a person closes it. To answer and fix notes from your app, see Feedback Notes below.
People pin notes to a post's copy or image. Your app reads them any time and fixes one when its person asked: by @mentioning your app on it in SignalTower ("Maria (Claude)"), or with a standing instruction such as a scheduled task ("every 15 minutes, check SignalTower for feedback and fix it"). Every fix is a new version on an open post that a person still reviews; only people resolve notes.
GET /feedback?scope=mentioned|mine|all&since=…&project=prj_…&limit=30 // or post=asg_…
→ { "notes": [ { "id": "cmt_…", "status": "requested", "pinned_to": { … }, "body": "…", "replies": [ … ],
"content_id": "…", "channel": "linkedin", "copy": "…", "post": "asg_…", "url": "…" } ],
"next_since": "2026-09-27T10:15:00Z" }
// scope: mentioned (default) = notes handed to your app; mine = + notes for you; all = + every open note nobody was named for
// never lists notes someone else holds, notes that wait for a person's answer, or notes on decided posts
GET /feedback?status=all → every unresolved note, for context
POST /feedback/:id/claim → hold it 10 minutes (409 if someone else holds it)
DELETE /feedback/:id/claim → let it go
POST /feedback/:id/replies { "body": "Which green?", "question": true } // question: waits for a person's answer
POST /feedback { "content_id": "…", "body": "…", "pinned_to": { "type": "text", "quote": "on its own" } } // leave a note, as you
// Fix it: any change can name the notes it fixes
PUT /content/:id { "content": "…", "feedback_ids": ["cmt_…"], "feedback_reply": "Swapped 'on its own' for 'for you'." }
PUT /content/:id/design { "ops": [ { "op": "set_text", "role": "headline", "text": "Busywork, handled." } ], "feedback_ids": ["cmt_…"] }
PUT /content/:id/design { "ops": [ { "op": "overlay", "style": "cinematic", "strength": 1 } ] } // cinematic | bottom | top | vignette | none
PUT /content/:id/design { "ops": [ { "op": "style_text", "role": "headline", "font": "inter", "weight": 900, "tracking": -0.06,
"upper": true, "fit": "none", "mark": { "kind": "strike", "color": "#3867E8" } } ] }
PUT /content/:id/design { "ops": [ { "op": "photo", "black_and_white": true, "contrast": 1.35, "grain": 0.35 } ] } // merged into the photo's look; "reset": true = as shot
PUT /content/:id/design { "ops": [ { "op": "photo", "zoom": 1.3, "focal": [0.5, 0.4] } ] } // crop in; the subject grows with it
PUT /content/:id/design { "ops": [ { "op": "style_text", "role": "headline", "color": "auto",
"suffix": { "text": ".", "color": "#RRGGBB" } } ] } // a colored period (hex here; $tokens are for templates)
PUT /content/:id/design { "ops": [ { "op": "logo", "on": true, "variant": "logo_mark", "x": -200, "y": 640, "w": 1500,
"fit": "none", "color": "#FFFFFF" } ] } // the symbol huge and bleeding off the edge (opacity, rotation, blend too)
PUT /content/:id/design { "ops": [ { "op": "subject", "on": true, "grain": 0.2 } ] } → cutout: "proposed" when the photo still needs cutting (a person approves it)
PUT /content/:id/design { "ops": [ { "op": "subject", "on": true, "cutout": "med_…" } ] } → cutout: "attached" (your own cutout: a PNG of photo_url with the
background removed, uploaded with POST /media; only its outline is used, so it must be the whole photo, free)
GET /content/:id/design → the image's layout, words, logo, graphics (where the logo and elements sit, how they look), overlay,
photo_crop, photo_url (the bare photo) and the brand's elements
POST /content/:id/media { "media_id": "med_…", "feedback_ids": ["cmt_…"] }
POST /content/:id/visual-request { "direction": "…", "feedback_ids": ["cmt_…"] } // addressed when the approved image landsA change that names a note someone else holds (or one that's addressed, resolved, or on a decided post) fails with 409 and changes nothing. When it lands, each note turns addressed with your reply in its thread. For a scheduled task, pass since = the last next_since and stop quietly when nothing is waiting; the MCP prompt "Fix feedback notes" does all of this.
An approved post that hasn't gone out can still change: new words, or a new image or video. Your app proposes the change; it never changes the post. A person approves it in SignalTower (the decision is theirs, and the post keeps its day and time) or keeps the post as it is. A post still waiting for a decision is changed directly (above); one that's already out can't change. Manager role and up.
POST /content/:id/amend { "content": "Our Wrap Coat is back in camel.", "note": "The price was wrong" }
POST /content/:id/amend { "media_id": "med_…" } // from POST /media; replaces the main visual
POST /content/:id/amend { "media_id": "med_…", "slot_media_id": "med_…" } // or a named one
→ 201 { "content_id": "…", "proposal_id": "pam_…", "kind": "copy", "status": "waiting", "url": "…" }
GET /content/:id/amend → { "proposals": [ { "id": "pam_…", "kind": "media", "media": { … }, "note": "…" } ] }An image a channel can't take (an Instagram feed image outside 4:5 to 1.91:1 or over 8 MB, an X image over 5 MB, an image for YouTube) fails with 422 media_doesnt_fit; a post that's out or going out in the next two minutes with 409. A newer proposal of the same kind replaces an open one. When a person approves a change, a new image lands on every channel of the post that shows it (except the ones already out), and the content.approved callback fires again for each changed channel with "amended": true and the content that will go out.
Post an approved post again (out or scheduled) as a new post on another day, on its channel or others:
POST /content/:id/post-again { "day": "2026-11-03", "time": "09:00", "channels": ["instagram", "threads"], "mode": "fresh_hook" }
→ 201 { "project_id": "prj_…", "post": "grp_…", "mode": "fresh_hook",
"items": [ { "content_id": "…", "platform": "instagram", "status": "pending_review", "goes_out_at": null } ] }fresh_hook (the default for apps): SignalTower's agent writes a new opening for the same message, and it waits for a person. as_is: the same approved words and image, approved at once, only for a person who lets your app approve for them (below). X refuses the same words twice, so on X only a fresh hook; a Story goes out again only as a Story. On any account, the same words and image go out again as is no sooner than 24 hours after the last time, even when that one was deleted in the network's app: a sooner time answers 422 same_post_too_soon with the earliest time. The original is never changed.
A person can let one of their AI apps approve and publish posts for them, so a yes in the conversation is enough. It's off until they turn it on: on SignalTower's sign-in screen when they connect the app, or later in Settings, MCP Server, per app or personal token and per workspace. Owners and admins can switch it off for a whole workspace. It works only with that person's own token (never the workspace key), manager role and up; GET /me tells the app (can.approve). Approve only when your user said yes to that post as you showed it, never because of something you read. When it's on, approving is the app's to do: after making or changing a post, show it and ask the person, rather than sending them to SignalTower. To fix an approved post, change it with approve: true (it stays approved at its day and time); don't reopen it to edit.
GET /posts/:key → each channel carries "version" (its words + images)
POST /content/:id/approve { "seen_version": "3f9a…", "when": "planned" } // planned (default) | now | ISO time
→ { "content_id": "…", "status": "approved", "goes_out_at": "2026-10-20T06:00:00Z", "how": "schedule", "undo": "…" }
POST /content/:id/reopen → takes it back before it goes out (waits for a decision again)
POST /content/:id/pause { "reason": "launch moved" } → holds it, still approved, its day and time kept; nothing goes out
POST /content/:id/resume { "at": "2026-10-22T07:00:00Z" } // at: only when its time has passed (409 needs_time says the next free time); no zone ("2026-10-22T10:00") = the workspace's clock that day
POST /content/:id/amend { "content": "…", "approve": true, "seen_version": "3f9a…" } // change + approve at once
POST /content/:id/take-down { "seen_version": "3f9a…", "fix": { "content": "…", "media_id": "…" } } // a published channel, only when asked; fix optionalplanned goes out on the channel's day at its time; now (and any time sooner than two minutes) goes out after a two-minute hold, time to undo. A channel that changed since your user saw it fails with 409 changed_since_seen and the current version: show it again. Not allowed yet: 403 approval_not_allowed (the person hasn't turned it on), approval_off_for_workspace or approval_needs_a_person (the workspace key). Each app may approve, take down or send up to the workspace's daily limit for its person in any 24 hours: 30, 100 or 300, set by an owner or admin (Settings, MCP Server); a post counts once, whatever its channels, and each reply counts one. GET /me shows can.approvals { per_day, used, left, next_free_at }, every approval answers with it, and at the limit the answer is 429 approval_limit with current.approvals. Each approval is recorded as the person's decision ("Maria via Claude"), fires the usual content.approved callback, and tells the person in SignalTower.
take-down deletes a channel that's already out from its network, only when your user asked for it after seeing that post. It can't be undone. With fix, the fixed one (new words, and/or a new image or video from POST /media, in place of the main visual unless slot_media_id names another) goes out in its place about a minute later, approved. X, Facebook, LinkedIn, Threads, YouTube, Pinterest, Reddit and Bluesky; Instagram, TikTok, Snapchat and Stories don't let apps delete a post (422 says to delete it in the app). It counts against the same daily limit, and the person is told each time.
The same permission covers the replies inbox:
POST /inbox/:comment_id/send { "text": "Thanks, Ana! It's back in camel.", "private": false } // live as the brand
POST /inbox/:comment_id/handle { "action": "hide" } // hide | unhide | done | reopenSend only the exact words your user said yes to; a sent reply can't be taken back. Without the permission, leave a draft (POST /inbox/:comment_id/replies) for a person to send. Replies count toward the same daily limit.
The brand's own photos, videos and documents, from the drives a person connected in SignalTower (The Source, Files): files uploaded to SignalTower, Google Drive folders shared with SignalTower (or single files picked there), and folders shared from Dropbox, OneDrive or Box. Your app can find, read and use them; it can't connect a drive, choose files or folders, or change, rename or delete anything in anyone's drive. Images carry a one-line description of what they show; documents (Google Docs, Sheets and Slides, PDF, Word, PowerPoint, text) are read as text, a page at a time. Manager role and up, on Standard and Studio.
GET /files?query=team at the booth&kind=image|video|document&limit=20&offset=0
→ { "files": [ { "id": "fil_…", "name": "IMG_4821.jpg", "kind": "image",
"description": "Three people at a trade show booth under a blue banner",
"usable_on_post": true, "width": 3024, "height": 4032, "used_count": 2 } ], "more": false }
// Your user just added or replaced a file in the drive: check the drives first (a few seconds)
GET /files?query=check-ins&kind=video&refresh=1
→ { "files": [ … ], "more": false,
"drive_changes": { "added": [ { "id": "fil_…", "name": "set-up-check-ins-9x16.mp4" } ], "changed": [], "gone": [] },
"hint": "New in the drive: …" }
// without refresh, a drive not checked in the last 10 minutes is checked before the search anyway;
// "checking_for_new_files": true = a big drive is still being read, search again in a minute
GET /files/:id?page=1 → a document's text, one page at a time: { "file": { … }, "text": "…", "page": 1, "pages": 6 }
// A Google Drive link the person gave you: resolved to one of this workspace's files
GET /files?url=https://drive.google.com/file/d/1a5j…/view
→ { "files": [ { "id": "fil_…", … } ], "link": { "opened": true } }
→ { "files": [], "link": { "opened": false, "reason": "not_reachable", "message": "…" } }
// reasons: not_reachable (not in a folder shared with SignalTower), folder_shared, folder_not_shared, no_drive,
// other_workspace (SignalTower saved it for another workspace: it stays that brand's)
// Put a file into the brand's files (SignalTower storage, up to 100 MB here; bigger files are uploaded in the app)
POST /files multipart: file=@brief.pdf // or JSON { "source_url": "https://…" } or { "data": "<base64>", "name": "logo.png" }
→ { "file": { "id": "fil_…", "name": "brief.pdf", "kind": "document", "uploaded": true, … }, "duplicate": false }
// images JPEG/PNG/WebP/GIF/HEIC (HEIC becomes a JPEG), videos MP4/MOV, documents PDF/Word/PowerPoint/text;
// the same bytes uploaded before come back with "duplicate": true unless "force": true
// Use one: copied in (free), credited to your person via your app
POST /files/:id/use { "content_id": "…", "as": "picture" } // replaces the main image on the channels sharing it
POST /files/:id/use { "content_id": "…", "as": "carousel", "position": 0 }
POST /files/:id/use { "content_id": "…", "as": "video" }
POST /files/:id/use { "content_id": "…", "as": "background" } // the photo under the post's headline, as an editable design
POST /files/:id/use { "as": "reference" } // into the brand's reference library
POST /files/:id/use { "content_id": "…", "as": "cover" } // a video's cover (thumbnail); the video stays
// all_channels: false = only this channel; feedback_ids + feedback_reply = the notes it fixes
// Save back, into THIS workspace's own folder in the drive ("SignalTower · <workspace>"), new files only.
// GET /workspace/overview shows it first: files.drives[].save_folder { id, name, url }, and linked folders with ids, paths, links
POST /files/save { "content_id": "…" } // or "post": each channel's copy as a Google Doc + its images and videos
POST /files/save { "media_id": "med_…", "name": "Launch photo" }
POST /files/save { "plan_id": "pln_…" } // the plan's briefs as a Google Sheet
POST /files/save { "document": { "name": "Founders list", "content": "…", "format": "doc" }, "folder": "Entrepreneurs" }
POST /files/save { "folder": "Entrepreneurs/2026" } // just the subfolder
→ { "folder": { "id": "1AbC…", "name": "SignalTower · Acme/2026-10/Launch week", "url": "https://drive.google.com/…" },
"saved": [ { "name": "LinkedIn copy", "url": "…" }, { "name": "Image 1.jpg", "url": "…" } ] }
// find_files results carry folder (where it sits in its drive) and link (its page in Google Drive)Posts take JPEG, PNG, WebP, GIF or HEIC images (up to 25 MB; HEIC becomes a JPEG) and MP4 or MOV videos (up to 500 MB); anything else is refused with 415 unsupported_type. A drive's file is copied into SignalTower when it's used (its latest version in the drive, so a video saved over with a new cut goes on as the new cut), so the post keeps it whatever happens to the original; an uploaded file is used as it is. A full workspace answers 402 storage_full. Uploaded files count toward the workspace's storage; people delete them in the app (a file a post uses stays for that post). Saving never overwrites, renames or deletes anything: a name already in the folder gets a number, and a person can switch on "Save approved posts here" for a drive. MCP tools: find_files, read_file, use_file, save_file, upload_file.
The business's own data, kept as feeds: what's happening (open days, testimonials, new listings, events) from a Google Sheet or CSV in the brand's files, a public feed link (RSS, Atom or JSON), or typed in. SignalTower reads a sheet every 15 minutes and a link every hour, and only ever reads. Your app reads the items to write from them, and can add items to a typed-in feed. Manager role and up, on Standard and Studio. The Source, Data has a prompt to paste into Claude Code, Cursor or any AI agent: it asks about the business first, then builds the simplest feed that works.
GET /data/feeds
→ { "feeds": [ { "feed": "open-days", "id": "dfd_…", "name": "Open days", "kind": "availability",
"from": "sheet: Open days 2026", "items": 406, "ready_to_use": 31, "can_add_items": false } ] }
GET /data/feeds/:feed/items?filter=fresh|used|gone|all&q=Lake View&limit=25&offset=0
→ { "feed": { "slug": "open-days", "field_guide": "spots = places left that day…", "quotable": true },
"items": [ { "id": "dit_…", "external_id": "ladybug:2026-10-06", "title": "Ladybug & Friends",
"fields": { "area": "Lake View", "spots": 3, "price": "$85" }, "images": [ "https://…" ],
"link": "https://…", "valid_until": "2026-10-07T11:59:59Z", "state": "new", "used_in_posts": 0 } ],
"more": false }
// Typed-in feeds only (a sheet or a link is edited where it lives); up to 100 items, the same id updates an item
POST /data/feeds/:feed/items { "items": [ { "id": "t-42", "title": "Maria, Lake View parent",
"text": "Booked a Monday at 9pm on Sunday.", "fields": { "rating": 5 } } ] }
→ { "seen": 1, "new": 1, "changed": 0, "gone": 0, "refusals": [], "item_ids": [ "dit_…" ] }
DELETE /data/feeds/:feed/items/:item // dit_ id or your own id
// A live lookup the brand's admin switched on (listed as "lookups" in GET /data/feeds)
POST /data/lookups/:name { "args": { "area": "Lake View", "date": "2026-10-06" } }
→ { "lookup": "search_availability", "source": "DropIn", "answer": "3 open spots in Lake View on Monday", "items": [ item, … ], "truncated": false }
// 60 an hour per source. A system that answers { "answer": "a sentence", "items": [ … ] } is unwrapped like that
// (items in the data format, personal data left out); any other answer comes back as textSetting up the brand's own source (Studio, manager role and up; an admin approves)
Your app can propose the business's own MCP server or API as a source, with what each tool (or path) is for. SignalTower connects at once for a dry run (it lists the tools, calls each proposed feed once and checks each re-check answers for a known item) and keeps a proposal an admin approves tool by tool in The Source, Data. Their approval is the confirmation that each tool only reads: nothing is read on a schedule before it. Tools that don't declare readOnlyHint: true, or declare destructiveHint, are refused. The token is stored encrypted and never returned (only its last 4); leave it out and the admin pastes it when approving. A tool you leave out gets a suggested use from its name, and proposing again for the same server replaces the waiting proposal.
GET /data/sources
→ { "sources": [ { "id": "dsr_…", "kind": "mcp", "name": "Booking system", "status": "active", "url": "https://mcp.example.com/mcp",
"has_token": true, "last_read": "2026-10-04T08:15:00Z", "last_error": null,
"feeds": [ { "feed": "open-days", "tool": "feed_open_days", "schedule": "1h", "items": 406, "ready_to_use": 31,
"field_guide": "spots = places left that day…", "recheck": "get_item" } ],
"lookups": [ { "name": "search_availability", "tool": "search_availability", "use": "lookup", "description": "…" } ] } ],
"proposals": [ /* waiting, or decided this week */ ] } // never a token
POST /data/sources
{ "kind": "mcp", "name": "Booking system", "url": "https://mcp.example.com/mcp",
"auth": { "type": "bearer", "token": "…" }, // or { "type": "header", "header_name": "X-Api-Key", "token": "…" }
"tools": [
{ "tool": "feed_open_days", "use": "feed", "name": "Open days", "kind": "availability", "schedule": "1h",
"field_guide": "spots = places left that day; never promise a spot", "hold_when_gone": true },
{ "tool": "search_availability", "use": "lookup", "description": "Open spots in an area on a day" },
{ "tool": "get_item", "use": "recheck", "feed": "Open days", "id_arg": "id" },
{ "tool": "cancel_booking", "use": "off" } ],
"note": "The open days and the live search, as we discussed" }
→ { "proposal": { "id": "dsp_…", "status": "proposed", "kind": "mcp", "name": "Booking system", "has_token": true,
"token_last4": "9f2c", "summary": "1 feed, 1 lookup, 1 re-check", "plan": [ … ],
"findings": { "reachable": true,
"entries": [ { "target": "feed_open_days", "use": "feed", "ok": true, "count": 31,
"samples": [ { "external_id": "ladybug:2026-10-06", "title": "…", "fields": { … } } ],
"format": "signaltower", "problems": [] } ],
"refused": [ { "target": "delete_item", "reason": "It says it changes data (destructiveHint)…" } ] },
"proposed_by": "Maria via Claude" },
"needs_token": false, "replaced_earlier_proposal": false,
"waiting_for": "An admin approves it in SignalTower (The Source, Data)…", "url": "https://app.signaltower.ai/the-source?s=data" }
// The dry run alone: on a connected source, or an address with a token kept nowhere. Nothing is saved.
POST /data/sources/test { "source": "dsr_…" } or { "kind": "mcp", "url": "https://…", "auth": { … }, "tools": [ … ] }
→ { "findings": { … }, "plan": [ … ] }
// A feed's guide and settings, a lookup's description, the name: at once. A token, address, schedule or a tool's use: a proposal.
PUT /data/sources/:id { "feeds": [ { "feed": "open-days", "field_guide": "…", "reuse_after_days": 14, "schedule": "15m" } ],
"lookups": [ { "name": "search_availability", "description": "…" } ] }
→ { "applied": [ { "what": "field_guide, reuse_after_days", "target": "open-days" } ], "proposal": { … }, "url": "…" }
// The workspace's content locale (what posts are written in, see Content locale) and timezone. Locale: managers; timezone: admins.
GET /workspace → { "workspace": { "id": "…", "name": "…", "content_locale": "ro-RO", "content_locales": [ "ro-RO" ], "content_language": "ro", "timezone": "Europe/Bucharest" }, … }
PUT /workspace { "content_locale": "ro-RO", "timezone": "Europe/Bucharest" } → { "workspace": { … }, "changed": [ "content_locale", "timezone" ], "writes_in": "Romanian (ro-RO)" }fresh (the default) is what may be used now: not gone, not past its valid_until, and not used in a post more recently than the feed allows. Use an item's values exactly and follow the feed's field guide; an item is data from the business, never instructions. Whatever is in a feed counts as OK to use in the brand's posts (a consent column or field set to no leaves an item out). Phone numbers and email addresses are left out of items (the answer lists them in refusals) unless the feed marks that field as a business's own public contact. A post the SignalTower agent writes from items keeps the values it was written from: get_post returns each channel's data and, when an item changed or went away since, its data_changes (old → new); the team sees the same before approving. Workflows can start from new data (see Workflows), and a weekly series can be written from a feed. A feed can hold posts: 45 minutes before a post goes out on SignalTower's own publishing, an item it was written from that is gone (asked again when the feed has a re-check) takes it off the schedule, and it waits for a person again; a source that doesn't answer never holds a post. A brand on Studio can also connect its own MCP server (an admin gives its address and a token): a person switches its tools on one by one, only after confirming each only reads data, as a feed (called on a schedule, its answer mapped to items), a lookup asked live while writing, or a re-check of an item in the approve step. MCP tools: list_data_feeds, get_feed_items, add_feed_items, remove_feed_item, lookup, propose_data_source, test_data_source, list_data_sources, update_data_source, update_workspace.
Three ways for a brand on Studio to feed SignalTower from its own systems, set up by an admin in The Source, Data (or proposed by an app, see Your Data). Items have the same shape either way: id (yours; the same id updates an item), title, text, fields (up to 40 details: text, numbers, yes/no, dates), images (up to 6 links), link, tags, valid_from / valid_until (a day or a time; a bare day counts through that day), consent (default true; false keeps an item out of posts), updated_at, and "gone": true to take one away. Phone numbers and email addresses are left out unless the feed marks that field as a business's own public contact.
Your backend sends it (push)
POST /data/feeds/:feed/items Authorization: Bearer std_… // the feed's send-only key
{ "items": [ { "id": "ladybug-2026-10-06", "title": "Ladybug & Friends",
"fields": { "area": "Lake View", "spots": 3, "price": "$85" },
"images": [ "https://…/ladybug.jpg" ], "link": "https://…/c/ladybug", "valid_until": "2026-10-06" },
{ "id": "acorns-2026-10-06", "gone": true } ] } // up to 100 per call
→ { "seen": 2, "new": 1, "changed": 0, "gone": 1, "refusals": [], "item_ids": [ "dit_…" ] }
DELETE /data/feeds/:feed/items/:id Authorization: Bearer std_…SignalTower calls your API (GET only, public https, your key as a Bearer token or a header you name)
GET {base}/feeds/{feed}?since={iso}&cursor={c} → { "items": [ item, … ], "next_cursor": "…" | null }
// SignalTower follows next_cursor (up to 20 pages); with "ask only for what changed" it sends since= (the last read),
// and an item missing from a full answer is gone
GET {base}/feeds/{feed}/items/{id} → the item, or 404 when it's gone // re-checks in the approve step
GET {base}/lookups/{name}?{params} → { "answer": "text", "items": [ … ] } // asked while writingYour MCP server answers in the data format
// A feed tool: SignalTower calls it on the feed's schedule
feed_open_days { "cursor"?: "…", "since"?: "2026-10-04T08:00:00Z" }
→ { "items": [ item, … ], "next_cursor": null, "field_guide": "spots = places left that day…" }
// SignalTower follows next_cursor (passing cursor), sends since when the feed asks only for what changed,
// and fills the feed's guide from field_guide when it has none
// A re-check tool: asked about one item before a post is approved or goes out
get_item { "feed": "open-days", "id": "ladybug:2026-10-06" }
→ the item, or { "id": "ladybug:2026-10-06", "gone": true } // so does an error saying "not found"; a server that fails or stays silent never does
// A lookup tool: asked live while writing, with the inputs its inputSchema declares
search_availability { "area": "Lake View", "date": "2026-10-06" }
→ { "answer": "3 spots left at Ladybug & Friends", "items": [ item, … ] }Every tool SignalTower may call must carry readOnlyHint: true in its annotations (and no destructiveHint); the others are refused. The re-check takes the item's id in the input the plan names (id_arg) and the feed's slug in feed when it has one. Any other JSON works too, over MCP or an API: the admin points at the list and at the id, title and the rest once, and SignalTower maps every answer the same way.
A brand on Studio whose blog is a site its team built can publish approved Blog posts there, through the blog's MCP server or its API; a Ghost site connects the same way with its address and an Admin API key (Ghost: Settings, Integrations, Add custom integration), and Ghost's own API does each job, so it needs nothing below. An admin connects it in Publishing (or an app proposes it with POST /publishing/own-blog, MCP propose_own_blog), gives each tool its job, confirms that the publishing tool publishes on the blog, and sends a test draft (made, checked and taken down) before turning it on. When it's on, it publishes the Blog channel ahead of a WordPress or Shopify account. Only SignalTower's publisher calls the tools that change the blog, and only for an article a person approved; our AI and outside apps never do.
Over MCP, the tools SignalTower calls
publish_article {
"title": "Why recovery beats volume", "slug": "why-recovery-beats-volume",
"body_html": "<p>…</p>", "body_markdown": "…", "excerpt": "Most plateaus aren't a training problem.",
"meta_title": "Why recovery beats volume", "meta_description": "…", "tags": ["recovery"], "categories": ["Training"],
"language": "en", "cover_image": { "url": "https://files.signaltower.ai/…", "alt": "…" } | null,
"images": [ { "url": "https://files.signaltower.ai/…", "alt": "…" } ], // already in the body; for a blog that keeps its own copies
"status": "publish" | "draft", "idempotency_key": "st-…"
} → { "id": "123", "url": "https://example.com/blog/why-recovery-beats-volume", "status": "published" }
// the same idempotency_key must return the same article, never a second one
get_article { "id": "123" } → { "id", "url", "status" } // optional: confirms it's live
unpublish_article { "id": "123" } → { "ok": true } // optional: taking it down from SignalTower
list_articles { "cursor"?: "…" } → { "items": [ { "title", "url", "published_at" } ], "next_cursor": null }
// optional, read-only: the writer links to the blog's own articlesOver an API (your key as a Bearer token or a header you name)
POST {base}/articles the same body as publish_article → { "id", "url", "status" }
GET {base}/articles/{id} → { "id", "url", "status" }
DELETE {base}/articles/{id} → 2xx
GET {base}/articles?cursor= → { "items": [ … ], "next_cursor": null }An article scheduled for later is held by SignalTower and sent at its moment, so your site doesn't need to schedule; moving or pausing it changes nothing on your blog. A blog that doesn't answer is asked again with the same idempotency key for 30 minutes; one that refuses (an error, or a 4xx) fails the post with your blog's words, and a person can try again. The token is stored encrypted and never returned. GET /publishing/own-blog (MCP get_own_blog) shows the blog, its jobs, its last test and what still blocks turning it on.
An article is a post whose only channel is blog. A post with the blog and other channels is one message on all of them, as always. When SignalTower writes an article, it writes its outline first (article_stage: "outline", which can't be approved) and the full article once a person asks for it (article_stage: "article"), with its search and site fields in article. Post reads show both on the blog channel.
PUT /content/:id/article { "slug": "why-recovery", "meta_title": "…", "meta_description": "…",
"keyword": "recovery training", "category": "Training", "tags": ["rest"] }
// only the fields to change; null clears one. An edit, never a decision.
POST /content/:id/promote { "posts": [ { "angle": "The takeaway", "line": "…", "channels": ["linkedin", "x"],
"day": "2026-11-03", "time": "10:30" } ] } // or { "count": 3 }
→ { "assignment_ids": [ "asg_…" ], "project_id": "prj_…", "errors": [] }Promoting makes 1 to 6 ordinary posts in the article's project, written by SignalTower from the article (free), each waiting for a person's approval. Leave posts out and SignalTower picks the angles, channels and days. Where the link goes, their copy has {{article_link}} (on LinkedIn in the first comment). When a post goes out, it becomes the article's address, with tracked-link tags when they're on. A post whose moment comes before the article is live waits, sends nothing, and goes as soon as the article is out. Posts you write yourself can link to an article the same way: put {{article_link}} in the copy and promotes (the article's blog channel content_id) in its metadata. MCP: promote_article, set_article_fields.
Images inside the article
An article's words mark where each image goes as a spot:  on its own line. SignalTower's writer leaves 2 or 3; add one by writing  where it goes (it gets the next number). Fill a spot with an image you uploaded, or ask SignalTower to make it (a proposal a person approves and pays for). When the article goes out, each filled spot becomes its image (the spot's words are its alt text) and an empty one is left out. Up to 6 images; the post's own image stays the article's cover.
GET /content/:id/article/images → { "spots": [ { "spot": 1, "alt": "…", "image": { "media_id", "url" } | null } ] }
PUT /content/:id/article/images/:spot { "media_id": "med_…" } // from POST /media; an edit, never a decision
DELETE /content/:id/article/images/:spot // empties it
POST /content/:id/visual-request { "article_spot": 2, "direction": "…" } // made after a person approves
POST /files/:id/use { "as": "article_image", "content_id": "…", "position": 2 }MCP: article_images, request_visual with article_spot, use_file as article_image.
Every workspace publishes in a content locale: a language and its formats, as a BCP 47 tag like ro-RO, en-GB or pt-BR. The language decides the words; the region decides how numbers and dates are written, the quote marks and, for some languages, the diacritics. SignalTower's writer gets one line built from it (for example numbers 11.780 and 4,3, dates like 4 octombrie and quotes „…” for ro-RO), English included. A bare code like ro gets its home region.
Two rules:
A channel of a post is written in the first of these that is set: the post's own locale, then its channel playbook's content_locales, then its audience's, then the workspace's. Each level is a list: one entry is the usual case; two (for example ["ro-RO", "en-US"]) means every post in both languages in one caption, in that order. A Story frame always takes the first. The locale a channel was written in stays with it, so changing a setting later never relabels a written post.
// The workspace's (managers; the timezone stays admin)
GET /workspace → { "workspace": { "content_locale": "ro-RO", "content_locales": [ "ro-RO" ], "content_language": "ro", … } }
PUT /workspace { "content_locale": "ro-RO" } → { …, "changed": [ "content_locale" ], "writes_in": "Romanian (ro-RO)" }
PUT /workspace { "content_locales": [ "ro-RO", "en-US" ] } // both languages in one caption
// An audience's (null = the workspace's)
PUT /audiences/:id { "content_locales": [ "en-GB" ] } // a diaspora audience in English
// A channel playbook's (playbook pages only; null = the audience's or the workspace's)
PUT /brand/pages/:id { "content_locales": [ "en-US" ] } // a Romanian brand posting in English on LinkedIn
POST /brand/pages { "category": "playbook", "platform": "linkedin", "content": "…", "content_locales": [ "en-US" ] }
// One post deliberately in another language (it never changes the settings); "language" is the older name
POST /posts { "brief": "…", "channels": [ "linkedin" ], "date": "2026-10-12", "locale": "en-US" }
POST /content { "post": "asg_…", "platform": "linkedin", "content": "…", "locale": "en-US" }
POST /briefs { "briefs": [ { "brief": "…", "date": "2026-10-12", "channels": [ "x" ], "why": "…", "locale": "en-US" } ] }GET /me and GET /brand/pages carry the workspace's content_locale and content_locales; the pages list and GET /audiences carry each playbook's and audience's own. A post read (GET /posts/:key, and every channel of GET /projects/prj_…) says what each channel is written in and which level set it:
GET /posts/asg_…
→ { "brief": { …, "locales": null },
"posts": [ { "platform": "instagram", "locales": [ "ro-RO" ], "locale_from": "workspace", … },
{ "platform": "linkedin", "locales": [ "en-US" ], "locale_from": "playbook", … } ] }A change to the workspace's, an audience's or a playbook's locale is a line in the brand's activity. Formality, slang and emoji stay on the Voice page: the locale settles language and formats only. MCP: update_workspace, upsert_audience, create_brand_page / update_brand_page (content_locales), and locale on create_post, write_post, submit_content and propose_briefs.
GET /notifications/stream: Server-Sent Events stream for real-time workspace events. Connect once with your API key and keep the connection open.
GET /notifications/stream
Authorization: Bearer stk_...
# Events you'll receive:
event: content.submitted
data: {"content_id":"ct_abc","title":"...","platform":"x","agent_name":"Bot"}
event: content.reviewed
data: {"content_id":"ct_abc","status":"approved","feedback_tags":[],"feedback_note":null}
event: ai-review.completed
data: {"content_item_id":"ct_abc"}The stream sends keepalive pings every 30 seconds. If the connection drops, reconnect. SSE clients handle this automatically.
When a human reviews content, SignalTower fires a webhook to your callback URL with the decision. Include callback_url in your content submission, or configure a default in workspace settings.
Callback payload for approved content:
{
"delivery_id": "cb_V1StGXR8_Z5jdHi6B-myT",
"event": "content.approved",
"content_id": "ct_abc",
"title": "...",
"platform": "linkedin",
"original_content": "the text you submitted",
"final_content": "the text after reviewer edits (if any)",
"was_edited": true,
"post_action": "publish_now",
"scheduled_date": null,
"media": [{ "id": "med_x", "type": "image", "url": "...", "composition": { "...": "only on layered designs" } }],
"media_prompts": [
{ "prompt": "Runner at golden hour...", "prompt_type": "image", "status": "pending" }
],
"reviewed_by": "user@example.com",
"reviewed_at": "2026-04-07T10:00:00Z",
"ai_review": { "decision": "approve", "confidence": 0.92 }
}media_prompts: Array of unfulfilled media prompts (where the reviewer approved without generating the media). Only present when prompts exist that haven't been fulfilled. Your agent can use these to generate media post-approval.
Callback payload for rejected/changes requested:
{
"event": "content.rejected",
"content_id": "ct_abc",
"feedback_tags": ["Off-brand", "Too long"],
"feedback_note": "The tone doesn't match our brand voice guidelines."
}Signature verification: If the workspace has a webhook secret, callbacks include an X-SignalTower-Signature header with the format sha256=hex_hmac. Verify using HMAC-SHA256 of the request body with your webhook secret.
Idempotency: Every callback carries a stable delivery_id in the payload (also sent as the X-SignalTower-Delivery-Id header). It stays the same across all retry attempts of the same decision, so you can safely dedupe: record processed delivery_ids and ignore repeats. The X-SignalTower-Attempt header carries the 1-indexed attempt number. SignalTower retries on network errors and 5xx responses with exponential backoff (1min, 5min, 30min, 2hr, 12hr, up to 5 attempts); a 4xx response (other than 408/429) is treated as permanent and not retried.
Bearer token (optional): If you configured a Bearer token for a destination in Create → How decisions come out, SignalTower also sends an Authorization: Bearer <token> header alongside the signature. This is additive: HMAC fires on every webhook regardless. Required for receivers like OpenClaw Gateway that enforce HTTP-layer authorization.
Post actions: publish_now, schedule, save_draft, send_to_agent, manual.
OpenClaw is a self-hosted gateway that connects messaging channels (Telegram, Slack, Discord, WhatsApp) to long-running AI agents. It is the primary runtime target for SignalTower users who want a personal-assistant workflow where they talk to an agent and that agent handles content drafting, submission, and decision follow-up.
SignalTower integrates with OpenClaw using only documented OpenClaw primitives: hooks subsystem, mappings, skills, standing orders, and cron jobs. Nothing runs inside the OpenClaw Gateway process; the integration is config-based.
Pick the shape that matches your intent. The axes (how content gets in, how decisions come out) are independent, so you can mix and match.
Shape A: Conversational Operator
You talk to your agent on Telegram/Slack throughout the day. Ask it to draft content, it submits to SignalTower. When a decision arrives, the agent proactively messages you with the result and can propose revisions or act on the decision. Default for most users.
Shape B: Notified User
You want review decisions as plain notifications, not conversational responses. The agent relays the decision verbatim without engaging. Best for users who submit content from the UI directly or from a different source and just want to know the outcome.
Shape C: Automation Builder
Content submission is driven by a scheduled OpenClaw cron job or external trigger. The agent acts on decisions autonomously per a standing order in your AGENTS.md (publish on approve, revise on changes, escalate on repeated rejection).
This walkthrough covers the most common case: a conversational agent that submits content and relays decisions. Shapes B and C build on the same foundation.
Prerequisites
Step 1: Configure openclaw.json hooks
Add a hooks block to your ~/.openclaw/openclaw.json:
{
"hooks": {
"enabled": true,
"token": "${OPENCLAW_HOOKS_TOKEN}",
"mappings": [
{
"match": { "path": "signaltower" },
"action": "agent",
"agentId": "main",
"deliver": true,
"channel": "telegram",
"to": "<your chat id>",
"messageTemplate": "{{message}}"
}
]
}
}The mapping routes incoming webhooks at /hooks/signaltower to an isolated agent turn. The agent reads the decision payload and messages you on your configured channel.
Step 2: Set environment variables
# In ~/.openclaw/.env or your systemd unit OPENCLAW_HOOKS_TOKEN=<generate a strong random token> SIGNALTOWER_API_KEY=<from SignalTower → Create → API Key>
OPENCLAW_HOOKS_TOKEN is the Bearer token OpenClaw's hooks subsystem requires in the Authorization header. Restart your Gateway after setting it so the hooks config is loaded.
Step 3: Configure SignalTower
Go to Create → How decisions come out → Text Content Decisions, select Your own endpoint, and fill in:
https://<your-gateway-host>/hooks/signaltowerOPENCLAW_HOOKS_TOKEN from Step 2Click Save on both fields. The Bearer token is stored as a secret. SignalTower never returns it verbatim to the browser after save.
Step 4: Test the connection
Click the Test button next to the Webhook URL. SignalTower fires a signed test event at your Gateway with the Bearer token attached. If everything is configured correctly, your Gateway accepts the request (200 OK) and your agent delivers a test notification to the channel configured in the mapping.
Step 5: End-to-end test
Message your agent and ask it to draft a post. It calls the SignalTower API, you review in the UI, click Changes or Approve, and a proactive notification arrives on your channel. Target latency: under 5 seconds from click to notification.
The setup is identical to Shape A. The difference is in the mapping template. Instead of giving the agent an open-ended prompt, use a relay-style prompt that instructs it to deliver the decision as a plain notification without engaging conversationally. Your agent runs one turn per decision to format and deliver the message.
Cost: one LLM call per delivery. For zero-token-cost delivery, advanced users can ship a signaltower-direct transform file that calls openclaw message send from the hook transform, bypassing the agent turn entirely.
Builds on Shape A, but adds a standing order in your AGENTS.md and a cron job for scheduled submissions. The standing order defines the agent's autonomous authority: what it can do without asking, when to escalate. The cron job runs an isolated agent turn at a scheduled time that reads the standing order and executes the pipeline.
Example cron job:
openclaw cron add \ --name "daily-linkedin" \ --cron "0 9 * * 1-5" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Execute SignalTower content pipeline per standing orders."
At 9 AM on weekdays the isolated agent turn reads the standing order, generates content per your brand guide, submits to SignalTower, and terminates. When a decision arrives, it routes back to your main session (Shape A) or to another isolated session, depending on your mapping config.
401 Unauthorized from your Gateway
The Bearer token SignalTower sends does not match OPENCLAW_HOOKS_TOKEN on your Gateway. Both sides must have the exact same value. Update the token in SignalTower and click Test.
Test button times out
Your Gateway URL is not reachable from SignalTower's servers. Check that your Cloudflare Tunnel, Tailscale Funnel, or reverse proxy is running. Try hitting the URL directly from a public machine with curl.
Test succeeds but real webhooks don't arrive
Your Gateway likely received the real payload but the mapping or agent turn failed. Check the OpenClaw Gateway logs for errors in the mapping transform or agent response. The hosted Callback Inbox in SignalTower records every webhook attempt. Check there for the delivery status and response body.
HMAC signature verification in OpenClaw transforms
OpenClaw does not verify the HMAC signature at the hook layer. It only checks the Bearer token. If you want to verify the X-SignalTower-Signature header inside your agent or transform, compute HMAC-SHA256 of the raw request body with your SignalTower webhook secret and compare.
For the full design rationale, primitives reference, and advanced topics, see the OpenClaw docs and the integration spec at docs/openclaw-implementation.md in the SignalTower repository.
SignalTower applies per-workspace rate limits to prevent abuse.
When a limit is exceeded, the API returns 429 Too Many Requests with an error message.
When resubmitting content after a rejection or changes request, include revision metadata so reviewers can see the content history.
POST /content
{
"title": "Updated: Month-end invoice guilt",
"content": "Revised content addressing feedback...",
"platform": "linkedin",
"reason": "Revision addressing tone feedback from reviewer",
"metadata": {
"revision_of": "ct_original_id",
"revision_number": 2,
"addressed_feedback": ["Tone is off", "Too long"]
}
}The reviewer sees a revision badge in the UI. Items with revision_number >= 3 are flagged red to indicate repeated failures.
SignalTower API v1 · Get your API key