Build with Pulse Publish.
Add video publishing to your app: upload once, set a caption per network, get a person's approval, and publish through every network's official API. One base URL, bearer auth, JSON both ways.
| Base URL | https://publish.debuginit.com |
|---|---|
| Auth | Authorization: Bearer <DebugInit ID access token> |
| Format | JSON; uploads are the raw file |
| Machine-readable | /openapi.json (OpenAPI 3.1): import it into Postman, Insomnia or a code generator |
| AI agents | MCP server at https://publish.debuginit.com/mcp, see MCP |
Built in-house by DebugInit. Pulse Publish is not a wrapper around another posting service. The DebugInit team wrote every network connector, the approval engine and this API in Rust, and it calls each network's official API directly.
Get access.
Create a DebugInit ID
Sign up at id.mypulse.buzz and verify your email. It's the identity your integration calls with.
Request API access
Tell us what you're building through debuginit.com/contact, with your verified email, the networks you need, and your web origin if you'll call from a browser.
Get your access level
We enable your ID with read or write access and connect the network accounts you'll publish to.
GET /v1/meshows what you have.
From a token to a live post.
The whole journey once, end to end. Pick your language in any code box; the rest of the page follows.
Set up
Get an access token for your DebugInit ID (see Authentication) and keep it in an environment variable.
export PP=https://publish.debuginit.com export TOKEN=… # your DebugInit ID access token// Node 18+, or a browser on a registered origin const PP = "https://publish.debuginit.com"; const TOKEN = process.env.TOKEN; // DebugInit ID access token const H = { Authorization: "Bearer " + TOKEN };# pip install requests import os, requests PP = "https://publish.debuginit.com" H = {"Authorization": "Bearer " + os.environ["TOKEN"]}Check your access
Expect your account, and
"write": trueif you can create and approve.curl "$PP/v1/me" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/me", { headers: H }); console.log(await res.json());r = requests.get(f"{PP}/v1/me", headers=H) print(r.status_code, r.json())See the connected accounts
Only networks with
"connected": truecan be posted to. See Connected accounts.curl "$PP/v1/networks" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks", { headers: H }); console.log(await res.json());r = requests.get(f"{PP}/v1/networks", headers=H) print(r.status_code, r.json())Upload the video and cover
curl -X POST "$PP/v1/media?name=edge-part2.mp4" \ -H "Authorization: Bearer $TOKEN" \ --data-binary @edge-part2.mp4 curl -X POST "$PP/v1/media?name=edge-part2-cover.png" \ -H "Authorization: Bearer $TOKEN" \ --data-binary @edge-part2-cover.pngimport { readFile } from "node:fs/promises"; for (const name of ["edge-part2.mp4", "edge-part2-cover.png"]) { const res = await fetch(PP + "/v1/media?name=" + name, { method: "POST", headers: H, body: await readFile(name) }); console.log(res.status, await res.json()); // 201 { name, bytes } }for name in ["edge-part2.mp4", "edge-part2-cover.png"]: with open(name, "rb") as f: r = requests.post(f"{PP}/v1/media", params={"name": name}, headers=H, data=f) print(r.status_code, r.json()) # 201 {'name': ..., 'bytes': ...}Create the post
One default caption, overrides where a network needs its own, and a time.
curl -X POST "$PP/v1/posts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "id": "edge-part2", "at": "2026-11-20T09:30:00+05:30", "title": "Why edge systems", "video": "edge-part2.mp4", "cover": "edge-part2-cover.png", "targets": ["linkedin", "x", "pinterest"], "caption": "Part 2 · Why edge systems keep working when the cloud doesn't.", "captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" }, "settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } } }'const res = await fetch(PP + "/v1/posts", { method: "POST", headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify({ "id": "edge-part2", "at": "2026-11-20T09:30:00+05:30", "title": "Why edge systems", "video": "edge-part2.mp4", "cover": "edge-part2-cover.png", "targets": ["linkedin", "x", "pinterest"], "caption": "Part 2 · Why edge systems keep working when the cloud doesn't.", "captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" }, "settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } } }), }); console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts", headers=H, json={ "id": "edge-part2", "at": "2026-11-20T09:30:00+05:30", "title": "Why edge systems", "video": "edge-part2.mp4", "cover": "edge-part2-cover.png", "targets": ["linkedin", "x", "pinterest"], "caption": "Part 2 · Why edge systems keep working when the cloud doesn't.", "captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" }, "settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } } }) print(r.status_code, r.json())Preview it for a network
The exact text that will go, and its length as that network counts it.
curl "$PP/v1/posts/edge-part2/x" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x", { headers: H }); console.log(await res.json());r = requests.get(f"{PP}/v1/posts/edge-part2/x", headers=H) print(r.status_code, r.json())Approve it
One call per network. This is the human step: put it behind an Approve button in your app, pressed by the person accountable for the post.
curl -X POST "$PP/v1/posts/edge-part2/x/approve" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x/approve", { method: "POST", headers: H }); console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts/edge-part2/x/approve", headers=H) print(r.status_code, r.json())Publish now, or let the time come
Approved posts go out at
at. To go now:curl -X POST "$PP/v1/posts/edge-part2/x/publish" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x/publish", { method: "POST", headers: H }); console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts/edge-part2/x/publish", headers=H) print(r.status_code, r.json())Read the result
statusbecomespostedwith the liveurl, orfailedwith the network's ownerror.curl "$PP/v1/posts/edge-part2" \ -H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2", { headers: H }); console.log(await res.json());r = requests.get(f"{PP}/v1/posts/edge-part2", headers=H) print(r.status_code, r.json())
That's the whole API. Everything else is detail: per-network settings, connected accounts and the reference below.
Authentication
Every /v1 call except /v1/health needs Authorization: Bearer <token>, where the token is a DebugInit ID access token for an account that has API access.
| Issuer | https://id.mypulse.buzz (OpenID configuration) |
|---|---|
| Flow | OAuth 2.1 authorization code with PKCE (S256) |
| Scopes | openid email profile |
| Checked | On each call (cached for a minute): the token must be valid and its verified email must have access |
Apps acting for a person sign them in with DebugInit ID and send their token. Server-to-server integrations use a token for a dedicated DebugInit ID created for that service.
Keep tokens out of URLs, logs and front-end bundles. In CI, store them as secrets. A 401 means the token expired or was revoked: get a fresh one.
Access levels.
Read lets you list networks, posts, previews, media and activity. Write also lets you upload, create, delete, approve and publish. Without write access every POST and DELETE answers 403 and nothing changes. GET /v1/me returns "write". In the reference, calls that need it are marked write access.
Posts and targets.
A post is one video with a title, a time and captions. Each network it goes to is a target: post × network. Targets are approved, published and reported one by one, so LinkedIn can be live while X still waits for approval.
Posts you create have "source": "api" and can be deleted until something is posted. Posts the workspace manages itself show "source": "config"; you can read and approve them, not delete them.
Approval#
Approving stores a fingerprint of the title, caption, settings, video and cover. Before anything is sent, the fingerprint is checked again. If it differs, approval reads changed and the target is refused until a person approves it again.
Lifecycle#
A failed target keeps its error. Fix the cause (a caption that's too long, an expired sign-in) and publish it again.
Scheduling#
Approved targets go out at their at time; the scheduler checks every minute. GET /v1/me shows "schedule": "on" while scheduled publishing runs. publish sends immediately whatever the time.
Recipes for real posts.
Post to a LinkedIn Page, for an audience#
Target linkedin_page and say which Page in settings: an id, a link, a vanity name or the Page's name. A Showcase Page works the same way. An audience narrows who sees it in their feed; LinkedIn needs more than 300 matching members.
curl -X POST "$PP/v1/posts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "edge-page", "at": "2026-11-21T09:30:00+05:30", "title": "Why edge systems",
"video": "edge-part2.mp4", "targets": ["linkedin_page"],
"caption": "Why edge systems keep working when the cloud doesn't.",
"settings": { "linkedin_page": { "page": "143896897",
"audience": { "locations": ["India"], "seniorities": ["director", "vp", "cxo"] } } }
}'const res = await fetch(PP + "/v1/posts", {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({
"id": "edge-page", "at": "2026-11-21T09:30:00+05:30", "title": "Why edge systems",
"video": "edge-part2.mp4", "targets": ["linkedin_page"],
"caption": "Why edge systems keep working when the cloud doesn't.",
"settings": { "linkedin_page": { "page": "143896897",
"audience": { "locations": ["India"], "seniorities": ["director", "vp", "cxo"] } } }
}),
});
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts", headers=H, json={
"id": "edge-page", "at": "2026-11-21T09:30:00+05:30", "title": "Why edge systems",
"video": "edge-part2.mp4", "targets": ["linkedin_page"],
"caption": "Why edge systems keep working when the cloud doesn't.",
"settings": { "linkedin_page": { "page": "143896897",
"audience": { "locations": ["India"], "seniorities": ["director", "vp", "cxo"] } } }
})
print(r.status_code, r.json())Audience keys: locations, seniorities (unpaid, training, entry, senior, manager, director, vp, cxo, partner, owner), industries, job_functions.
One caption per network#
caption is the default; captions overrides it for any network. Check with the preview call: on X a link always counts as 23 characters and an emoji as 2, so the raw length isn't what X sees.
curl "$PP/v1/posts/edge-part2/x" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/posts/edge-part2/x", headers=H)
print(r.status_code, r.json())Pinterest Pins#
Pins have their own title (up to 100), a destination link and alt text (up to 500). The board is the one chosen when Pinterest was connected.
"targets": ["pinterest"],
"captions": { "pinterest": "Six reasons edge systems matter, with real examples. Part 2 of 3." },
"settings": { "pinterest": {
"title": "Why Edge Systems Matter: 6 Real Examples (Part 2 of 3)",
"link": "https://wayworks.space",
"alt_text": "Vertical explainer video on six reasons edge systems matter"
} }Connected accounts#
Pulse Publish holds the sign-ins to each network, so your app never stores a LinkedIn or X token. GET /v1/networks lists them with connected, the account name and expires_in_days.
- To connect an account, or reconnect one before it expires, call
signin. - Send the account's owner to the returned
openURL. They approve on the network's own screen; the network returns to Pulse Publish, which shows "Connected". - Check
GET /v1/networksagain. A network not yet enabled for your access answers409: ask us to enable it.
curl -X POST "$PP/v1/networks/linkedin/signin" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks/linkedin/signin", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/networks/linkedin/signin", headers=H)
print(r.status_code, r.json())Call from a browser#
Browsers may call the API from the web origins registered for your integration (CORS); tell us yours when you request access. Use the signed-in person's own DebugInit ID token.
// inside the Pulse app: token = the signed-in person's DebugInit ID access token
const res = await fetch("https://publish.debuginit.com/v1/posts", {
headers: { Authorization: "Bearer " + token },
});
if (res.status === 401) return signInAgain();
const { posts } = await res.json();Automate from CI#
Render the video in CI, upload it, queue it, and leave approval to a person. Keep the token in the CI's secrets.
# .github/workflows/queue-video.yml
on: { workflow_dispatch: {} }
jobs:
queue:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
curl -fsS -X POST "$PP/v1/media?name=weekly.mp4" -H "Authorization: Bearer $TOKEN" --data-binary @out/weekly.mp4
curl -fsS -X POST "$PP/v1/posts" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @post.json # approval stays with a person
env:
PP: https://publish.debuginit.com
TOKEN: ${{ secrets.PULSE_PUBLISH_TOKEN }}Let an AI agent do the busywork.
Pulse Publish is also an MCP server. Claude, Cursor, VS Code or any MCP client can check accounts, draft captions within each network's limits, upload and queue posts, and read results, then stop and ask a person before anything is approved or published.
| Endpoint | https://publish.debuginit.com/mcp |
|---|---|
| Transport | Streamable HTTP, JSON responses, stateless |
| Protocol | 2026-07-28 and 2025-11-25 · 2025-06-18 · 2025-03-26 |
| Auth | Authorization: Bearer <DebugInit ID access token> |
| Offers | 14 tools · 4 resources and 2 templates · 3 prompts |
See an agent at work#
A real sequence of tool calls. The agent does the legwork and hands the decision back to you.
YouQueue the edge-systems video for LinkedIn and X, tomorrow 9:30, captions in our voice.
whoamiwrite access · schedule onlist_networkslinkedin ✓ · x ✓ · pinterest ✓list_mediaedge-part2.mp4 · edge-part2-cover.pngcreate_postedge-part2 queued for Tue 09:30 · approval: nopreview_post · x214 of 280 characters (links count as 23) · fitspreview_post · linkedin1,180 of 3,000 characters · fits
Approval needed. approve_post publishes in your name, so the agent stops and shows you both captions.
approve_post · xapproval: okapprove_post · linkedinapproval: ok
AgentApproved. Both go out Tue 09:30; I'll check get_post for the live links after.
AgentNothing was approved. The post waits in your queue until you say so.
The human stays in charge. approve_post and publish_now are marked as write tools, and the server's instructions tell agents never to call them without your go-ahead. Without write access they refuse anyway.
Connect your client#
Pick your client, paste, and set your token. Then ask it something like "what's waiting for approval in Pulse Publish?"
# Claude Code
claude mcp add --transport http pulse-publish https://publish.debuginit.com/mcp \
--header "Authorization: Bearer $TOKEN"// .cursor/mcp.json
{
"mcpServers": {
"pulse-publish": {
"url": "https://publish.debuginit.com/mcp",
"headers": { "Authorization": "Bearer ${env:PULSE_PUBLISH_TOKEN}" }
}
}
}// .vscode/mcp.json
{
"inputs": [{ "type": "promptString", "id": "pp-token", "description": "Pulse Publish token", "password": true }],
"servers": {
"pulse-publish": {
"type": "http",
"url": "https://publish.debuginit.com/mcp",
"headers": { "Authorization": "Bearer ${input:pp-token}" }
}
}
}// Claude Desktop and other stdio-only clients: claude_desktop_config.json
{
"mcpServers": {
"pulse-publish": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://publish.debuginit.com/mcp", "--header", "Authorization:Bearer ${PULSE_PUBLISH_TOKEN}"],
"env": { "PULSE_PUBLISH_TOKEN": "…" }
}
}
}Tool explorer#
Every tool runs the same code as its REST call. Pick one to see its schema, hints and an example call.
Who am I whoami
Who the token belongs to, whether writing is on (approve/publish/create need it), and whether the schedule runs.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {},
"name": "whoami"
}
}{
"additionalProperties": false,
"properties": {},
"type": "object"
}List networks list_networks
Every network: connected or not, the account, keys set, days until the sign-in ends, and limits.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {},
"name": "list_networks"
}
}{
"additionalProperties": false,
"properties": {},
"type": "object"
}List the queue list_posts
Every post with, per network, approval (no · ok · changed), status (waiting · due · posting · posted · failed), and the live url or the error.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {},
"name": "list_posts"
}
}{
"additionalProperties": false,
"properties": {},
"type": "object"
}Get a post get_post
One post with its per-network approval, status, url and error.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2"
},
"name": "get_post"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
}Preview a post for one network preview_post
Exactly what would be sent to that network: title, caption, its length as the network counts it, settings, whether it fits the limits, and the approval state.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2",
"network": "x"
},
"name": "preview_post"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
},
"network": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
}
},
"required": [
"id",
"network"
],
"type": "object"
}List media list_media
Videos and covers uploaded to the server (names to use in create_post).
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {},
"name": "list_media"
}
}{
"additionalProperties": false,
"properties": {},
"type": "object"
}Recent activity recent_activity
The latest log lines: who approved what, what was posted, errors.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {},
"name": "recent_activity"
}
}{
"additionalProperties": false,
"properties": {
"lines": {
"default": 50,
"maximum": 500,
"minimum": 1,
"type": "integer"
}
},
"type": "object"
}Upload a video or cover upload_media
Upload a file (base64) of up to 25 MB. Larger files: use the REST upload (POST /v1/media). The name can't already exist.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"data_base64": "iVBORw0KGgo…",
"name": "edge-part2-cover.png"
},
"name": "upload_media"
}
}{
"additionalProperties": false,
"properties": {
"data_base64": {
"contentEncoding": "base64",
"type": "string"
},
"name": {
"description": "file name ending in .mp4 .mov .png .jpg or .jpeg",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$",
"type": "string"
}
},
"required": [
"name",
"data_base64"
],
"type": "object"
}Create a post create_post
Queue a video. It goes nowhere until a person approves each network.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"at": "2026-11-20T09:30:00+05:30",
"caption": "Why edge systems keep working when the cloud doesn't.",
"id": "edge-part2",
"targets": [
"linkedin",
"x"
],
"title": "Why edge systems",
"video": "edge-part2.mp4"
},
"name": "create_post"
}
}{
"additionalProperties": false,
"properties": {
"at": {
"description": "RFC 3339 with offset, e.g. 2026-11-20T09:30:00+05:30",
"format": "date-time",
"type": "string"
},
"caption": {
"description": "default text for every network",
"type": "string"
},
"captions": {
"additionalProperties": {
"type": "string"
},
"description": "per-network text, keyed by network id",
"type": "object"
},
"cover": {
"description": "name from list_media",
"type": "string"
},
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
},
"settings": {
"additionalProperties": {
"type": "object"
},
"description": "per-network options, e.g. linkedin_page.page / audience, pinterest.title / link / alt_text",
"type": "object"
},
"targets": {
"items": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
},
"minItems": 1,
"type": "array"
},
"title": {
"type": "string"
},
"video": {
"description": "name from list_media",
"type": "string"
}
},
"required": [
"id",
"at",
"title",
"video",
"targets",
"caption"
],
"type": "object"
}Delete a post delete_post
Delete a post created through the API, only while nothing has been posted.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2"
},
"name": "delete_post"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
}Approve a post for a network approve_post
Approve exactly the current content for one network. Only when the user has clearly approved it.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2",
"network": "x"
},
"name": "approve_post"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
},
"network": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
}
},
"required": [
"id",
"network"
],
"type": "object"
}Withdraw approval unapprove_post
Stop a post on one network before it goes out.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2",
"network": "x"
},
"name": "unapprove_post"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
},
"network": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
}
},
"required": [
"id",
"network"
],
"type": "object"
}Publish now publish_now
Publish an approved post to one network right away instead of at its time. Public and can't be undone from here: only on the user's explicit go-ahead.
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"id": "edge-part2",
"network": "x"
},
"name": "publish_now"
}
}{
"additionalProperties": false,
"properties": {
"id": {
"description": "post id",
"pattern": "^[a-z0-9-]{1,64}$",
"type": "string"
},
"network": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
}
},
"required": [
"id",
"network"
],
"type": "object"
}Start a network sign-in start_network_signin
Returns the URL a person opens to connect a network (the network's own consent screen).
{
"id": 1,
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"arguments": {
"network": "x"
},
"name": "start_network_signin"
}
}{
"additionalProperties": false,
"properties": {
"network": {
"description": "network id",
"enum": [
"linkedin",
"linkedin_page",
"facebook",
"instagram",
"threads",
"bluesky",
"mastodon",
"pinterest",
"x",
"youtube",
"tiktok"
],
"type": "string"
}
},
"required": [
"network"
],
"type": "object"
}Resources and prompts#
| Resource | What |
|---|---|
pulse-publish://guide | The workflow and the approval rule, written for an AI client |
pulse-publish://posts · …/posts/{id} · …/posts/{id}/{network} | The queue, one post, one network's preview |
pulse-publish://networks · pulse-publish://media | Connected accounts and limits · uploaded files |
| Prompt | What |
|---|---|
draft_captions | One caption per network for a topic, inside each network's limits |
review_queue | What's waiting for approval, what failed and why, what goes out next |
queue_video | Upload, create and preview a post, then stop for your approval |
Protocol details#
- 2026-07-28 clients send
_meta["io.modelcontextprotocol/protocolVersion"]andclientCapabilitieson every request, plus theMCP-Protocol-Version,Mcp-Methodand (fortools/call,resources/read,prompts/get)Mcp-Nameheaders. A mismatch answers 400 with-32020.server/discoverdescribes the server. - Older clients call
initialize; the server answers with the version they asked for when it supports it, otherwise2025-11-25. Notifications get 202. No session id is issued. - Unsupported versions: 400 with
-32022and the supported list. Unknown methods:-32601. One JSON-RPC message per POST; GET and DELETE answer 405. - 401s carry
WWW-Authenticatepointing to the protected-resource metadata (RFC 9728). Browser origins must be registered; others get 403.
# a raw 2026-07-28 call, for testing
curl "$PP/mcp" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: list_posts" \
-d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_posts", "arguments": {},
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {} } } }'Every call.
17 calls. Examples assume PP, TOKEN and H from the set-up step. Network ids: linkedin, linkedin_page, facebook, instagram, threads, bluesky, mastodon, pinterest, x, youtube, tiktok.
/v1/healthpublicHealth#
Public, no token. Use it for uptime checks.
curl "$PP/v1/health" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/health", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/health", headers=H)
print(r.status_code, r.json()){ "ok": true, "service": "pulse-publish", "version": "0.1.0", "config_error": false }/v1/mereadWho am I#
Who the token belongs to, whether you have write access, and whether scheduled publishing runs.
curl "$PP/v1/me" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/me", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/me", headers=H)
print(r.status_code, r.json()){ "caller": "you@yourcompany.com", "root": false, "write": true, "owner": "Pulse", "schedule": "on" }/v1/networksreadList networks#
Every network: connected or not, the account, whether its keys are set, the sign-in type and redirect URL, days until the sign-in ends, and its limits.
curl "$PP/v1/networks" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/networks", headers=H)
print(r.status_code, r.json()){ "networks": [ { "id": "linkedin", "name": "LinkedIn", "connected": true, "account": "Sanjay shah",
"keys_set": true, "sign_in": "oauth", "redirect_url": "https://publish.debuginit.com/callback/linkedin",
"expires_in_days": 57, "expired": false, "limits": { "caption_max_chars": 3000, … } }, … ] }/v1/networks/{network}/signinwrite accessStart a sign-in#
Returns the network's consent URL. Open it in a browser; the network sends the browser back to /callback/{network}, which shows "Connected".
curl -X POST "$PP/v1/networks/linkedin/signin" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks/linkedin/signin", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/networks/linkedin/signin", headers=H)
print(r.status_code, r.json()){ "open": "https://www.linkedin.com/oauth/v2/authorization?…", "redirect_url": "https://publish.debuginit.com/callback/linkedin" }/v1/networks/{network}/connectwrite accessConnect without a browser#
For Bluesky and Mastodon accounts set up for your access: they sign in with a handle and app password, no browser step.
curl -X POST "$PP/v1/networks/bluesky/connect" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks/bluesky/connect", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/networks/bluesky/connect", headers=H)
print(r.status_code, r.json()){ "connected": "bluesky", "account": "debuginit.bsky.social" }/v1/networks/{network}write accessForget a sign-in#
Removes the stored sign-in for one network; posting to it stops until it is connected again.
curl -X DELETE "$PP/v1/networks/x" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/networks/x", { method: "DELETE", headers: H });
console.log(res.status, await res.json());r = requests.delete(f"{PP}/v1/networks/x", headers=H)
print(r.status_code, r.json()){ "disconnected": "x" }/v1/mediareadList media#
Videos and covers uploaded to this server.
curl "$PP/v1/media" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/media", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/media", headers=H)
print(r.status_code, r.json()){ "media": [ { "name": "edge-part2.mp4", "bytes": 22295769 } ] }/v1/media?name={file}write accessUpload a video or cover#
The raw file is the body. Names use letters, digits and . - _ and end in .mp4, .mov, .png, .jpg or .jpeg. A name can't be reused (409); files can be up to 500 MB (413).
curl -X POST "$PP/v1/media?name=edge-part2.mp4" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @edge-part2.mp4
curl -X POST "$PP/v1/media?name=edge-part2-cover.png" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @edge-part2-cover.pngimport { readFile } from "node:fs/promises";
for (const name of ["edge-part2.mp4", "edge-part2-cover.png"]) {
const res = await fetch(PP + "/v1/media?name=" + name, { method: "POST", headers: H, body: await readFile(name) });
console.log(res.status, await res.json()); // 201 { name, bytes }
}for name in ["edge-part2.mp4", "edge-part2-cover.png"]:
with open(name, "rb") as f:
r = requests.post(f"{PP}/v1/media", params={"name": name}, headers=H, data=f)
print(r.status_code, r.json()) # 201 {'name': ..., 'bytes': ...}{ "name": "edge-part2.mp4", "bytes": 22295769 }/v1/postsreadList the queue#
Every post with, per network, its approval (no · ok · changed), status (waiting · due · posting · posted · failed), and the live url or the error.
curl "$PP/v1/posts" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/posts", headers=H)
print(r.status_code, r.json()){ "posts": [ { "id": "edge-part2", "at": "2026-11-20T04:00:00Z", "title": "Why edge systems", "source": "api",
"video": "edge-part2.mp4", "cover": "edge-part2-cover.png",
"targets": [ { "network": "x", "approval": "ok", "status": "posted", "url": "https://x.com/…/status/…",
"approved_at": "…", "posted_at": "…", "error": null } ] } ] }/v1/postswrite accessCreate a post#
Fields: id (a–z, 0–9 and -, unique) · at (RFC 3339 time with its offset) · title · video and cover (names from /v1/media) · targets (network ids) · caption (the default text) · captions (per-network text) · settings (per-network options, see the guides).
curl -X POST "$PP/v1/posts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "edge-part2",
"at": "2026-11-20T09:30:00+05:30",
"title": "Why edge systems",
"video": "edge-part2.mp4",
"cover": "edge-part2-cover.png",
"targets": ["linkedin", "x", "pinterest"],
"caption": "Part 2 · Why edge systems keep working when the cloud doesn't.",
"captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" },
"settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } }
}'const res = await fetch(PP + "/v1/posts", {
method: "POST",
headers: { ...H, "Content-Type": "application/json" },
body: JSON.stringify({
"id": "edge-part2",
"at": "2026-11-20T09:30:00+05:30",
"title": "Why edge systems",
"video": "edge-part2.mp4",
"cover": "edge-part2-cover.png",
"targets": ["linkedin", "x", "pinterest"],
"caption": "Part 2 · Why edge systems keep working when the cloud doesn't.",
"captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" },
"settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } }
}),
});
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts", headers=H, json={
"id": "edge-part2",
"at": "2026-11-20T09:30:00+05:30",
"title": "Why edge systems",
"video": "edge-part2.mp4",
"cover": "edge-part2-cover.png",
"targets": ["linkedin", "x", "pinterest"],
"caption": "Part 2 · Why edge systems keep working when the cloud doesn't.",
"captions": { "x": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing" },
"settings": { "pinterest": { "title": "Why Edge Systems Matter (Part 2 of 3)", "link": "https://wayworks.space" } }
})
print(r.status_code, r.json())201 — the new post, in the same shape as GET /v1/posts/{id}/v1/posts/{id}readGet one post#
One post, same shape as in the list. Poll this after publishing.
curl "$PP/v1/posts/edge-part2" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/posts/edge-part2", headers=H)
print(r.status_code, r.json()){ "id": "edge-part2", "targets": [ … ] }/v1/posts/{id}write accessDelete a post#
Only posts created through the API, and only until something has been posted.
curl -X DELETE "$PP/v1/posts/edge-part2" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2", { method: "DELETE", headers: H });
console.log(res.status, await res.json());r = requests.delete(f"{PP}/v1/posts/edge-part2", headers=H)
print(r.status_code, r.json()){ "deleted": "edge-part2" }/v1/posts/{id}/{network}readPreview for one network#
Exactly what would be sent: title, caption, its length as that network counts it, settings, the limits check, and the fingerprint an approval would cover.
curl "$PP/v1/posts/edge-part2/x" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/posts/edge-part2/x", headers=H)
print(r.status_code, r.json()){ "network": "x", "title": "Why edge systems", "caption": "Part 2 · Why edge systems. Part 1: https://lnkd.in/… #EdgeComputing",
"caption_length": 74, "caption_max": 280, "fits": true, "settings": {}, "fingerprint": "9f2c…" }/v1/posts/{id}/{network}/approvewrite accessApprove#
Approves exactly the current content for one network. If anything changes later, approval becomes "changed" and nothing goes out.
curl -X POST "$PP/v1/posts/edge-part2/x/approve" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x/approve", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts/edge-part2/x/approve", headers=H)
print(r.status_code, r.json()){ "id": "edge-part2", "targets": [ { "network": "x", "approval": "ok", "status": "waiting" } ] }/v1/posts/{id}/{network}/unapprovewrite accessWithdraw approval#
Stops a post that hasn't gone out yet.
curl -X POST "$PP/v1/posts/edge-part2/x/unapprove" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x/unapprove", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts/edge-part2/x/unapprove", headers=H)
print(r.status_code, r.json()){ "id": "edge-part2", "targets": [ { "network": "x", "approval": "no" } ] }/v1/posts/{id}/{network}/publishwrite accessPublish now#
Publishes now instead of at its time. It must be approved. Answers 202 at once; follow the result with GET /v1/posts/{id}.
curl -X POST "$PP/v1/posts/edge-part2/x/publish" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/posts/edge-part2/x/publish", { method: "POST", headers: H });
console.log(res.status, await res.json());r = requests.post(f"{PP}/v1/posts/edge-part2/x/publish", headers=H)
print(r.status_code, r.json())202 { "publishing": "edge-part2/x", "check": "/v1/posts/edge-part2" }/v1/activity?lines=50readActivity log#
The latest log lines: who approved what, what was posted, errors. Up to 500 lines.
curl "$PP/v1/activity?lines=50" \
-H "Authorization: Bearer $TOKEN"const res = await fetch(PP + "/v1/activity?lines=50", { headers: H });
console.log(await res.json());r = requests.get(f"{PP}/v1/activity?lines=50", headers=H)
print(r.status_code, r.json()){ "lines": [ "2026-11-20 09:30:02 x: posted edge-part2 → https://x.com/…", … ] }Errors and status codes.
Errors are JSON with one plain sentence; when a network refused something, its own message is included.
409 { "error": "edge-part2/x changed since it was approved — approve again" }
502 { "error": "x: HTTP 403 Forbidden: {\"detail\":\"You are not permitted to perform this action.\"}" }| Code | Meaning | What to do |
|---|---|---|
| 200 · 201 · 202 | Done · created · started | For 202, poll GET /v1/posts/{id} |
| 400 | Something in the request is wrong | The message names the field |
| 401 | No token, or it expired or was revoked | Get a fresh DebugInit ID token |
| 403 | Your access is read-only, or your DebugInit ID isn't enabled | Request access |
| 404 | No such post, network or file | Check the id |
| 409 | Not possible now: not approved, changed since approval, already posted, name taken, network not enabled | Approve again, rename, or ask us |
| 413 | Upload over 500 MB | Compress the file |
| 502 | DebugInit ID couldn't be reached | Retry with backoff |
Network limits.
The limits the API checks before anything is sent. The preview call checks them for you.
| Id | Network | Caption | Title | Video | Notes |
|---|---|---|---|---|---|
linkedin | 3000 | 200 | 30 min | custom cover | |
linkedin_page | LinkedIn Page | 3000 | 200 | 30 min | custom cover |
facebook | Facebook Page | 63206 | 255 | 90 s | custom cover |
instagram | 2200 | — | 15 min | custom cover | |
threads | Threads | 500 | — | 5 min | public video URL |
bluesky | Bluesky | 300 | — | 3 min | — |
mastodon | Mastodon | 500 | — | — | custom cover, links count as 23 |
pinterest | 800 | 100 | 15 min | custom cover | |
x | X | 280 | — | 20 min | links count as 23, emoji count as 2 |
youtube | YouTube Shorts | 5000 | 100 | 3 min | custom cover |
tiktok | TikTok | 2200 | — | 10 min | — |
Integration checklist.
What a solid integration handles. Ticks are remembered in this browser.