Pitch: Construí um outliner em que o arquivo .md é a fonte da verdade (e o sync não precisa de servidor)
Uso outliner há anos. Passei pelo Roam, fui pro Logseq, e os dois acertaram muito na experiência: journal diário, links bidirecionais, cada bullet sendo uma coisa que dá pra referenciar. Onde os dois me incomodavam era sempre o mesmo lugar: o sync e o que acontece com os meus arquivos.
No Roam, as notas ficam no banco deles. No Logseq, ficam em disco, o que já é muito melhor, mas cada bloco ganha uma linha id:: no meio do markdown, e o sync é por arquivo, na base do último que grava vence. Se editei no notebook offline e no celular offline, alguém perde.
Foi daí que nasceu o outl: um outliner local-first em que o markdown é a fonte da verdade. Quero contar aqui as decisões técnicas, porque elas são a parte interessante.
Markdown limpo, IDs fora do arquivo
O .md que você vê é o .md que você escreveu. Os IDs dos blocos ficam num sidecar (um dotfile ao lado), não em linhas id:: espalhadas pelo texto. Dá pra abrir as notas com grep, awk ou qualquer editor sem parsear formato proprietário.
Referência de bloco e embed existem (((blk-XXXXXX)) e !((blk-XXXXXX))), com handles curtos resolvidos pelo sidecar.
Por que um CRDT de árvore
A parte que mais me tomou tempo foi o sync. Um outline não é um texto e nem uma lista, é uma árvore. CRDT de texto não tem opinião sobre pai e filho: dois aparelhos movendo o mesmo bloco ao mesmo tempo podem terminar com o bloco com dois pais, sem pai, ou num ciclo. Merge do git também não resolve, porque bagunça a estrutura e os IDs.
O outl usa o algoritmo de move do paper do Kleppmann et al. (A highly-available move operation for replicated trees, 2022). Cada aparelho tem um op log append-only, ordenado por HLC (timestamp, actor), com operações Move, Edit, SetProp e Create. A árvore materializada é derivada do log: se ela corromper, é só repetir o log. Movimentos concorrentes convergem pela ordem das operações, e um movimento que criaria ciclo vira no-op de forma determinística. A edição de texto dentro do bloco fica com Yjs/Yrs.
Escrevi o passo a passo com código em CRDT walkthrough e o porquê em Sync, done right.
Sync P2P, sem conta e sem servidor
Você pareia notebook e celular e eles sincronizam direto, P2P, via iroh. É o setup padrão. Sync por arquivo (iCloud Drive ou sistema de arquivos compartilhado) também existe, como opção.
P2P tem um limite honesto: só converge quando os dois aparelhos se enxergam. Se o notebook fecha às 18h e o celular é editado às 22h, eles não se encontram. Pra isso dá pra subir um peer sempre ligado, num hardware seu, com Docker. Ele não é um servidor por onde as notas passam: é só mais uma réplica do op log que nunca dorme. Desligou, os outros aparelhos continuam conversando entre si.
Onde roda
- TUI: é o cliente mais testado.
?mostra os atalhos,:abre a paleta de comandos,Ctrl+Pfaz o fuzzy jump. - CLI: o mesmo binário, pra script e automação.
- MCP:
outl mcp serveexpõe o workspace pra Claude Desktop, Cursor, Zed e qualquer host MCP. Cada tool é 1:1 com um subcomando do CLI. - Desktop: Tauri, pra macOS, Linux e Windows.
- Mobile: iOS em beta no TestFlight e APK de Android em cada release.
Vindo do Logseq ou do Roam
outl import logseq ~/caminho/do/grafo ~/notes
outl import roam ~/Downloads/backup.json ~/notes
Também tem importador de Obsidian, e auto detecta o formato. Block refs ((uid)) e {{embed}} viram referências reais do outl, o estado de dobrado é mantido e cada dialeto é traduzido. Rode com --dry-run primeiro: o relatório conta tudo que foi tocado, e o que não deu pra resolver fica como ((unresolved:UID)) pra você revisar.
Status: beta, de verdade
Tudo que sai hoje é beta, a cada push na main. Algumas arestas que prefiro dizer antes:
- O Homebrew é por tap de terceiro e precisa de
brew trust. - O dmg do desktop não é assinado, então na primeira abertura é botão direito → Abrir.
- O APK de Android é assinado com uma chave nova a cada release, então atualizar exige desinstalar antes (#171).
- Não tem graph view, e não é substituto de Notion: nada de kanban, views de banco ou workspace de time.
Como testar
brew tap outlmd/outl https://github.com/outlmd/outl
brew trust outlmd/outl
brew install outlmd/outl/outl-beta # TUI, CLI e MCP
brew install --cask outl-desktop-beta # desktop (macOS)
outl init ~/notes
outl --workspace ~/notes # abre a TUI no journal de hoje
Desktop pra Linux (AppImage, deb, rpm) e Windows ficam nas releases. iOS pelo TestFlight. Tem um tutorial de 15 minutos e o getting started completo.
Quer contribuir?
O projeto é em Rust, licença MIT. Pra começar:
git clone https://github.com/outlmd/outl.git
cd outl
cargo build --workspace
cargo test --workspace
O core (CRDT, op log) não depende de UI, e cada crate tem um arquivo descrevendo o que ela é dona e quais invariantes não podem quebrar. Antes de um PR maior, vale ler o guia de desenvolvimento e o guia de contribuição. Tem issues marcadas como good first issue, e a gente conversa no Discord.
Se você testar, me conta o que quebrou. Feedback de quem veio do Logseq ou do Obsidian é o que mais me ajuda agora.
Pra quem quiser ir mais fundo, escrevi dois textos sobre o caminho até aqui: File sync isn't trivial e From paper to outliner.