Construí uma alternativa open source ao Buffer com Bun, Next.js e MCP - por dentro da arquitetura do manypost
Depois de muito tempo entre código, migrations, OAuth, filas e APIs que mudam sem pedir licença, chegou o momento de apresentar o Manypost.
O Manypost é uma plataforma open source para criar, aprovar, agendar e publicar conteúdo em várias redes sociais. Ele pode ser usado pela interface web, por uma API REST ou diretamente por agentes de IA através do Model Context Protocol, o MCP.
A descrição curta seria “uma alternativa self-hosted ao Buffer, Hootsuite, Hypefury e Later”. Mas por trás do calendário existe um sistema multi-tenant lidando com filas, OAuth, rate limits, idempotência, mídia, falhas distribuídas e contratos diferentes para cada rede.
Neste artigo, apresento o problema, as decisões arquiteturais e o que aprendi construindo um publicador multicanal com Bun, Hono, Next.js, PostgreSQL, Drizzle, pg-boss e Redis.
O código está no GitHub, sob a licença AGPL-3.0:
github.com/manypost/manypost-app
Por que construir outro agendador de redes sociais?
Existem ferramentas maduras para gestão de conteúdo, mas em muitas o código é fechado, o self-hosting é limitado, automações dependem de planos caros e a API cobre apenas parte do produto. Recursos para agentes de IA e versões comunitárias também costumam viver em camadas ou bases separadas.
Eu queria explorar outra direção: uma aplicação cujo código completo pudesse ser inspecionado e executado, em que interface, API e agentes compartilhassem a mesma regra de negócio.
Isso levou a alguns princípios:
- o self-hosted não seria uma demonstração reduzida;
- automações não teriam um domínio paralelo ao da interface;
- adicionar uma rede social não deveria espalhar condicionais pelo sistema;
- o banco seria a fonte de verdade das publicações;
- em caso de dúvida após uma chamada externa, o sistema não repostaria automaticamente;
- limitações e origem do projeto seriam documentadas.
O resultado ainda é um MVP, mas já deixou de ser apenas um experimento de calendário.
O que o Manypost faz hoje
No compositor multicanal, a pessoa escreve, escolhe canais, adiciona mídia e configura cada destino.
A partir daí, o sistema oferece:
- agendamento, texto e configurações próprias por canal;
- calendário e kanban de publicações;
- threads com atraso entre os itens;
- biblioteca de mídia e aprovação por link público;
- conexão de canais por OAuth ou credenciais;
- retry, rate limit, semáforos de concorrência e recovery;
- webhooks assinados e atualizações em tempo real por SSE;
- API REST com OpenAPI 3.1 e servidor MCP;
- execução self-hosted e serviço gerenciado a partir do mesmo monorepo.
O registry atual possui adapters para Mastodon, Telegram, Bluesky, Discord, LinkedIn, X, TikTok, Threads, Instagram, Facebook, Twitch, Kick, Dev.to e YouTube. A disponibilidade depende das credenciais e aprovações de cada plataforma.
O objetivo não é fingir que todas essas APIs são iguais. É justamente modelar suas diferenças sem transformar o core em uma sequência interminável de if (provider === ...).
Arquitetura em uma imagem
Web, API pública e MCP são três portas de entrada para os mesmos casos de uso:
Web ──┐
REST ─┼──► API/Hono ──► core/casos de uso
MCP ──┘ │
┌────────┼─────────┐
▼ ▼ ▼
PostgreSQL filas providers
O monorepo está dividido assim:
| Módulo | Responsabilidade |
|---|---|
apps/api | Hono, auth, OpenAPI, webhooks, SSE e MCP |
apps/web | interface Next.js |
apps/worker | consumidores de jobs |
packages/core | domínio, máquina de estados, casos de uso e ports |
packages/db | Drizzle/PostgreSQL e repositories |
packages/providers | adapters das redes sociais |
packages/queue | pg-boss, Redis e coordenação |
packages/contracts | tipos, schemas e eventos |
packages/config | ambiente e hosts |
O packages/core não importa as aplicações, o banco ou os providers. O domínio de publicação não depende de framework. Essas fronteiras são verificadas no CI com dependency-cruiser; não são apenas caixas bonitas em um diagrama.
Decisão 1: uma regra de negócio para pessoas, APIs e agentes
Quando uma aplicação começa pela interface e ganha uma API depois, é comum duplicar validações e autorização.
No Manypost, as entradas são adapters:
Pessoa ─────────► Web ─────┐
Automação ──────► REST ────┼──► schedulePost() ──► domínio
Agente de IA ───► MCP ─────┘
O caso de uso recebe ator, organização, canais, conteúdo e configurações, sem saber se a intenção veio de um clique, n8n ou MCP.
A API pública usa chaves com scopes, rate limit e Idempotency-Key. O OpenAPI gera o cliente tipado do frontend.
O MCP usa Streamable HTTP para listar canais, consultar posts, agendar, editar, cancelar e importar mídia. O agente não acessa o banco diretamente: passa pelas mesmas regras de autorização, plano, canal e auditoria.
Essa decisão reduz duplicação e torna as superfícies comparáveis. Se uma regra existe apenas na interface, provavelmente está na camada errada.
Decisão 2: providers são contratos de capacidade
“Publicar em uma rede social” parece uma interface simples. Na prática, cada plataforma muda limites de texto e mídia, formatos, threads, autenticação, campos obrigatórios, subcontas, concorrência e rotação de credenciais.
Por isso, cada adapter implementa comportamento e descreve capabilities:
ChannelProvider
│
┌───────────────┼────────────────┐
▼ ▼ ▼
autenticação capabilities publicação
OAuth/campos texto/mídia post/reply
refresh token limites/schema resultado/erro
O compositor usa essas informações para montar os controles, o core valida o conteúdo e o worker executa o efeito externo.
Uma configuração do YouTube não precisa existir no domínio do Mastodon, nem um artigo do Dev.to caber no formato de um post curto.
Cada provider fica isolado e passa por um test-kit de contrato. A complexidade externa continua existindo, mas não se espalha pelo monorepo.
Decisão 3: PostgreSQL guarda a intenção; a fila realiza o trabalho
Ao agendar conteúdo, uma transação cria:
- o grupo que representa a criação multicanal;
- uma publicação por canal;
- os itens individuais de cada thread;
- estados, cursores, horário e versão do job.
Somente depois do commit o trabalho é enviado ao pg-boss.
Web / REST / MCP
│
▼
validar ator, organização, canais, mídia e capabilities
│
▼
┌──────────────────── transação PostgreSQL ────────────────────┐
│ post_group │
│ ├── publication (canal A) │
│ │ ├── item 0 │
│ │ └── item 1 │
│ └── publication (canal B) │
└───────────────────────────────────────────────────────────────┘
│ commit
▼
enfileirar publicação no pg-boss
│
▼
worker chama o provider
Enfileirar antes do commit permitiria que o worker procurasse algo que ainda não existe. Mas fazer o enqueue depois também abre uma janela: o processo pode cair após persistir e antes de criar o job.
Por isso existe um scanner de recuperação. Uma publicação SCHEDULED vencida continua representando uma intenção não cumprida, mesmo se o job desaparecer. O scanner pode recriá-lo.
Essa escolha torna o PostgreSQL a fonte de verdade e a fila um mecanismo de execução. O pg-boss também usa PostgreSQL, o que reduz a infraestrutura necessária para self-hosting.
Redis possui outro papel: rate limit, semáforos, idempotência da API e pub/sub para realtime. Ele coordena; não decide se um post foi publicado.
Decisão 4: publicação precisa de uma máquina de estados
Um booleano published não explica o que está acontecendo. Uma publicação pode estar aguardando, executando, renovando credencial, esperando retry, cancelada, concluída ou em estado incerto.
O domínio trabalha com transições explícitas:
┌───────────────┐
│ TOKEN_REFRESH │
└───────┬───────┘
│
▼
DRAFT ──► SCHEDULED ──► PUBLISHING ──► PUBLISHED
│ │ │
│ │ ├────────► RETRYING ──┐
│ │ │ │
▼ ▼ ├────────► FAILED │
CANCELLED ◄────┘ │ │
└────────► NEEDS_REVIEW
As mudanças são condicionais. Um worker não pede simplesmente “grave PUBLISHED”. Ele precisa comprovar o estado, a versão do job e a propriedade da tentativa.
O caso difícil: a rede publicou, mas a resposta se perdeu
Agora chegamos a um dos problemas mais interessantes do projeto.
Manypost Rede social
│ │
│ POST /publicar │
├─────────────────────────────────►│
│ │ cria o post
│ conexão interrompida│
│◄─────────────── X ───────────────┤
│ │
│ resultado local: desconhecido │ resultado externo: sucesso
Uma transação PostgreSQL não inclui atomicamente um efeito no LinkedIn, no Mastodon ou no YouTube. Também não existe um protocolo de commit comum a essas APIs.
Logo, “exactly once” de ponta a ponta não é uma promessa realista.
O Manypost separa algumas garantias:
- apenas um worker recebe o direito de executar um item por vez;
- jobs antigos não podem alterar versões novas;
- erros seguramente transitórios podem usar retry;
- resultados indeterminados não são publicados novamente de forma automática;
- providers que oferecem idempotência recebem uma chave estável por item lógico.
Quando a rede pode ter aceitado a publicação, mas a confirmação local falha, o estado vai para NEEDS_REVIEW. Isso troca disponibilidade automática por segurança contra duplicação.
É uma decisão de produto: uma publicação esperando revisão é recuperável; uma campanha duplicada na conta de um cliente pode não ser.
Decisão 5: fencing e leases contra workers concorrentes
Editar, reagendar, cancelar ou repetir manualmente uma publicação incrementa seu job_version. Todo job carrega a versão conhecida no momento em que foi criado.
publicação atual: job_version = 8
job atrasado: job_version = 7
7 != 8 → no-op, sem chamar o provider
Esse número é um fencing token. Mesmo se a remoção do job antigo perder uma corrida, ele não pode ressuscitar conteúdo cancelado ou sobrescrever uma edição recente.
Existe ainda a concorrência entre dois workers válidos. Conferir o estado e atualizá-lo depois não basta, pois ambos podem ler o mesmo valor.
Cada item lógico possui uma tentativa durável identificada por:
(publication_id, job_version, position)
O repository concede atomicamente um lease com um owner_token:
Worker A ──┐
├──► claim atômico ──► A recebe owner_token ──► chama o provider
Worker B ──┘ B recebe conflito ──► não publica
Somente o proprietário atual pode confirmar o resultado e avançar o cursor da thread. Se o lease expirar e outro worker assumir, uma conclusão tardia do proprietário anterior é rejeitada.
Isso não torna a rede externa transacional. Resolve, porém, uma importante fonte local de duplicações.
Decisão 6: multi-tenancy não pode depender da rota
O Manypost organiza usuários por memberships. Canais, posts, mídia, webhooks, API keys, billing e auditoria pertencem a uma organização.
O orgId enviado pelo browser não é tratado como autoridade. A autenticação resolve a identidade e a membership persistida para construir um Principal:
credencial
│
▼
identidade + membership
│
▼
Principal { actor, userId, orgId, role, scopes }
│
├──► autorização do caso de uso
└──► filtro/join obrigatório no repository
O caso de uso verifica se a ação é permitida, mas o repository também precisa preservar o escopo. Uma rota correta não compensa uma consulta capaz de retornar dados de outra organização.
Nem toda tabela filha precisa repetir org_id. Entretanto, toda leitura ou mutação deve provar o tenant diretamente ou por um join com um pai já escopado.
Essa regra aparece no código, nos testes e nas revisões de migrations.
Decisão 7: segurança influencia o desenho
Uma integração social armazena credenciais capazes de publicar em nome de terceiros. Isso muda a arquitetura.
Entre as medidas presentes no projeto estão:
- tokens de acesso, refresh tokens e segredos de webhook cifrados com AES-256-GCM;
- AAD associado ao canal e à organização;
- API keys persistidas somente como hash e exibidas uma única vez;
- webhooks assinados com HMAC;
- validação de destinos remotos contra SSRF;
- correlation IDs, métricas e logs estruturados sem credenciais;
- scopes separados para pessoas, API pública e MCP;
- testes de isolamento usando organizações diferentes;
- autenticação humana e autenticação de máquina em middlewares distintos.
Segurança também aparece em decisões menos óbvias. Mídia referenciada precisa pertencer à organização. Um callback OAuth valida state e origem. Um agente MCP não pode reutilizar uma sessão humana para contornar scopes.
Essas preocupações não ficam restritas a um middleware chamado security.
Community e Cloud usam a mesma base
O Manypost mantém aplicação, billing, workspaces e recursos operacionais no mesmo monorepo open source.
mesmo código AGPL-3.0
│
┌──────────┴──────────┐
▼ ▼
Community/self-host serviço gerenciado
infraestrutura própria infraestrutura operada
sem cobrança interna planos e billing ativos
A diferença é de configuração e operação, não a existência de um repositório privado com as funcionalidades “de verdade”.
Isso tem um custo: as fronteiras entre política de plano e regra do produto precisam ser claras. Por outro lado, qualquer pessoa pode estudar como a aplicação funciona, executar sua própria instância e verificar o que está ou não implementado.
Por que essa stack?
As escolhas atuais tentam manter uma linha TypeScript curta entre contrato e execução:
| Tecnologia | Papel |
|---|---|
| Bun | runtime, workspaces, scripts e testes |
| Hono + Zod OpenAPI | API, validação e contrato OpenAPI |
| Next.js + React | aplicação web |
| PostgreSQL | fonte de verdade multi-tenant |
| Drizzle | schema, migrations e repositories tipados |
| pg-boss | jobs duráveis apoiados em PostgreSQL |
| Redis | coordenação distribuída e realtime |
Não acredito que essa seja uma stack universal.
Temporal poderia modelar workflows mais sofisticados. Kafka seria adequado para outro volume e outra topologia. SQS reduziria responsabilidades operacionais em uma aplicação presa à AWS. Uma outbox dedicada poderia ser preferível em outros cenários.
Para um produto self-hosted em estágio de MVP, PostgreSQL com pg-boss oferece uma relação interessante entre durabilidade e quantidade de serviços. Bun e Hono mantêm a API pequena. Drizzle permite que schema e queries continuem próximos do SQL sem abandonar tipos.
A tecnologia específica importa menos que as invariantes:
- persistir a intenção antes do efeito;
- conseguir recuperar trabalho perdido;
- invalidar workers antigos;
- classificar falhas externas;
- não transformar cache na fonte de verdade;
- preservar tenant e autorização em todas as camadas.
Transparência sobre a origem: Manypost e Postiz
O Manypost é uma reimplementação derivada conceitualmente do Postiz, também licenciado sob AGPL-3.0.
O contrato de providers, a taxonomia de erros, o pipeline de publicação e partes do modelo de dados seguem ideias estudadas no projeto original. A implementação usa outra stack — Bun, Hono e Drizzle no lugar de NestJS e Prisma —, mas trocar a tecnologia não apaga a origem das decisões.
Por isso, o repositório mantém NOTICE, ATTRIBUTION.md, a análise técnica da derivação e a licença AGPL-3.0.
Faço questão de incluir isso na primeira apresentação pública. Open source também significa preservar atribuição e permitir que as pessoas distingam implementação original, influência técnica e trabalho derivado.
O que ainda está em construção
O projeto está em fase de MVP.
Isso significa que há funcionalidades implementadas e testadas, mas também integrações sujeitas a gates externos, fluxos operacionais a amadurecer e casos de borda que precisam de mais uso real.
Não quero apresentar o Manypost como se anos de maturidade de produção já existissem. Quero apresentá-lo como um projeto aberto, com uma base arquitetural deliberada e espaço real para colaboração.
As APIs sociais tornam esse domínio especialmente desafiador:
- escopos e processos de aprovação mudam;
- algumas plataformas não oferecem idempotência;
- limites variam por usuário, aplicação e endpoint;
- formatos de mídia são incompatíveis;
- um refresh token pode ser rotacionado durante concorrência;
- uma publicação externa nunca participa da transação local.
Esses problemas são parte do produto, não detalhes que desaparecem com mais uma biblioteca.
Como explorar o projeto
Se quiser conhecer a implementação, estes são bons pontos de entrada:
- README e instruções de instalação;
- visão canônica da arquitetura;
- fluxos ponta a ponta;
- mapa do monorepo;
- atribuição e origem técnica.
O conjunto principal de validações inclui:
bun install --frozen-lockfile
bun run check
bun run db:check
bun run build:web
bun run spec:validate
Contribuições podem assumir várias formas: testar o self-hosting, revisar uma decisão, documentar um caso de borda, melhorar a experiência, implementar um provider ou relatar uma falha de integração com contexto suficiente para reproduzi-la.
Por que estou publicando agora
Construir um projeto dessa dimensão sozinho ou com poucos colaboradores tem uma armadilha: sempre existe mais uma melhoria que parece necessária antes de mostrar o trabalho.
Mais uma integração. Mais um teste. Mais uma tela. Mais um caso de falha. Mais uma revisão da documentação.
Em algum momento, porém, o projeto precisa encontrar usuários e desenvolvedores reais. Arquitetura sem uso vira apenas uma hipótese bem organizada.
O Manypost é a minha tentativa de construir uma alternativa open source e self-hosted para publicação em redes sociais sem tratar API, automação e agentes como acessórios.
Se a proposta fizer sentido para você, visite o repositório:
github.com/manypost/manypost-app
Uma estrela ajuda o projeto a ser encontrado. Uma issue bem descrita ajuda ainda mais.
E quero terminar com uma pergunta para quem também constrói produtos open source: qual foi o momento em que você decidiu parar de “preparar o lançamento” e finalmente mostrar o projeto para outras pessoas?