Skip to content

Campaigns

The post calendar and content-performance surface of dfl-campaigns — Bloco 3 of the revenue-engine-inbound-v1 plan. An agent writes a post into the queue. A person clears it. The agent can then send the post and analyze its results.

Endpoint https://campaigns.mcp.devfellowship.com/mcp
Tools 34 in 6 groups
Package packages/dfl-mcp-campaigns
Auth Your dfl-auth login token as Authorization: Bearer <token>. Every call runs as you, under RLS. See Auth & security.
.mcp.json
{
"mcpServers": {
"dfl-campaigns": {
"type": "http",
"url": "https://campaigns.mcp.devfellowship.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_ACCESS_TOKEN"
}
}
}
}

Other clients (Cursor, VS Code, codex, the Anthropic SDK): see Getting started, step 3.

Backing dataCalendar writes use the dfl-campaigns Hono API. Analytics uses SECURITY INVOKER RPCs and bounded history reads with the caller’s JWT and RLS. The social account mapping reads and writes campaigns.social_accounts with the caller’s JWT and RLS.

Read the Zernio profiles and connected accounts, the business units the calendar schedules for, and a business unit’s brand voice. Read these before you draft.

See who publishes where, which social accounts are connected, and how a brand should sound before you write a post.

list_zernio_profiles

Who publishes where: each Zernio profile (a person or the company) with its connected accounts per network, plus the accounts that belong to no profile.

List Zernio profiles and their accounts

Who publishes where: each Zernio profile (a person or the company) with its connected accounts per network, plus the accounts that belong to no profile. Call this FIRST when drafting a post: draft_post_for_review requires zernio_profile_id — the profile of the person or brand the post goes out as ("Criador" in the dfl-campaigns UI). A post is NOT limited to that profile's own accounts — the app allows channels across profiles (e.g. the brand DevFellowship plus a team member's personal accounts); the profile just names who the post is mainly for.

Takes no parameters.

list_zernio_accounts

The social accounts connected in Zernio, each with the id a channel needs to actually publish.

List connected Zernio accounts

The social accounts connected in Zernio, each with the id a channel needs to actually publish. Call this BEFORE draft_post_for_review whenever the post has channels: Zernio publishes per ACCOUNT, and there is more than one account on the same platform (a company profile and a personal one), so "instagram" alone does not say where the post goes out. A channel drafted without its zernio_account_id is dropped at dispatch, and the review queue cannot add the account afterwards — that post has to be redone.

Takes no parameters.

list_post_business_units

The business units the post calendar can schedule for — strategy.business_units, archived ones excluded.

List post calendar business units

The business units the post calendar can schedule for — strategy.business_units, archived ones excluded. Call this FIRST: every other tool takes a business_unit_id (a uuid), never a name like "itera".

Takes no parameters.

get_business_unit_voice

READ the brand VOICE of a business unit before you write any post copy — the writing patterns that say how that BU sounds.

Get business unit voice

READ the brand VOICE of a business unit before you write any post copy — the writing patterns that say how that BU sounds. Source is strategy.writing_patterns, the single canonical store, read through the strategy MCP tool list_writing_patterns with your own JWT. Call it after list_post_business_units and BEFORE draft_post_for_review: a Reel written without it is written in a voice you guessed. Each row is one "slot" (e.g. "book", "youtube"); the free-form pattern jsonb carries description, toneAxes, vocabularyDo / vocabularyAvoid and examplePairs. Optionally narrow to one slot. This tool is READ-ONLY: authoring a voice happens in BM Canvas or on the strategy MCP.

ParameterTypeRequiredDescription
business_unit_idstringyesThe BU whose voice to read (from list_post_business_units)
slotstringnoNarrow to a single voice slot, e.g. "book", "youtube", "pedagogia_aula".

The Curadoria board of content ideas (“pautas”). A person turns a topic into one or more posts.

The ideas board for posts: read what people said this week, and add, edit, move or archive topic suggestions.

list_core_daily_topics

What each person said in the last N days, merged per person from the Discord channel #core-daily-updates (primary) and the Core Daily meeting transcripts (when there was one).

List Core Daily topics per person

What each person said in the last N days, merged per person from the Discord channel #core-daily-updates (primary) and the Core Daily meeting transcripts (when there was one). Each line has source ("channel" with a Discord url, or "meeting"); lines under 8 words and repeats are removed, at most 30 per person, newest first. sources says which side answered. Read-only. Use it to propose one draft per person: each line is raw speech, so rewrite it into a hook before calling draft_post_for_review, and pass assignee_id = user_id when it is present (user_id is only set on an exact name match with a member).

ParameterTypeRequiredDescription
daysnumbernoWindow in days, 1 to 30. Default: 7.
speakerstringnoOnly this speaker (case and accents ignored), as shown in speaker.

list_suggested_topics

Read the suggested topics on the Curadoria board of /posts/pautas in dfl-campaigns.

List suggested topics (Curadoria board)

Read the suggested topics on the Curadoria board of /posts/pautas in dfl-campaigns. A suggested topic (a curated content topic, "pauta" in Portuguese) is an idea for content that a person or an agent put on the board and that later turns into one or many posts. Each topic has a title, briefing, status (idea, todo, awaiting_content, in_production, awaiting_approval, posted, standby), optional due_date, formats, channels, reference_links, business_unit_id, assignee_id and a url to its card. Active topics are returned by default; pass archived=true for the archived ones. Filters are applied after the read. Read-only. Call it before create_suggested_topic so you do not add a duplicate. If the answer says the board is not live on this deployment, do not retry.

ParameterTypeRequiredDescription
statusenumnoOnly topics in this column. One of: idea, todo, awaiting_content, in_production, awaiting_approval, posted, standby.
assignee_idstringnoOnly topics assigned to this user.
business_unit_idstringnoOnly topics of this BU (from list_post_business_units)
archivedbooleannotrue reads the archived topics instead of the active ones. Default: false.

create_suggested_topic

Add one suggested topic to the Curadoria board of /posts/pautas in dfl-campaigns.

Add a suggested topic to the Curadoria board

Add one suggested topic to the Curadoria board of /posts/pautas in dfl-campaigns. A suggested topic (a curated content topic, "pauta" in Portuguese) is an idea for content that a person or an agent proposes and that later turns into one or many posts. Creating one posts NOTHING and drafts no post: it only puts a card on the board, marked as created by an agent (origin "mcp"), for a person to pick up. To draft a post use draft_post_for_review. The status defaults to idea. Run list_suggested_topics first to avoid duplicates. The answer carries the card url. A refusal (not available on this deployment, a rule, a missing BU or user) says do not retry; only a temporary failure may be retried.

ParameterTypeRequiredDescription
titlestringyesShort name of the topic, as it shows on the card.
briefingstringnoWhat the content is about and the angle (up to 5000 characters)
statusenumnoBoard column; omitted means idea. One of: idea, todo, awaiting_content, in_production, awaiting_approval, posted, standby.
due_datestringnoDay the content is wanted, YYYY-MM-DD.
formatsenum[]noContent formats.
channelsenum[]noNetworks the content is for.
reference_linksstring[]nohttp(s) links that inspired or back the topic.
business_unit_idstringnoWhich BU the topic is for (from list_post_business_units)
assignee_idstringnoThe user expected to produce it.

update_suggested_topic

Edit the content of one suggested topic ("pauta") on the Curadoria board of /posts/pautas in dfl-campaigns: title, briefing, due date, formats, channels, reference links, BU or assignee.

Edit a suggested topic on the Curadoria board

Edit the content of one suggested topic ("pauta") on the Curadoria board of /posts/pautas in dfl-campaigns: title, briefing, due date, formats, channels, reference links, BU or assignee. Any member can edit any topic. Send only the fields to change; an omitted field keeps its value, and null clears briefing, due_date, business_unit_id or assignee_id. Arrays (formats, channels, reference_links) replace the whole list. It does NOT change the column or the order: use move_suggested_topic for that, and archive_suggested_topic to archive. It refuses an empty change, an unknown field and an unknown id. Get the id from list_suggested_topics. A refusal says do not retry; only a temporary failure may be retried.

ParameterTypeRequiredDescription
idstringyesThe suggested topic id (from list_suggested_topics)
titlestringnoNew title.
briefingstringnoNew briefing; null clears it.
due_datestringnoDay the content is wanted, YYYY-MM-DD; null clears it.
formatsenum[]noReplaces the formats.
channelsenum[]noReplaces the channels.
reference_linksstring[]noReplaces the http(s) reference links.
business_unit_idstringnoBU from list_post_business_units; null clears it.
assignee_idstringnoThe user expected to produce it; null clears it.

move_suggested_topic

Move one suggested topic ("pauta") to a column of the Curadoria board of /posts/pautas in dfl-campaigns and, optionally, to a place inside it.

Move a suggested topic to a column or position

Move one suggested topic ("pauta") to a column of the Curadoria board of /posts/pautas in dfl-campaigns and, optionally, to a place inside it. status is the target column (it may be the current one, to only reorder). Without before_id/after_id the card goes to the end of the column; with before_id it lands right above that card, with after_id right below it (send one, never both). The neighbour must be an active topic already in the target column. Any member can move any topic. It does not edit content (update_suggested_topic) and does not touch posts. A refusal says do not retry; only a temporary failure may be retried.

ParameterTypeRequiredDescription
idstringyesThe suggested topic to move (from list_suggested_topics)
statusenumyesTarget column. One of: idea, todo, awaiting_content, in_production, awaiting_approval, posted, standby.
before_idstringnoPlace the card right above this one.
after_idstringnoPlace the card right below this one.

archive_suggested_topic

Archive one suggested topic ("pauta") of the Curadoria board of /posts/pautas in dfl-campaigns, or bring an archived one back with restore=true.

Archive or restore a suggested topic

Archive one suggested topic ("pauta") of the Curadoria board of /posts/pautas in dfl-campaigns, or bring an archived one back with restore=true. Archiving hides the card from the board but keeps it, and it is reversible: this is the safe way to take a topic off the board. Any member can archive or restore any topic. To remove one for good use delete_suggested_topic. Archived topics are read with list_suggested_topics archived=true. A refusal (unknown id, not available on this deployment) says do not retry; only a temporary failure may be retried.

ParameterTypeRequiredDescription
idstringyesThe suggested topic id (from list_suggested_topics)
restorebooleannotrue brings an archived topic back to the board; default archives. Default: false.

delete_suggested_topic

Permanently delete one suggested topic ("pauta") from the Curadoria board of /posts/pautas in dfl-campaigns.

Permanently delete a suggested topic you created

Permanently delete one suggested topic ("pauta") from the Curadoria board of /posts/pautas in dfl-campaigns. It cannot be undone. Only the creator of the topic or an admin may delete it: the server decides that, from the caller session, and refuses everybody else with "do not retry" (ask the creator or an admin instead). To take a topic off the board without losing it, use archive_suggested_topic. Pass confirm_title equal to the topic's current title, exactly as list_suggested_topics shows it; a different title, or an id that is not on the board, deletes nothing. Posts already drafted from the topic are not deleted.

ParameterTypeRequiredDescription
idstringyesThe suggested topic id (from list_suggested_topics)
confirm_titlestringyesThe topic's current title, copied exactly: the guard against deleting the wrong card.

Draft, revise, approve, schedule, tag and remove posts. An agent writes a post into the review queue; a person clears it.

The posting calendar: see what runs this week or on any date range, write a post, and send it through human review to publishing.

list_posts

Read the post calendar, ordered by scheduled_for.

List calendar posts

Read the post calendar, ordered by scheduled_for. Without business_unit_id it is the consolidated view across every BU. Pass status: "awaiting_review" to read the human review queue — everything the AI wrote that is still waiting on a person. assignee_id and zernio_profile_id narrow it to one person or one profile; while those columns do not exist yet the filter is skipped and notes says so. Each post carries assignment; pass include_metrics: true to attach latest_metrics per platform and account. Each post carries archetype and media_group (its content-format tag; null when unset).

ParameterTypeRequiredDescription
business_unit_idstringnoFilter to one BU (from list_post_business_units)
statusenumnoFilter by lifecycle state. One of: draft, awaiting_review, approved, rejected, dispatched.
scheduled_fromstringnoOnly posts scheduled at/after this ISO date-time.
scheduled_tostringnoOnly posts scheduled at/before this ISO date-time.
assignee_idstringnoOnly posts assigned to this user id.
zernio_profile_idstringnoOnly posts linked to this Zernio profile.
include_metricsbooleannoAttach latest_metrics per platform and account to each post (never summed across platforms) Default: false.
limitnumbernoDefault: 50.

get_post

One post with its channels, its lifecycle state and its approval trail — who approved it and when, and whether it was already dispatched to Zernio.

Get one calendar post

One post with its channels, its lifecycle state and its approval trail — who approved it and when, and whether it was already dispatched to Zernio. Also returns assignment (zernio_profile_id, assignee_id; null while those columns do not exist) and latest_metrics: the latest views/likes/comments per platform AND Zernio account, never summed across platforms. The post always carries archetype and media_group (its content-format tag; null when unset or while the columns do not exist). live is { published_at, published_url } once dfl-campaigns read the post LIVE in Zernio (status published): the link to the post on the network. null while it is not known live. Use it to answer "how did this post do" and "where is it".

ParameterTypeRequiredDescription
post_idstringyes—

draft_post_for_review

Write one post into the calendar.

Draft a post into the human review queue

Write one post into the calendar. It lands in awaiting_review and NOTHING leaves this server until a person opens the queue in dfl-campaigns, reads it and approves it — you cannot clear it yourself; approve_post only records a decision a person already gave you. Schedule the time you want it to go out; a channel only reaches its network if it carries the connected zernio_account_id. An Instagram Reel cover is optional: pass cover_media_id (the COVER_MEDIA_ID from the thumbify-reel-cover skill) or cover_url (a Thumbify render URL), or Instagram uses frame 0 of the video. A cover that is not a Thumbify cover is refused at approval. The Lesson Studio cover slide is NOT carried over. A carousel (Instagram, or a TikTok photo carousel) is either media_ids (existing public.media ids) or media_urls (rendered images, e.g. from render_composition_images; dfl-campaigns registers them as your media). A single image is media_id; TikTok accepts it as a photo post (JPEG, PNG or WebP, up to 20 MB each). Pass media_group + archetype (the content-format tag): a post without an archetype is "untagged" in the weekly summary, and the result carries content_tag_warning.

ParameterTypeRequiredDescription
business_unit_idstringyesWhich BU publishes it (from list_post_business_units)
titlestringyesInternal title — how the post shows up in the calendar.
bodystringyesThe text that goes out to the network.
scheduled_forstringyesISO date-time the post should be published at.
youtube_visibilityenumnoHow the post lands on YouTube and TikTok. Omitted means private — the closed default, on purpose: a post that goes out more public than intended cannot be taken back, while a private one is one click away from being opened. On TikTok (video or photo) it sets the privacy level: private = only the creator, unlisted = followers only, public = everyone — so the default private means a TikTok post only the creator can see. Only matters when a channel is youtube or tiktok. One of: private, unlisted, public.
link_urlstringnoINTERNAL note only — this URL is NOT published. The dispatch sends title, body and media to Zernio and nothing else, so a link (and any utm_ it carries) reaches the network ONLY if it is written inside body. The dfl-campaigns form stopped offering this field for that reason; it is kept here for rows written through the API. Put the link in body.
media_idstringnopublic.media id — never a raw bucket URL; media is served by media.devfellowship.com/:id.
media_idsstring[]noCarousel (Instagram, or TikTok photo carousel): ordered public.media ids of 2 to 10 images; use instead of media_id. Never a single image: one image is media_id.
media_urlsstring[]noCarousel (Instagram, or TikTok photo carousel) from rendered images: 2 to 10 public devfellowship S3 media/ PNG/JPG URLs (e.g. render_composition_images output) in order; use instead of media_ids. Never together with media_id or media_ids.
cover_media_idstringnoReel cover: another public.media id (JPEG or PNG, 1080x1920) holding a Thumbify cover (reel-cover-v<N>-…) — any other file is refused at approval. Not the video. Omitted means Instagram uses frame 0. Dispatch sends this as instagramThumbnail.
cover_urlstringnoReel cover as a URL, in place of cover_media_id: a public JPEG/PNG of the devfellowship S3 bucket under media/ — typically a Thumbify render output_url (media/renders/<template>/<ts>.png). dfl-campaigns registers it as public.media (owned by you) and stores that id. Other hosts are refused. Never both.
instagram_collaboratorsstring[]noInstagram co-authors: up to 3 usernames, without the @. A co-authored post appears in the feed AND the grid of BOTH accounts, with both handles in the header and the reach added together — it is what makes a post published by a personal profile also work for the company account, without reposting. Naming the @ in the caption does NOT do this; that is only text. ⚠️ Tagging is not publishing: the tagged account gets an INVITE and has to accept it in the Instagram app, and until it does the post does not appear there. Neither Zernio nor Meta can accept for us. Dispatch sends these as collaborators, so they must be on the post BEFORE dispatch — they cannot be added to a post already sent.
channelsobject[]noNetworks this post goes out through. The same content on several networks or accounts — even across profiles and business units (e.g. TikTok of a person plus YouTube Shorts of the brand) — is ONE call with several entries here, never one call per channel: each extra post is one more approval for the reviewer. Default: [].
zernio_profile_idstringyesZernio profile of the person or brand this post goes out as ("Criador" in the dfl-campaigns UI). Required on every post — get it from list_zernio_profiles.
assignee_idstringnoWho is expected to produce the content ("Solicitante" in the UI). null unassigns it.
keyword_idsstring[]noSEO keywords (strategy.keywords ids, from list_keywords on the strategy MCP) this post was written for, same business unit as the post. The first one is the primary keyword: the post's views count for it. Replaces the whole set; an empty array unlinks all.
archetypestringnoContent-format archetype of the post, as a lowercase slug (e.g. "talking-head-hook"), max 64 characters. Requires media_group. null clears it.
media_groupenumnoMedia group of the post: vertical-short | long-video | micro-clips | ephemeral-daily | swipe-static. Required when archetype is set. null clears it (only when the post has no archetype). One of: vertical-short, long-video, micro-clips, ephemeral-daily, swipe-static.

revise_post_in_review

Rewrite a post that is still in the queue — typically after a reviewer left review_notes, or when a rejected post is being redone.

Revise a post that has not been approved yet

Rewrite a post that is still in the queue — typically after a reviewer left review_notes, or when a rejected post is being redone. Only draft, awaiting_review and rejected posts can be revised: once a person has approved it, the text they read is the text that goes out. Status is never changed here.

ParameterTypeRequiredDescription
post_idstringyes—
titlestringno—
bodystringno—
scheduled_forstringnoNew ISO date-time.
link_urlstringnoINTERNAL note only, never published — the dispatch sends title, body and media to Zernio and nothing else. To change the link the network actually sees, edit body. null clears this note.
media_idstringnonull clears the media.
cover_media_idstringnoReel cover (JPEG/PNG public.media) holding a Thumbify cover — any other file is refused at approval. null clears it, and Instagram then uses frame 0. Not the video.
cover_urlstringnoReel cover as a URL, in place of cover_media_id: a public JPEG/PNG of the devfellowship S3 bucket under media/ — typically a Thumbify render output_url (media/renders/<template>/<ts>.png). dfl-campaigns registers it as public.media (owned by you) and stores that id. Other hosts are refused. Never both. Replaces the cover.
youtube_visibilityenumnoNew visibility — private, unlisted or public. Also sets TikTok privacy: private = only the creator, unlisted = followers only, public = everyone. One of: private, unlisted, public.
instagram_collaboratorsstring[]noReplaces the co-author list. An empty array clears it. Instagram co-authors: up to 3 usernames, without the @. A co-authored post appears in the feed AND the grid of BOTH accounts, with both handles in the header and the reach added together — it is what makes a post published by a personal profile also work for the company account, without reposting. Naming the @ in the caption does NOT do this; that is only text. ⚠️ Tagging is not publishing: the tagged account gets an INVITE and has to accept it in the Instagram app, and until it does the post does not appear there. Neither Zernio nor Meta can accept for us. Dispatch sends these as collaborators, so they must be on the post BEFORE dispatch — they cannot be added to a post already sent.
zernio_profile_idstringnoZernio profile of the person or brand this post goes out as ("Criador" in the dfl-campaigns UI), from list_zernio_profiles. null unlinks it.
assignee_idstringnoWho is expected to produce the content ("Solicitante" in the UI). null unassigns it.
archetypestringnoContent-format archetype of the post, as a lowercase slug (e.g. "talking-head-hook"), max 64 characters. Requires media_group. null clears it.
media_groupenumnoMedia group of the post: vertical-short | long-video | micro-clips | ephemeral-daily | swipe-static. Required when archetype is set. null clears it (only when the post has no archetype). One of: vertical-short, long-video, micro-clips, ephemeral-daily, swipe-static.

approve_post

Record that a PERSON cleared this post for publishing, when they said so to you instead of clicking in the dfl-campaigns UI — the case the queue had no answer for (Tainan asked for a Reel over Telegram, 2026-09-11).

Record a human approval taken outside the review queue

Record that a PERSON cleared this post for publishing, when they said so to you instead of clicking in the dfl-campaigns UI — the case the queue had no answer for (Tainan asked for a Reel over Telegram, 2026-09-11). It does not approve on your behalf: it WRITES DOWN a decision a human already took, and approval_reference is where you point at the message they took it in. Never call this off your own judgement and never invent a reference to satisfy the field — no server can tell a made-up pointer from a real one, which is precisely why the queue exists. If nobody told you to publish, leave the post in awaiting_review and say it is waiting on a person. Restricted to super admins; everybody else is refused and has to approve in the UI. Approving IS publishing: the same call sends the post to Zernio, scheduled for its scheduled_for (a time that already passed goes out now), and Zernio publishes it then. If Zernio refuses, this call fails and the post stays approved but NOT scheduled — fix what the error names and retry with dispatch_post. If the error says it cannot tell whether Zernio received the post, do NOT retry: tell the person to check Zernio.

ParameterTypeRequiredDescription
post_idstringyesThe post the person cleared (from list_posts with status: "awaiting_review")
approval_referencestringyesWhere the human said "publish it", in their words or as a locator: "Telegram msg 18508". The only trace of the person on an approval with no click — it must point at something real that someone auditing this post later can go read.
expected_updated_atstringyesThe post's updated_at exactly as you read it (get_post / list_posts) when you showed it to the person. If the post was revised since, the server refuses with 409: read it again and get the person's go-ahead on the new content.
review_notesstringnoWhat they said along with it, if anything — kept on the post as the reviewer note.

dispatch_post

Approving a post already sends it to Zernio — you do not need this after approve_post or after a person approves in the UI.

Retry scheduling an approved post in Zernio

Approving a post already sends it to Zernio — you do not need this after approve_post or after a person approves in the UI. Use it only to RETRY a post that is approved but not scheduled, because Zernio refused it at approval time (the approval error said so). Fix what that error named first, or the retry fails the same way. The server refuses anything that is not approved with a recorded approver, so this can never publish something nobody cleared. A post whose send outcome is unknown stays dispatched without a Zernio id and is refused here on purpose, so it is never published twice — tell the person to check Zernio. Zernio publishes at scheduled_for; a time that already passed goes out now, and the calendar is updated to match.

ParameterTypeRequiredDescription
post_idstringyesThe approved, not yet scheduled post (list_posts with status: "approved")

unschedule_post

Stop a post that Zernio is holding from publishing: the server cancels it in Zernio FIRST and only then rewrites the row, which comes back as awaiting_review with the approval trail cleared.

Take a scheduled post back out of Zernio

Stop a post that Zernio is holding from publishing: the server cancels it in Zernio FIRST and only then rewrites the row, which comes back as awaiting_review with the approval trail cleared. It is NOT a rejection and NOT a shortcut around the queue — it only moves a post BACKWARDS, into human review, and someone has to approve it again for it to be scheduled at all. Judging the post is still not something this server does. A new scheduled_for is required because it is what the post holds while it waits (and it has to be in the future). This only works while Zernio still HOLDS the post: once it published — which includes the last few minutes before scheduled_for, when Zernio may already be sending — nothing here takes it back, and the call is refused saying so rather than reporting a cancel that did not happen. Deleting what is already on the network is done in the network itself, by a person.

ParameterTypeRequiredDescription
post_idstringyesThe scheduled post (from list_posts with status: "dispatched")
scheduled_forstringyesNew ISO date-time the post waits for in the queue. Must be in the future — the server refuses a date that already passed.
review_notesstringnoWhy it was pulled, kept on the post as the reviewer note for whoever reads it next.

reject_post

Move a post that YOU created from awaiting_review to rejected — for example a smoke or test post, or a post the person told you to drop.

Withdraw one of your own posts from the review queue

Move a post that YOU created from awaiting_review to rejected — for example a smoke or test post, or a post the person told you to drop. It refuses any post created by somebody else: rejecting another person's post is a judgement on content, and a person does that in the dfl-campaigns UI. It also refuses a draft (delete it with delete_post), an approved post, and a scheduled or published post (unschedule_post takes a scheduled post back to the queue). The reason is stored as the post's review_notes. A rejected post never publishes; delete_post then removes it.

ParameterTypeRequiredDescription
post_idstringyesYour post, from list_posts with status: "awaiting_review".
reasonstringyesWhy the post leaves the queue, kept as review_notes: "smoke test post, not for publishing".

delete_post

Permanently delete a post that YOU created, while it is a draft or rejected — for example a smoke or test post.

Delete one of your own draft or rejected posts

Permanently delete a post that YOU created, while it is a draft or rejected — for example a smoke or test post. The post and its channels go; this cannot be undone. It refuses a post created by somebody else, a post in the review queue (reject it with reject_post first), and every approved, scheduled or published post.

ParameterTypeRequiredDescription
post_idstringyesYour draft or rejected post.

archive_post

Hide an approved or PUBLISHED post from dfl-campaigns — the calendar, the profile pages (recent posts, top posts, cadence, counts) and analytics — while KEEPING its metric snapshots in the database.

Archive a published post (admin only) — hide it, keep its metrics

Hide an approved or PUBLISHED post from dfl-campaigns — the calendar, the profile pages (recent posts, top posts, cadence, counts) and analytics — while KEEPING its metric snapshots in the database. Use it for a published post that should not be in the lists, for example a test post that already went live. It is a soft delete that records who archived the post and why; it does not delete anything and it does not touch Zernio or the social network (the post stays live there, or has already expired). There is no un-archive tool. Restricted to global admins (IAM level 80 or higher): everybody else gets 403. It refuses a draft or rejected post (use delete_post), a post in the review queue (reject_post, then delete_post), and a post still scheduled in Zernio (unschedule_post first). Only call it when a person asked for the post to go, and put where they asked in the reason.

ParameterTypeRequiredDescription
post_idstringyesThe approved or published post to hide.
reasonstringyesWhy the post leaves the lists, pointing at the decision: "Test Stories, Tainan TG msg 19998/20007". Stored on the post for whoever audits it later.

set_post_keywords

Replace the SEO keywords linked to a post, in any status — including posts already dispatched, so older content can be mapped to keywords.

Link a post to the SEO keywords it was written for

Replace the SEO keywords linked to a post, in any status — including posts already dispatched, so older content can be mapped to keywords. It never changes the post itself and never touches its approval. The first keyword is the primary one.

ParameterTypeRequiredDescription
post_idstringyes—
keyword_idsstring[]yes—

set_post_content_tag

Write media_group + archetype on ONE post, in any status — published posts included.

Set the content-format tag of a post (any status)

Write media_group + archetype on ONE post, in any status — published posts included. It writes those two fields and nothing else: title, text, media, schedule and approval stay as they are. The creator of a post may tag it; any other post needs a global admin (IAM level >= 80). The pair must match the content-visual taxonomy (e.g. vertical-short + talking-head; archetype null is a media-group-only tag). Pass dry_run: true to see the before/after and the authority check without writing.

ParameterTypeRequiredDescription
post_idstringyes—
media_groupenumyesMedia group: vertical-short | long-video | micro-clips | ephemeral-daily | swipe-static. One of: vertical-short, long-video, micro-clips, ephemeral-daily, swipe-static.
archetypestringyesArchetype slug of that media group, from the dfl-campaigns content taxonomy (e.g. talking-head, framework-carousel, news-carousel, selfie-proof-story). An unknown slug is refused with the list of known ones. null = media group only.
dry_runbooleannotrue = check and show the change, write nothing.

An ordered Instagram Story sequence: one post per video part, approved and sent in index order.

Plan a multi-part Instagram Story, approve it once, and check that the parts went out in order.

draft_story_sequence_for_review

Write an ordered Instagram Story sequence: one post per video part, published in index order a few minutes apart.

Draft an Instagram Story sequence into the human review queue

Write an ordered Instagram Story sequence: one post per video part, published in index order a few minutes apart. Get media_ids from the studio tool split_export_for_stories, and pass them IN THAT ORDER — the order of the list is the order on Instagram. A single Story (one video of 3 to 60 s) is a sequence of 1: pass one media id. Instagram only, exactly one account. Every part lands in awaiting_review and NOTHING is published until a person approves the sequence (in the dfl-campaigns UI, or through approve_story_sequence with the reference of the message where the person said so). Optional user_tags: [{username, x?, y?}], up to 3 (our limit). It tags other Instagram accounts on the Stories. One list for the whole sequence: it is stored on every part and sent to Zernio as platformSpecificData.userTags, on Stories only. A leading "@" is stripped and names are lowercased; x and y (0.0 to 1.0) come together or not at all. Zernio (docs.zernio.com/platforms/instagram): "Images require x/y (0.0 to 1.0); Reels and videos ignore coordinates; Stories take them optionally." The tagged account must be a public Business or Creator account. WHAT INSTAGRAM RENDERS ON A STORY WAS NOT VERIFIED: it may be a plain tag and not the interactive mention sticker. Do not promise the person a clickable mention. The tags are fixed at draft time and are shown on the review page and in get_story_sequence (sequence.user_tags, parts[].user_tags). Until the dfl-schema migration that adds campaigns.posts.story_user_tags is applied, a call with user_tags answers 503 story_sequence_schema_missing and creates nothing; a call without user_tags works as before. Returns the sequence and review_url: send that link to the person who reviews it.

ParameterTypeRequiredDescription
business_unit_idstringyesWhich BU publishes it (from list_post_business_units)
titlestringyesInternal title. Each part shows up in the calendar as "<title> (i/N)"; a single Story keeps "<title>".
bodystringyesThe text that goes with the Stories.
zernio_account_idstringyesThe connected Instagram Zernio account that publishes (from list_zernio_accounts)
media_idsstring[]yespublic.media ids of the Story videos, 1 to 25, in order — as split_export_for_stories returns them. One id = a single Story.
first_atstringnoISO date-time of part 0. Omitted means now + 5 minutes. The next parts follow at gap_minutes intervals.
gap_minutesnumbernoMinutes between two parts, 1 to 5. Omitted means the server default (2).
user_tagsobject[]noInstagram accounts to tag on every Story of the sequence, up to 3: [{username, x?, y?}]. x and y are 0.0 to 1.0, both or neither. Public Business or Creator accounts only. What Instagram shows on a Story was not verified.

get_story_sequence

One Story sequence with every part in index order: status, scheduled_for, updated_at, the approval trail and the Zernio id, and the user tags (sequence.user_tags and parts[].user_tags; absent when the dfl-schema column…

Get one Instagram Story sequence

One Story sequence with every part in index order: status, scheduled_for, updated_at, the approval trail and the Zernio id, and the user tags (sequence.user_tags and parts[].user_tags; absent when the dfl-schema column story_user_tags is not applied yet). Read it before approve_story_sequence, and show the person what they approve.

ParameterTypeRequiredDescription
groupstringyesThe sequence group id (the group that draft_story_sequence_for_review returned)

approve_story_sequence

Record that a PERSON cleared this whole Story sequence for publishing (show them its user_tags first: the tags go to Instagram with the Stories, and what Instagram renders was not verified), when they said so to you…

Record a human approval of a Story sequence taken outside the review queue

Record that a PERSON cleared this whole Story sequence for publishing (show them its user_tags first: the tags go to Instagram with the Stories, and what Instagram renders was not verified), when they said so to you instead of clicking in the dfl-campaigns UI. It does not approve on your behalf: it WRITES DOWN a decision a human already took, and approval_reference is where you point at the message they took it in. Call it ONLY with an explicit human approval and the reference of that message. Never call this off your own judgement and never invent a reference to satisfy the field — no server can tell a made-up pointer from a real one. If nobody told you to publish, leave the sequence in awaiting_review and say it is waiting on a person. Restricted to super admins; everybody else is refused and has to approve in the UI. Approving IS publishing: the same call sends the parts to Zernio as Stories, in index order. If one part fails, the later parts are not sent (the order is kept); the answer says which part and what to do. If it says nobody can tell whether Zernio received a part, do NOT retry: tell the person to check Zernio.

ParameterTypeRequiredDescription
groupstringyesThe sequence group id (the group that draft_story_sequence_for_review returned)
approval_referencestringyesWhere the human said "publish it", in their words or as a locator: "Telegram msg 18508". The only trace of the person on an approval with no click — it must point at something real that someone auditing this sequence later can go read.
expected_updated_atobjectnoMap of every part id to its updated_at, exactly as you read it (get_story_sequence) when you showed the sequence to the person. If a part was revised since, the server refuses with 409: read it again and get the go-ahead on the new content. Omitted means the tool reads the sequence now and uses its current values.
review_notesstringnoWhat they said along with it, if anything — kept as the reviewer note.
first_atstringnoISO date-time of part 1. A future time is honored. A time earlier than now + 30 s moves to now + 30 s. The answer returns first_at: the time asked for, the time sent, and whether it moved. Omitted means the stored time of part 1.
gap_minutesnumbernoMinutes between two parts, 1 to 5. Omitted means the server default (2).

check_story_sequence_order

Read from Zernio when each part of the sequence was published, and compare the order.

Check that the Stories of a sequence went out in order

Read from Zernio when each part of the sequence was published, and compare the order. check.ok is true only when every checked part went out after the part before it. check.violations names the parts out of order; check.incomplete names the parts that are not published yet (check again later). parts[].outcome: ok = live (publishedUrl is the live link), pending = Zernio holds it, not_sent = never sent (post_status says approved or awaiting_review; it is not a refusal), rejected = Zernio refused it or reports it failed, unknown = check Zernio. It sends nothing. It records each part that Zernio reports live as published, with its live URL.

ParameterTypeRequiredDescription
groupstringyesThe sequence group id (the group that draft_story_sequence_for_review returned)

retry_story_sequence

Use it only after approve_story_sequence (or a later retry) stopped at a part that Zernio rejected or refused.

Retry sending the rest of an approved Story sequence

Use it only after approve_story_sequence (or a later retry) stopped at a part that Zernio rejected or refused. It sends the parts that are approved but not scheduled, in index order. It never approves anything: the server refuses a part that no person cleared. action "none" means there was nothing to send. A 409 retry_stopped means the server will not retry — for example a part whose send outcome is unknown. Then tell the person to check Zernio.

ParameterTypeRequiredDescription
groupstringyesThe sequence group id (the group that draft_story_sequence_for_review returned)
gap_minutesnumbernoMinutes between two parts, 1 to 5. Omitted means the server default (2).

Post metrics per account and platform. See Analyze content performance below.

How the posts did: rank an account's posts, see the history of one post, and read 30 days of account numbers.

list_campaign_accounts

List each platform and Zernio account combination that has stored post metric snapshots.

List campaign analytics accounts

List each platform and Zernio account combination that has stored post metric snapshots. Accounts stay separate even when they use the same platform. An RLS-safe database RPC excludes soft-deleted posts before it computes the summaries.

ParameterTypeRequiredDescription
platformenumnoFilter to one social platform. One of: instagram, facebook, linkedin, tiktok, youtube, x, threads, pinterest, reddit, bluesky, telegram, discord, whatsapp, google_business.

rank_account_posts

Rank one Zernio account's campaigns posts.

Rank posts for one account

Rank one Zernio account's campaigns posts. lifetime uses the latest absolute counter. period_gain subtracts the baseline from the latest observation inside the requested period and can be negative. The RLS-safe database RPC computes and bounds the ranking.

ParameterTypeRequiredDescription
account_idstringyesExact Zernio account ID from list_campaign_accounts.
platformenumyesExact platform from list_campaign_accounts. One of: instagram, facebook, linkedin, tiktok, youtube, x, threads, pinterest, reddit, bluesky, telegram, discord, whatsapp, google_business.
metricenumyesOne of: views, likes, comments.
basisenumnoOne of: lifetime, period_gain. Default: "lifetime".
start_datestringnoPeriod start date, required for period_gain.
end_datestringnoPeriod end date, required for period_gain.
limitnumbernoDefault: 10.

get_post_metric_history

Return stored daily absolute counters for one campaigns post.

Get post metric history

Return stored daily absolute counters for one campaigns post. Account and platform targets are required so separate publication targets are never combined. Reads fail explicitly above 10,000 visible snapshots; use a smaller date range.

ParameterTypeRequiredDescription
post_idstringyescampaigns.posts ID.
account_idstringyesExact Zernio account ID.
platformenumyesExact publication platform. One of: instagram, facebook, linkedin, tiktok, youtube, x, threads, pinterest, reddit, bluesky, telegram, discord, whatsapp, google_business.
start_datestringno—
end_datestringno—

get_account_analytics

The last 30 São Paulo days of ONE Zernio account on ONE platform, the same numbers as the Analytics page of dfl-campaigns.

Get 30-day analytics for one account

The last 30 São Paulo days of ONE Zernio account on ONE platform, the same numbers as the Analytics page of dfl-campaigns. daily: for each day, the sum over this account's posts of each post's latest cumulative views on or before that day (a post counts from its first snapshot in the window). top: the 5 posts with the most latest views. median_views: median latest views of posts dispatched with scheduled_for inside the window. Never add two accounts or platforms together; call once per account. truncated=true means the page cap was hit and totals may be low.

ParameterTypeRequiredDescription
account_idstringyesExact Zernio account ID from list_campaign_accounts.
platformenumyesExact platform from list_campaign_accounts. One of: instagram, facebook, linkedin, tiktok, youtube, x, threads, pinterest, reddit, bluesky, telegram, discord, whatsapp, google_business.

Map each Zernio account to a business unit and an owner.

Say which business unit and which person own each social account.

list_social_accounts

List campaigns.social_accounts: each Zernio account with its business unit (name, logo) and its owner (name, avatar).

List Zernio account mappings

List campaigns.social_accounts: each Zernio account with its business unit (name, logo) and its owner (name, avatar). All filters combine with AND. unmapped_only returns the accounts with no business_unit_id. Write a mapping with set_social_account_mapping.

ParameterTypeRequiredDescription
business_unit_idstringnoOnly accounts of this BU.
owner_user_idstringnoOnly accounts owned by this user.
zernio_profile_idstringnoOnly accounts of this Zernio profile.
unmapped_onlybooleannoOnly accounts with no business_unit_id.

set_social_account_mapping

Create or update the campaigns.social_accounts row of one Zernio account (idempotent UPSERT on zernio_account_id).

Set the BU and owner of a Zernio account

Create or update the campaigns.social_accounts row of one Zernio account (idempotent UPSERT on zernio_account_id). It maps the account to a business unit (strategy.business_units) and to the person who owns it (public.profiles). An omitted optional field keeps its stored value; an explicit null clears business_unit_id or owner_user_id. Take zernio_account_id, zernio_profile_id and platform from list_zernio_accounts. The BU must exist: find it with list_canvas_business_units on the strategy MCP, or create a creator BU there with create_canvas_business_unit — this server does not create BUs. Runs with your JWT; RLS allows the write only to global admins.

ParameterTypeRequiredDescription
zernio_account_idstringyesZernio account id (the conflict key)
zernio_profile_idstringyesZernio profile id of the account.
platformstringyesZernio platform string, e.g. instagram, youtube. Stored lowercase.
handlestringnoZernio username. Omit to keep the stored value; null clears it.
display_namestringnoZernio displayName. Omit to keep the stored value; null clears it.
avatar_urlstringnoZernio profilePicture URL. Omit to keep the stored value; null clears it.
business_unit_idstringnostrategy.business_units id. Omit to keep; null clears (account not clickable).
owner_user_idstringnoUser id of the person who owns the account (public.profiles id). Omit to keep; null clears.

Start with list_campaign_accounts. Use its exact account_id in rank_account_posts. Pass its exact account_id and platform. Keep each account and platform separate. Two Instagram accounts are two analysis groups.

These tools require the analytics schema from devfellowship/dfl-schema PR #993. Before that schema is applied, the MCP returns a readiness error. It does not convert a missing function or table into an empty result.

Choose the ranking basis explicitly:

  • lifetime uses the latest stored absolute counter. Use it for the first retrospective report after the historical bootstrap. It does not accept dates.
  • period_gain subtracts the latest snapshot at or before start_date from the latest observation inside the inclusive date range. Both dates are required.

The initial historical bootstrap cannot reconstruct old daily gains. A post can have a current lifetime total without a baseline for last week. In this case, the tool returns coverage_status: "missing_baseline" and a coverage_warning. Do not describe that lifetime total as weekly gain.

A post with only pre-period snapshots has no period result. The tool returns coverage_status: "missing_period_observation" and a warning. It does not invent a zero gain from the baseline.

Counter decreases stay negative. The tool does not apply an absolute value or clamp the result to zero. Use get_post_metric_history to inspect a decrease or another material anomaly.

The first delivery supports views, likes, and comments.

Each ranking row contains the campaigns post ID, the Zernio post ID, the platform, the account ID, the planned publish time from scheduled_for, a short content excerpt, the chosen metric, and snapshot coverage. It excludes full content and unrelated identity data.

Account summaries and rankings use RLS-safe SECURITY INVOKER database RPCs. The ranking RPC applies the requested limit before it returns data. It excludes soft-deleted posts.

Metric history has a 10,000-snapshot hard cap. The server returns an explicit error above the cap before it loads row data. Below the cap, it pages by the unique collected_for date and requires the fetched total to match an exact PostgREST count. It repeats the count after paging and rejects concurrent count changes. A smaller server page cap cannot silently truncate history. Narrow the date range and retry when the count or pages change.

There is no reject_post, and approve_post cannot decide anything — it only writes down what a human decided. The judgement stays in the dfl-campaigns UI.

The rule from the plan is the AI never posts on its own: a generated post waits in awaiting_review until a person reads it. The JWT reaching this server is the user’s own — nothing here can tell “Samuel clicking” apart from “Samuel’s agent calling a tool”. So the boundary is not a permission check, it is the tool surface itself: the agent that wrote the post has no call available that judges it.

There was no approve tool at all until 2026-09-11. What changed is not the rule — it is where a person is allowed to say the words.

Tainan asked his agent, over Telegram, to post a Reel on his own Instagram. The agent drafted it and stopped dead: dispatch_post refuses anything that is not approved, and there was nowhere to record that he had already said yes. The decision existed; the only thing missing was a place to write it down.

So the tool records rather than decides, and two things the dfl-campaigns server enforces keep it honest:

  • Super admin only (get_my_iam_role() ≥ 100). Everyone else is refused with 403, not 503 — for an agent, “unavailable” is an invitation to retry forever, and a permission refusal has to read as one.
  • approval_reference is required, and it is the whole point: on an approval with no click it is the only trace of the person. It has to point at something real — “Telegram msg 18508” — that someone auditing the post later can go read. No check on either server can tell a fabricated pointer from a genuine one, which is exactly why the tool description tells the agent, in as many words, never to invent one.

The row then carries approval_channel (ui or mcp) next to approved_by, so a click and a conversation are never the same record after the fact. Approving in the queue still needs no special role: whoever clicks is reading the post.

There is no cron and no separate send step. Approving a post, in the UI or with approve_post, sends it to Zernio with status: "scheduled" for its scheduled_for, and Zernio publishes it then; a time that already passed goes out now. If Zernio refuses, the post stays approved with no Zernio id, which the UI shows as not scheduled, and dispatch_post is the retry. A post nobody approves before its time does not go out and shows as missed in the calendar.

Dispatch was on this list until 2026-09-08, when Tainan moved it: “o approve é uma boa manter na UI mesmo samu, todo o resto ser possível fazer via agent (inclusive publicacao/envio dos approved)”.

Sending is not judging. canDispatch in dfl-campaigns refuses any post that is not approved with approved_by and approved_at recorded. Those fields are written by a person clicking in the queue, or by approve_post recording a decision they already gave you — never by dispatch_post itself. An agent calling it on something it just drafted still gets “Só um post aprovado por uma pessoa pode ser enviado.” The human gate shrank to the approval; it did not move.

revise_post_in_review closes the same door from the other side. Editing the body of an already-approved post would ship text nobody read, without ever calling approve — so it refuses anything past approved. To change an approved post, a reviewer moves it back to the queue in the UI first.

reject_post and delete_post reach only your own posts

Section titled “reject_post and delete_post reach only your own posts”

Added 2026-09-25, because agents left smoke drafts in the human review queue and the dfl-campaigns API deletes only a draft or a rejected post. Both tools read the post first and refuse unless its created_by is the caller’s user id. The database RLS on campaigns.posts is by role (iam.is_member()), not by owner, so this check is what keeps an agent from rejecting another person’s post — that stays a judgement a person makes in the UI.

  • reject_post {post_id, reason} sends the same PATCH status: "rejected" the UI sends, with the caller’s JWT. It accepts only awaiting_review.
  • delete_post {post_id} calls DELETE /api/posts/:id. The API accepts only draft and rejected, and the DELETE filters on the same states.
  • A post with no created_by, or a session with no known caller, is refused.

archive_post hides a published post and keeps its numbers

Section titled “archive_post hides a published post and keeps its numbers”

Added 2026-09-29 (Tainan TG msg 20007). delete_post never reaches a post that went out, and a hard delete would take its post_metric_snapshots with it by FK cascade. archive_post {post_id, reason} calls POST /api/posts/:id/archive with the caller’s JWT. The server checks iam.get_global_level() >= 80 before it reads the post (so a member gets 403 for any id), requires the reason, and sets deleted_at, archived_by and archive_reason. RLS enforces the same gate again (dfl-schema posts_archive_admin_self). The post leaves the calendar, the profiles and analytics; the snapshots stay.

Why unschedule_post is not the reject tool

Section titled “Why unschedule_post is not the reject tool”

Approving schedules, so a post a person cleared is already in Zernio’s hands, and until 2026-09-16 the only way to take it back was the UI. unschedule_post is that action, and it moves in one direction only: the server cancels the post in Zernio first, then rewrites the row to awaiting_review with approved_by, approved_at and zernio_post_id cleared. The post cannot go out again without a new human approval — the tool spends an approval, it never grants one. Rejecting is a verdict on the content and still has no tool here.

The new scheduled_for is required, not decoration: it is the date the post holds while it waits in the queue, and the server refuses one that already passed. Its reach ends where Zernio publishes — inside the last few minutes before scheduled_for Zernio may already be sending, so the server refuses rather than report a cancel that did not happen. What is already on the network comes down in the network, by a person.

instagram_collaborators is a list of up to 3 usernames, without the @. A post with a collaborator shows up in the feed and the grid of both accounts, so it is not metadata: dfl-campaigns treats a change to the list exactly like a rewritten body — the approval is dropped and the post returns to awaiting_review. Both tools pass the list through untouched and let that server validate it; a username Instagram would not accept comes back as a refusal with the reason, and nothing is written. The list is never silently trimmed, because a post the caller believes marks someone and does not is worse than an error.

Marking is an invite: the other account has to accept it in the Instagram app before the post appears there. Neither Zernio nor Meta can accept it for us.

Every other package in this fleet talks to Postgres with the caller’s JWT. The calendar tools talk HTTP to dfl-campaigns. They do not write its tables directly. This is a security choice.

The approval state machine lives in that repo’s server/post-approval.ts. The database holds only a handful of CHECK constraints (approved and dispatched require approved_by + approved_at; an mcp approval requires an approval_reference) and an RLS policy of WITH CHECK (true) — a direct writer could insert status = 'approved' with its own user id, skip awaiting_review entirely, and every constraint would pass. The role gate on approve_post is server-side for the same reason: it is a check the database does not make. Going through the same server the UI uses means the agent gets exactly the actions a person has, under exactly the same guards.

It also keeps the Zernio bearer token out of reach: dispatch happens inside dfl-campaigns, which is where that credential lives.

Analytics is the read-only exception. The account and ranking tools call SECURITY INVOKER functions with the caller’s Supabase JWT. Metric history reads campaigns.post_metric_snapshots with the same JWT. The table’s member policy controls the rows. The MCP has no service-role client. It exposes no metric write tool.

The social account mapping is the second exception, and it is a write. The mapping tools read and write campaigns.social_accounts directly with the caller’s JWT. That table holds no post, no status and no approval, so it has no path to the review queue. Its RLS is the whole gate: members read, and only global admins insert, update or delete. A trigger stamps created_by and updated_by from auth.uid().