flows.md · documentação
Componentes de página#
Um bloco de código cujo idioma começa com @ não é código: é uma chamada de componente. A página continua sendo markdown — dá para exportar, colar no GitHub, guardar num arquivo — e o @ é a única sintaxe nova.
A linha de chamada#
A primeira linha do bloco é o nome e os argumentos:
```@pages columns:2
/@docs/showcase/orbit
/@docs/showcase/relay
```
@pages é o nome. columns:2 é um argumento — nome:valor, separados por espaço, com aspas quando o valor tiver espaço (title:"Antes do deploy"). O resto do bloco é o corpo, e cada componente diz o que faz com ele: uma linha por item, texto livre, ou nada.
Para a página de topo de uma seção, nem a lista precisa: from: mostra as páginas de dentro de uma pasta, na ordem em que foram criadas, e uma página nova entra sozinha. Cada leitor vê só as que pode abrir.
```@pages from:/@docs/showcase
```
Uma lista digitada mostra as 12 primeiras; limit: vai até 48, e o validate avisa quando a lista passa do limite.
Layout: arrumar a página#
Um bloco com @ guarda conteúdo — um gráfico, uma tabela, um formulário. Para arrumar a página em faixas, colunas e grades existe a outra sintaxe, :::nome argumentos … :::, e tudo entre as duas linhas é markdown comum, inclusive outros blocos:
Continua sendo markdown
Fora do flows.md as linhas ::: aparecem como texto solto e o resto da página lê normalmente. Nada se perde no caminho.
Numa grade, --- separa os itens:
section, hero, split, columns, grid, card, cover, inline, carousel, masonry, marquee, nav, footer e banner — cada argumento é uma escolha de uma lista, nunca CSS, e tudo funciona do celular à tela ultra-wide, no tema claro e no escuro. A lista completa, com exemplos, está na referência e os padrões prontos em padrões.
Filhos#
Alguns componentes aceitam filhos: linhas do corpo que começam com @ e configuram um pedaço da coisa. @data desenha as linhas de uma coleção do space, e os filhos dizem o que filtrar e o que mostrar:
```@data collection:chamados
@filter status eq aberto
@column titulo
@column prioridade
@count
```
Os operadores do @filter são eq, ne, gt, gte, lt, lte, contains, in e nin (os dois últimos levam uma lista, aberto,pausado). Todas as linhas valem juntas, e cada campo aceita cada operador uma vez — menos ne, que pode se repetir: @filter status ne fechado e @filter status ne cancelado deixam de fora os dois, o mesmo que @filter status nin fechado,cancelado.
Uma data no @filter pode contar a partir de hoje, para a página não precisar ser editada toda manhã: today, today+3d, today-1w, ou só o deslocamento, -14d — dias (d) e semanas (w). Hoje é o de quem lê (o fuso das Configurações dessa pessoa; para quem não escolheu, o do dono do space; sem nenhum, UTC), e numa data e hora — como updated_at — é o dia inteiro nesse fuso: lte today vai até a meia-noite de hoje, gt today começa à meia-noite, eq today é qualquer hora de hoje e ne today qualquer outra, enquanto lt today e gte today começam onde hoje começa. Uma tabela assim fica guardada entre visitas só por aquele dia e aquele fuso: as linhas não mudaram à meia-noite, mas a resposta mudou.
```@data collection:tarefas sort:prazo
@filter prazo lte today
@filter updated_at lt -14d
@count by:status
@column titulo link:pr,chamado
@column created_at format:age label:"Idade"
```
@count by:status conta as linhas de cada opção de um campo de seleção — todas as opções, na ordem do @schema, com zero quando não há nenhuma, e "Sem valor" quando alguma linha não tem — sobre as mesmas linhas que a tabela mostra. format:age mostra há quanto tempo foi uma data ("hoje", "3d", "2sem", "2m", "2a"; uma data que ainda vai chegar diz "em 3d"), numa coluna de data, data e hora, created_at ou updated_at (a última vez que a linha mudou, que também é coluna e ordem: sort:-updated_at). link:pr,chamado faz o texto da coluna virar um link para o primeiro desses campos de endereço que estiver preenchido; numa coluna que já é um endereço, label: é o texto do link ("Abrir").
Com updated:true na linha da cerca, a tabela diz embaixo quando uma linha da coleção foi adicionada, mudada ou apagada pela última vez — "Atualizado em 7 de out. de 2026, 14:30", no relógio e no idioma de quem lê. É um momento, nunca um "há 5 minutos": a tabela fica guardada entre uma visita e outra, e um tempo relativo pararia no instante em que foi guardada.
Escrever, não só ler#
@form grava numa coleção. Os campos vêm da declaração da coleção (@schema), então o formulário não pode pedir um campo que a tabela não tem:
```@form collection:chamados
titulo
prioridade
```
Para uma inscrição de um campo só — uma newsletter, uma lista de espera — o formulário cabe em uma linha:
```@form collection:lista layout:inline submit:"Assinar"
email
```
Um quadro#
A mesma leitura, em colunas: view:board agrupa as linhas por um campo de seleção (group:), uma coluna por opção, na ordem do @schema. A primeira @column é o título do cartão; as outras viram linhas pequenas embaixo dele.
```@data collection:chamados view:board group:status order:posicao
@column titulo
@column prazo format:date
@column link format:link label:"Abrir"
```
Quem edita o space arrasta um cartão para outra coluna (ou usa o menu "Mover para…" do cartão, pelo teclado), e isso grava o campo daquela linha. Com order: — um campo de número — também dá para mudar a ordem dentro da coluna, com "Mover para cima" e "Mover para baixo". Um cartão solto em outro quadro da mesma coleção, na mesma página, numa visão lado a lado ou em outra janela, ganha o campo do quadro onde caiu. Quem só lê vê o quadro sem nada para mover; quem não lê a coleção vê o aviso de dados privados.
columns: escolhe quais opções viram colunas, e em que ordem (columns:"fazendo,revisao"); as linhas com outra opção não entram no quadro nem ocupam o limit:. wip: dá a uma coluna um limite de trabalho em andamento (wip:"fazendo=3,revisao=2"): a contagem vira "n/limite", contando todas as linhas que os filtros deixam passar — não só os cartões que cabem no limit: — e o cabeçalho da coluna fica marcado quando passa do limite. O limite avisa; ele não impede um cartão de entrar.
As colunas não crescem a página: cada uma rola sozinha dentro de uma altura máxima (uns dois terços da tela), com o nome e a contagem sempre à vista. height: escolhe outra — short, medium (a de sempre), tall, screen, ou auto para a coluna crescer a página como antes — e um tema muda a de sempre com --nt-board-height.
Um calendário#
A mesma leitura, nos dias: view:calendar põe cada linha no dia do seu campo de data ou data e hora (date:). Um mês mostra semanas inteiras, de segunda a domingo; period:week mostra uma semana. Quem lê vai para o mês (ou a semana) anterior, o próximo ou volta a hoje pelos links ao lado do título — e só as linhas do período mostrado são lidas, até limit: (escreva limit:100 num calendário cheio; o calendário diz quantas ficaram de fora).
```@data collection:eventos view:calendar date:quando limit:100
@column titulo
@column sala
```
A semana começa na segunda (ISO); week_start:sunday ou week_start:saturday muda isso para todo mundo que lê a página. O calendário abre no mês (ou na semana) de hoje; start: abre em outro: uma data (start:2026-10-13) ou um dia contado a partir de hoje, como num @filter (start:today+2d, start:-1w). Dali o leitor avança e volta, e "Hoje" leva ao período de hoje. Numa tela estreita (600 px ou menos) o mês e a semana viram uma lista dos dias que têm itens, com o dia de hoje sempre, e as mesmas ações. Com o foco dentro do calendário, T volta a hoje e [ e ] vão para o período anterior e o próximo (dá para trocar as teclas em Atalhos de teclado).
Um dia de mês mostra três itens e um "+N" que abre o resto. Uma data e hora cai no dia em que acontece no fuso de quem lê: o das Configurações dessa pessoa; para quem não escolheu um (e para visitantes), o do dono do space; sem nenhum, UTC — o calendário diz qual. Quem edita o space arrasta um item para outro dia (ou usa "Mover para a data…", pelo teclado, para qualquer dia), e isso grava a data daquela linha; uma data e hora mantém a hora do relógio — 9h continua 9h, mesmo quando o horário de verão muda entre os dois dias. Numa semana de data e hora (period:week), com as horas desenhadas, soltar um item numa hora dá a ele aquela hora, em ponto; soltar no cabeçalho do dia mantém a hora que ele tinha. Um cartão de um quadro da mesma coleção pode ser solto num calendário de data (ganha o dia); num de data e hora não, porque a hora dele não é conhecida. Um item do calendário solto num quadro ganha a coluna do quadro. Quem só lê vê o calendário sem nada para mover; quem não lê a coleção vê o aviso de dados privados.
As linhas sem data esperam numa faixa Sem data embaixo do calendário (até limit:, dizendo quantas faltam). Arrastar um item dela para um dia dá a data; arrastar um item de volta para a faixa — ou escolher "Sem data" no "Mover para a data…" — apaga a data, a não ser que o campo seja obrigatório (aí a faixa avisa e não recebe nada). Numa data e hora, um item sem data que ganha um dia fica às 9h.
Cada item tem "Adicionar à minha agenda": um link do Google Agenda e um arquivo .ics (Apple, Outlook), os mesmos do bloco @event — uma data vira um dia inteiro, uma data e hora vira uma hora a partir dela. Quem só lê também tem.
Uma lista de tarefas#
view:checklist dá a cada linha uma caixa de marcar ligada a um campo sim/não (done:), com a primeira @column como rótulo e uma contagem ("3 de 8 feitos"). group: — um campo de seleção — separa a lista em seções, cada uma com a sua contagem; order: — um número — guarda a ordem, que quem edita muda arrastando pela alça ou com "Mover para cima" e "Mover para baixo", dentro da seção.
```@data collection:mudanca view:checklist done:embalado group:comodo order:posicao
@column item
@column observacao
```
Quem edita o space marca e desmarca, e isso grava o campo na hora (se não der, a caixa volta e a página diz por quê). Quem só lê vê as caixas desabilitadas. Não é o bloco @checklist, que guarda as marcas no navegador de quem lê e não toca em coleção nenhuma: esta é a lista da coleção, a mesma para todo mundo.
Um campo, mudado ali mesmo#
view:field mostra um campo de uma linha — o status do post na página do próprio post, a caixa de feito na página da tarefa — como o controle do tipo dele, e quem edita o space muda ali mesmo. Os @filter escolhem a linha; field: diz o campo:
```@data collection:posts view:field field:status
@filter slug eq x-launch
```
Vários campos da mesma linha, lado a lado, com fields: (nessa ordem):
```@data collection:posts view:field fields:status,prazo,aprovado
@filter slug eq x-launch
```
Cada tipo tem o seu controle: seleção vira uma lista de opções; seleção múltipla, uma caixa por opção; etiquetas, as fichas (Enter acrescenta); sim/não, uma caixa de marcar; data, um campo de data; data e hora, no relógio de quem lê; número, um campo de número; texto, e-mail e link, uma caixa de texto (salva ao sair dela ou com Enter; Esc desfaz). Uma referência, um arquivo, um campo calculado e a data de criação mostram o valor e dizem onde se mudam. Cada mudança é salva na hora; se não der, o controle volta e a página diz por quê. Quem só lê vê os valores (a seleção como etiqueta). Sem JavaScript, quem edita vê os valores e um link para a tela de Coleções.
Se os filtros pegarem várias linhas, cada uma vira uma linha própria, até o limit, com o título da primeira @column (ou o nome da própria linha) e "mais N" no fim; se não pegarem nenhuma, aparece o empty:. Então o status de cada post da semana é:
```@data collection:posts view:field field:status limit:10 sort:prazo
@filter prazo lte today+7d
@column titulo
```
Avisar outro sistema (hooks)#
Uma linha @hook no @schema manda as mudanças das linhas para um endereço seu, depois que elas foram gravadas — um cartão que chegou em "Pronto", uma inscrição nova, uma linha apagada:
```@schema collection:chamados
titulo text
status select:Aberto,Pronto,Fechado
@hook on:updated field:status to:Pronto url:https://example.com/hooks/pronto
@hook on:created url:https://example.com/hooks/novo
@hook on:deleted,restored source:screen,mcp url:https://example.com/hooks/auditoria
```
-
url:para onde vai o POST:https://, num host público, na porta 443 (a de sempre), 80 ou de 1024 para cima. -
on:quais eventos:created,updated,deleted,restored. Semon:, os quatro — ou sócreatedeupdatedquando a linha temfield:, para quefield:status to:""não dispare a cada linha apagada. -
field:só quando esse campo mudou;from:eto:o valor de antes e o de depois, que o campo precisa conseguir guardar: num campo de seleção, uma das opções; num número, um número; num sim/não,trueoufalse; numa data,2026-10-04; numa data e hora,2026-10-04T12:00:00Z(comparada como instante).to:""é "ficou vazio". -
source:só mudanças feitas por estas portas:form,board,calendar,checklist,field,screen,mcp,import,group,feedback,system— um item movido num calendário écalendar, uma caixa marcada numa lista échecklist, um campo mudado comview:fieldéfield.
Só quem é dono do space aplica um @schema com hooks, e só ele vê o segredo de assinatura e as últimas entregas, na tela da coleção (Coleções). A linha @hook é texto da página: quem edita a página a lê no código-fonte; quem só lê não recebe as linhas @hook por porta nenhuma — nem na página, nem no Markdown dela, nem pelo MCP, nem na busca. Ainda assim, a prova de que uma entrega veio de nós é a assinatura, não um token no endereço. Num space provisório (sem dono) não há hooks.
Cada entrega é um POST com JSON — { hook, collection, events: [{ id, event, changes, source, actor, at }] }, onde changes é { campo: [antes, depois] } e actor é o nome de usuário de quem mudou (nada para um visitante) — e, numa importação, uma entrega por hook com as linhas da importação (até 1 000 eventos por entrega). Os cabeçalhos: X-Flows-Delivery (o mesmo em cada nova tentativa: use para não processar duas vezes) e X-Flows-Signature: t=<segundos>,v1=<hex>, o HMAC-SHA256 com o segredo de "<t>.<corpo>". Confira antes de ler o corpo:
import crypto from "node:crypto";
// rawBody: os bytes recebidos, antes de qualquer JSON.parse.
// header: o X-Flows-Signature; secret: o segredo da tela da coleção.
function verify(rawBody, header, secret) {
if (typeof header !== "string") return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // mais de 5 minutos: reenvio
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
// Com Express, o corpo cru vem de express.raw:
// app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(req.body, req.get("X-Flows-Signature"), process.env.FLOWS_HOOK_SECRET)) return res.sendStatus(401);
// const { events } = JSON.parse(req.body);
// res.sendStatus(204);
// });
Recuse uma assinatura com mais de 5 minutos (como no exemplo) e guarde os X-Flows-Delivery já processados: uma entrega repetida, nossa ou de quem a capturou, tem o mesmo id. Responda 2xx em até 5 segundos (a tentativa inteira tem 15). Outra resposta, um redirecionamento (não seguimos) ou silêncio é tentado de novo depois de 1 min, 5 min, 30 min, 2 h e 6 h; depois de cinco entregas seguidas que falharam, o hook se desliga e a tela da coleção mostra o botão para ligá-lo de novo. As entregas de um space saem uma de cada vez; com 500 entregas de um hook ainda esperando, as novas não entram na fila e a tela diz isso.
Quantos hooks cabem numa coleção depende do plano do dono do space: 1 no Free, 20 no Pro.
Um hook também pode ouvir os lembretes da coleção (@hook on:reminder url:…, abaixo): cada lembrete via:hook que vence vira um evento reminder, com { reminder: { id, line, field, value, due_at } }. Uma linha on:reminder não leva field:, from:, to: nem source:.
Lembretes (@remind)#
Uma linha @remind no @schema lembra pessoas de uma data das linhas — um post que sai amanhã, uma tarefa que vence em uma hora, uma reunião às 9h:
```@schema collection:tarefas
titulo text
prazo date
inicio datetime
responsavel email
@remind field:prazo before:1d to:field:responsavel
@remind field:inicio before:1h,15m
@remind field:prazo at:09:00 before:0d to:group:Editores
@remind field:prazo before:1d via:hook
@hook on:reminder url:https://example.com/hooks/lembrete
```
-
field:o campo de data (date) ou de data e hora (datetime) de que o lembrete conta. -
before:quanto antes, separado por vírgula, até quatro:15m,1h,1d,1w(no máximo um ano). Num campo de data só dias e semanas: uma data não tem hora de onde contar. -
at:HH:MMa hora no relógio de quem recebe: no próprio dia, oubefore:dias ou semanas antes. Um campo de data semat:lembra às 9h. -
to:quem:owner(o dono do space — também quandoto:fica de fora),field:<campo>(um campo de e-mail ou de texto da linha com o e-mail ou o nome de usuário de uma conta) ougroup:<nome>(um grupo com papel no space; nome com espaço vai entre aspas:to:"group:Book club"). -
via:email(o padrão),hook— o eventoreminderque uma linha@hook on:reminderencaminha; um lembrete por hook não tem destinatário (to:não vale) e conta no relógio do dono — oucalendar: só um alarme na linha, no feed do calendário de cada pessoa queto:nomeia (veja abaixo), sem e-mail nenhum; por isso vale em qualquer plano.
Cada pessoa é lembrada no seu próprio fuso (o das preferências da conta); quem não escolheu fuso é lembrado no fuso do dono do space, e sem esse em UTC. A conta é feita na data e hora locais, então um lembrete das 9h continua às 9h dos dois lados de uma mudança de horário de verão; uma hora que não existe (a que o relógio pula na primavera) vira a hora seguinte, e uma que existe duas vezes (a que se repete no outono) é a primeira.
Só recebe quem tem conta, pode ler a coleção e é gente do space: o dono, quem tem um papel no space e já abriu o convite, ou o membro de um grupo com papel no space — numa coleção pública, ler não basta, e um convite que a pessoa nunca abriu também não. Tudo isso é conferido de novo na hora de enviar: a linha ainda existe e a data ainda é a mesma, a pessoa ainda é a que o lembrete nomeia, ainda lê a coleção e ainda aceita e-mails de lembrete. Os lembretes que vencem para uma pessoa no mesmo minuto chegam num e-mail só, com o título de cada linha (o link abre a página do @schema se a pessoa pode abri-la; senão o space; senão não há link) e a data no relógio dela. Uma pessoa recebe no máximo 50 e-mails de lembrete por dia, somados todos os spaces. Todo e-mail de lembrete tem um link para parar de recebê-los (e o botão de cancelar do próprio programa de e-mail); eles voltam nas preferências da conta.
Uma data que já passou quando a linha é criada, ou um lembrete novo sobre linhas antigas, não dispara uma enxurrada: só vale o que vence dali em diante. Um lembrete que o servidor alcança mais de 6 horas atrasado não é enviado. Um lembrete nunca sai duas vezes; se o envio for interrompido no meio, ele pode se perder, e a tela da coleção mostra.
Lembretes por e-mail são do Pro: até 20 lembretes por coleção e 1 000 e-mails de lembrete por space por dia; passado isso, os do resto do dia ficam marcados como não enviados e a tela da coleção avisa. No Free, via:email é recusado, e cabe 1 lembrete via:hook por coleção, dentro do hook que o plano permite (o limite diário de e-mails não vale para hooks).
Só o dono do space aplica um @schema com lembretes e só ele os vê no bloco e na tela da coleção (Coleções), com os próximos e os últimos enviados. Como as linhas @hook, as linhas @remind são texto da página: quem edita a página as lê; quem só lê não as recebe por porta nenhuma.
No calendário de cada um (calendar:)#
calendar: no @schema põe as linhas da coleção no calendário de quem as lê — no Google Agenda, no Calendário da Apple ou no Outlook da pessoa, pelo feed do calendário dela:
```@schema collection:tarefas calendar:prazo
titulo text
prazo date
responsavel email
@remind field:prazo before:1d to:field:responsavel via:calendar
```
-
calendar:um campo de data (date, a linha vira um dia inteiro) ou de data e hora (datetime, a linha vira uma hora a partir dali). O campo ganha um índice, como se dissesseindexed, para que o feed leia só a janela que mostra. - Só o dono do space aplica.
Cada pessoa tem um endereço secreto (Configurações → Preferências → Feed do calendário) que o app de calendário dela assina. Nele entra tudo com data que ela pode ler nos spaces que são dela — o seu, um space compartilhado com ela que ela abriu, um space onde um grupo de que ela é membro tem papel: as linhas das coleções com calendar: que ela pode ler, e os blocos @event das páginas que ela pode ler. Uma coleção ou página pública de um space que não é dela não entra (ler não é assinar); uma página compartilhada com ela pelo nome entra, com as de baixo. Do que a linha tem, só sai o título (o primeiro campo de texto) e o nome da coleção.
Um @event com members_of: entra no calendário de quem a página deixa ver o bloco: quem é membro ativo do grupo (e é do space) e quem edita a página — perguntado de novo a cada busca, então quem sai do grupo perde o evento na próxima. Para os outros não sai nada dele: nem título, nem lugar, nem detalhes. O grupo é o que o bloco nomeava quando a página foi salva; se ele for renomeado ou apagado, o evento fica só para quem edita a página até ela ser salva nomeando um grupo de novo.
Os lembretes dela viram alarmes: toda linha @remind no campo do calendário que a nomeia (via:email ou via:calendar) é um alarme no evento, contado a partir do início — um dia antes de uma data são 15 horas antes da meia-noite em que o dia começa (9h da véspera). O celular lembra, em qualquer plano.
O feed é de graça em todos os planos. Ele mostra de 60 dias atrás a um ano à frente, até 1 000 itens (os próximos primeiro; o calendário diz quando ficou algo de fora), e é conferido de novo a cada vez que o app pergunta: um papel removido, um grupo deixado, uma coleção que saiu do calendário somem na próxima busca. O endereço aparece uma vez só, quando é criado; criar de novo com o feed já ligado (dois cliques, duas abas) não troca nada; "Novo endereço" troca por outro e o antigo para de funcionar na hora; "Desligar" apaga.
Ajudas em código inline#
Dentro de uma frase, um trecho em código pode virar uma coisa nossa.
icon:nome desenha um ícone do conjunto do flows.md, no tamanho e na cor do texto em volta:
[Começar agora `icon:arrow-right`](#comecar)
vira os links para as outras versões desta página, quando ela está escrita em mais de um idioma. O lugar é seu — normalmente o rodapé — e o visual é o da página, porque é a sua página que mostra o idioma, não o flows.md:
:::footer
**Sua marca** — uma frase sobre o que você faz.
`languages`
---
**Produto**
- [Recursos](#recursos)
:::
Só aparecem os outros idiomas: quem está lendo já está num deles. Numa página escrita num idioma só, o token não desenha nada — então o mesmo rodapé serve para todas as páginas do site.
O dia de hoje, com o dia da semana — numa página diária é a primeira coisa que se procura — e, com short, só a data. Escritos em código inline (aqui num bloco, para aparecerem como se escreve):
# Hoje, `today:`
Plantão de `today:short`.
É o dia de quem lê, no relógio dessa pessoa e no idioma da página: "quarta-feira, 7 de outubro de 2026" e "7 de out. de 2026". A página sai do servidor com a data do dia em que foi enviada, e o navegador troca pela de quem está lendo — então uma aba esquecida aberta de um dia para o outro mostra o dia novo quando a pessoa volta a ela.
Quando você erra#
Uma cerca com um argumento que o componente não declara não é ignorada em silêncio — isso seria uma página que renderiza e mente. Ela vira um bloco de erro no lugar dela, com o que estava errado, um "você quis dizer" quando dá para adivinhar, e a fonte da cerca ainda na tela. O resto da página renderiza normalmente.
A lista inteira#
Referência de componentes — todo componente, todo argumento, gerado por eles mesmos.