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.

Endpointhttps://campaigns.mcp.devfellowship.com/mcp
Tools34
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.
ToolWhat it does
list_zernio_profilesEach 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_topicsWhat 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_topicsThe 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_topicAdds 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_topicEdits 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_topicMoves 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_topicArchives a suggested topic (hides it from the board, reversible) or brings it back with restore=true. Any member may archive.
delete_suggested_topicPermanently 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_accountsThe 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_unitsThe BUs the calendar schedules for (strategy.business_units, archived excluded). Everything else takes a business_unit_id uuid, never a name.
get_business_unit_voiceRead 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_postsRead 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_postOne 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_reviewWrite 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_reviewRewrite 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_postRecord 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_reviewWrite 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_sequenceOne Story sequence with every part in index order: status, schedule, updated_at, approval trail and Zernio id.
approve_story_sequenceThe 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_orderRead 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_sequenceSend the approved parts that are not scheduled, in index order, after Zernio refused one. Never approves anything.
unschedule_postTake 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_postWithdraw 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_postPermanently 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_postAdmin 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_tagWrite 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_keywordsReplace 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_postRetry 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_accountsList each platform and Zernio account combination with stored post metrics. It includes post count and snapshot coverage.
rank_account_postsRank posts for one exact account and platform by views, likes, or comments. The caller must select lifetime or period_gain.
get_post_metric_historyRead the stored daily absolute counters for one campaigns post. Account and platform are required. Date filters narrow the history.
get_account_analyticsThe 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_accountsList 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_mappingSet 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).

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().