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 |
| Backing data | Calendar 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. |
| Tool | What it does |
|---|---|
list_zernio_profiles | Each Zernio profile (a person or the company) with its connected accounts per network. Call it first when drafting for a person: pass the profile as zernio_profile_id and use only its accounts as channels. |
list_core_daily_topics | What each person said in the last N days (1–30), from #core-daily-updates on Discord and the Core Daily transcripts: short chatter and repeats removed, up to 30 lines per person. Read-only; use it to propose a draft per person. |
list_suggested_topics | The suggested topics (“pautas”) on the Curadoria board of /posts/pautas: curated content ideas that later become one or many posts. Filters by status, assignee and BU; archived reads the archived ones. Read-only. Says plainly when the board is not live on this deployment (do not retry). |
create_suggested_topic | Adds one suggested topic to the board (default status idea, origin always mcp) and returns its card URL. Posts nothing: a person turns the topic into posts. A refusal reads as do-not-retry; only a 503 is retryable. |
update_suggested_topic | Edits the content of one suggested topic (title, briefing, due date, formats, channels, links, BU, assignee); only the fields sent change and null clears a nullable one. Does not change the column or the order. Any member may edit. A refusal reads as do-not-retry. |
move_suggested_topic | Moves a suggested topic to a board column (status) and, optionally, right above before_id or right below after_id (never both). Any member may move. A refusal reads as do-not-retry. |
archive_suggested_topic | Archives a suggested topic (hides it from the board, reversible) or brings it back with restore=true. Any member may archive. |
delete_suggested_topic | Permanently deletes a suggested topic. Needs confirm_title equal to its current title. Only the creator or an admin: the server decides and refuses everybody else with do-not-retry. |
list_zernio_accounts | The connected social accounts, each with the id a channel needs. Call it before drafting: Zernio publishes per account, and there is more than one on the same platform. |
list_post_business_units | The BUs the calendar schedules for (strategy.business_units, archived excluded). Everything else takes a business_unit_id uuid, never a name. |
get_business_unit_voice | Read the BU’s brand VOICE before writing copy. The writing patterns from strategy.writing_patterns — the single canonical store — read through the strategy MCP with your own JWT. Refuses with an error when the BU has no slots, rather than returning an empty array an agent would read as permission to guess. Read-only; authoring lives in BM Canvas. |
list_posts | Read the calendar, ordered by scheduled_for. Filter by BU, by status and by a date window. status: "awaiting_review" is the review queue. Each post carries assignment (profile and assignee); include_metrics: true attaches latest_metrics per platform and account. |
get_post | One post with its channels, its state and its approval trail, plus assignment and latest_metrics — the latest views/likes/comments per platform and Zernio account, never summed across platforms. |
draft_post_for_review | Write a post into the calendar. It is always recorded as AI-authored, so it lands in awaiting_review. For an Instagram Reel, pass cover_media_id or Instagram uses frame 0. instagram_collaborators marks up to 3 co-author accounts. |
revise_post_in_review | Rewrite a post that is still in the queue — body, schedule, media or instagram_collaborators. Refuses one that a person already approved, and never changes status. |
approve_post | Record an approval a person gave you in conversation, pointing at the message they gave it in, and schedule the post in Zernio in the same call. Requires the expected_updated_at the agent read; a post revised since is refused with 409. Super admins only. |
draft_story_sequence_for_review | Write an ordered Instagram Story sequence: one post per video part from the studio tool split_export_for_stories, in that order, to exactly one Instagram account. Every part lands in awaiting_review. Returns the review_url of part 0. |
get_story_sequence | One Story sequence with every part in index order: status, schedule, updated_at, approval trail and Zernio id. |
approve_story_sequence | The approve_post rule for a whole sequence: record an approval a person gave you, with the reference of their message, and send the parts to Zernio as Stories in index order. It stops at the first failure, so the order is kept. A future first_at is honored; an earlier one moves to now + 30 s, and the answer returns both times in first_at. Reads the sequence to fill expected_updated_at when the caller does not pass it. Super admins only. |
check_story_sequence_order | Read from Zernio when each part was published and report the parts out of order or not yet published. A part never sent reads not_sent (with its row status), not rejected. Sends nothing; records each live part as published, with its live URL. |
retry_story_sequence | Send the approved parts that are not scheduled, in index order, after Zernio refused one. Never approves anything. |
unschedule_post | Take a post Zernio is holding back out of it: cancelled there first, then returned to awaiting_review with the approval cleared. Only moves a post backwards — it is not the missing reject, and it cannot reach a post that already published. |
reject_post | Withdraw one of your own posts from the review queue: awaiting_review → rejected, with the reason kept as review_notes. Refuses a post created by somebody else (a person rejects that in the UI), a draft, an approved post and a scheduled or published post. |
delete_post | Permanently delete one of your own posts while it is a draft or rejected — for example a smoke or test post. Refuses a post created by somebody else, a post in review (reject it first), and every approved, scheduled or published post. |
archive_post | Admin only (global level ≥ 80). Hide an approved or published post from the calendar, the profile pages and analytics while keeping its metric snapshots: a soft delete that records who archived it and a required reason. Never calls Zernio, and there is no undo. Refuses a draft or rejected post (delete_post), a post in review, and a post still scheduled in Zernio (unschedule_post first). |
set_post_content_tag | Write the content-format tag (media_group + archetype) on one post, in any status, published posts included — nothing else changes. The creator may tag; any other post needs a global admin (iam.get_global_level() ≥ 80). The pair must match the content-visual taxonomy. dry_run shows before/after without writing. |
set_post_keywords | Replace the SEO keywords (strategy.keywords) a post was written for, in any status — dispatched posts included, so older content can be mapped. The first keyword is the primary one and gets the post’s views in the SEO coverage. Never touches the post or its approval. draft_post_for_review also takes keyword_ids. |
dispatch_post | Retry only: re-send an approved post that Zernio refused at approval time. Approving already schedules the post; refuses anything without a recorded approver. |
list_campaign_accounts | List each platform and Zernio account combination with stored post metrics. It includes post count and snapshot coverage. |
rank_account_posts | Rank posts for one exact account and platform by views, likes, or comments. The caller must select lifetime or period_gain. |
get_post_metric_history | Read the stored daily absolute counters for one campaigns post. Account and platform are required. Date filters narrow the history. |
get_account_analytics | The last 30 São Paulo days of one account on one platform, the same numbers as the Analytics page: daily sum of each post’s latest cumulative views, the top 5 posts and the median views of posts dispatched in the window. One account per call; never summed across accounts. |
list_social_accounts | List campaigns.social_accounts: each Zernio account with its business unit (name, logo) and its owner (name, avatar). Filter by BU, owner or Zernio profile; unmapped_only returns the accounts with no BU. |
set_social_account_mapping | Set the BU and the owner of one Zernio account (idempotent UPSERT on zernio_account_id). An omitted field keeps its value; null clears the BU or the owner. The BU must exist — create a creator BU with create_business_unit on the strategy MCP. Global admins only (RLS). |
Analyze content performance
Section titled “Analyze content performance”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:
lifetimeuses the latest stored absolute counter. Use it for the first retrospective report after the historical bootstrap. It does not accept dates.period_gainsubtracts the latest snapshot at or beforestart_datefrom 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.
What this server deliberately cannot do
Section titled “What this server deliberately cannot do”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.
Why approve_post is not a hole in that
Section titled “Why approve_post is not a hole in that”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_referenceis 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.
Approving schedules (since 2026-09-15)
Section titled “Approving schedules (since 2026-09-15)”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.
Why dispatch_post is not a hole in that
Section titled “Why dispatch_post is not a hole in that”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 samePATCH status: "rejected"the UI sends, with the caller’s JWT. It accepts onlyawaiting_review.delete_post {post_id}callsDELETE /api/posts/:id. The API accepts onlydraftandrejected, 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 count as content
Section titled “Instagram collaborators count as content”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.
Calendar writes go through the app server
Section titled “Calendar writes go through the app server”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().