Como Construimos um produto de R$600.000.000 Com 61% Uptime (E Como Arrumamos)
Obs: traduzi o meu texto original usando IA :) Medium Post
Um jornalista da Harper's Magazine veio fazer um perfil do nosso fundador.
Roy se ofereceu pra fazer uma demo do produto.
Eis o que Sam Kriss escreveu:
"Ele abriu o Cluely no laptop e o produto imediatamente parou de funcionar. Roy desceu pisando firme até o andar da engenharia. 'O Cluely não tá funcionando!' disse. Seguiram-se uns quinze minutos de conserto frenético enquanto seu time escolhido a dedo de programadores de elite tentava colocar o produto de volta no ar. Assim que conseguiram, retomamos nossos lugares, momento em que o Cluely imediatamente caiu de novo."
Aquilo não foi um dia ruim. Aquilo era o produto.
O gráfico

61.26% de uptime em 30 dias.
Não era degradação. Não era intermitência. Simplesmente fora do ar. De forma confiável. Todo mês. Duas barrinhas verdes num mar de vermelho. Cada usuário, cada dia, na base da sorte.
Não existe enquadramento que torne esse gráfico aceitável. Ou você conserta o sistema, ou aceita que ele está quebrado.
A gente passou muito tempo sem fazer nenhum dos dois.
Como chegamos aqui
A Cluely nasceu com uma visão clara: uma IA em tempo real que entende o que está acontecendo ao seu redor e te ajuda antes mesmo de você pedir. Roy já tinha construído algo que funcionava no espaço de preparação para entrevistas. O objetivo era expandir isso para algo muito maior — mais próximo de uma extensão do seu próprio pensamento do que de uma ferramenta que você abre.
A versão inicial ganhou tração. Mas "funcionava bem o suficiente pra ganhar tração" estava fazendo trabalho pesado nessa frase.
Quando entrei como Engenheiro Fundador em setembro, a base de código já era reconhecidamente um problema. Fazer deploy era como jogar na loteria. O produto era instável de formas que estavam ficando impossíveis de esconder. Cada deploy vinha acompanhado do mesmo medo silencioso: o que a gente quebrou dessa vez.
O que ninguém entendia completamente ainda era a profundidade do buraco.
Os problemas não eram só código bagunçado. Eram erros em todos os níveis: em funções individuais, na forma como os sistemas se conectavam e em decisões arquiteturais tomadas antes de uma única linha ser escrita.
Deixa eu mostrar os dois lados.
O micro: código em que não dava pra confiar
O if de 56 linhas
Nosso produto roda em cima das suas reuniões. Para saber quando você estava em uma, precisávamos detectar atividade no microfone. A abordagem: parsear logs brutos do macOS verificando se cada linha continha certas strings.
Aqui está a função que decidia se uma linha de log importava:
private processLine(line: string, now: number): void {
// Portão 1: essa linha parece relevante?
if (
line.includes("input_running") ||
line.includes("AVCaptureDevice") ||
line.includes("startStream") ||
// ... mais 5
) { /* talvez */ } else { return; }
// Portão 2: ignora linhas de compartilhamento de tela
if (
line.includes("display") ||
line.includes("screen") ||
// ... mais 9
) { return; }
// Portão 3: O MEGA-FILTRO
if (
line.includes("terminated") ||
line.includes("exited") ||
line.includes("cleanup") ||
line.includes("quit") ||
line.includes("close") || // casa com "disclose", "foreclose"...
line.includes("stop") || // casa com TUDO que contém "stop"
// ... mais 20 condições, incluindo um bloco AND/OR profundamente aninhado
) { return; }
// ...e SÓ ENTÃO a gente faz o processamento de verdade
}
Isso vivia num arquivo de 495 linhas. Toda vez que alguém descobria um falso positivo — uma linha de log que não devíamos ter capturado — adicionava mais um || e fazia deploy. A palavra "stop" casava com qualquer linha de log que contivesse a string "stop" em qualquer lugar, em qualquer contexto. Comentários ao longo do código descreviam a lógica de deduplicação como "ultra-agressiva", que era um jeito educado de dizer: a gente foi empilhando gambiarra até mais ou menos funcionar.
Acima dessa função havia um registro hardcoded de mais de 40 variantes de nomes de navegadores ("Arc", "Arc Browser", "Arc.app" como entradas separadas) e nove debug logs comentados, vestígios de sessões de debugging que ninguém limpou.
Essa era a nossa detecção de reunião.
Três serviços. Os mesmos bugs. Três vezes.
A gente suportava múltiplos provedores de transcrição. Decisão de produto razoável. A implementação:
assemblyAiTranscriptionService.ts (262 linhas)
deepgramTranscriptionService.ts (352 linhas)
audioTranscriptionService.ts (361 linhas)
Os três tinham shapes de estado idênticos, padrões de dispose() idênticos, tratamento de erros idêntico, boilerplate de MobX idêntico. ~975 linhas que deveriam ser ~400.
Quando você corrige um bug em um, precisa lembrar de corrigir nos outros dois. Você nunca lembra. Ninguém lembra.
O comentário que dizia tudo
Em algum lugar do renderer, um desenvolvedor perdeu a paciência com o linter:
// biome-ignore lint/suspicious/noArrayIndexKey: fuck off
<AiResponseMarkdown key={index}>{it}</AiResponseMarkdown>
Todo mundo já esteve aí. O problema é quando isso vira documentação estrutural.
O macro: arquitetura que trabalhava contra a gente
O código micro era sintoma. Os problemas mais profundos estavam em decisões que tomamos antes de escrever as funções bagunçadas.
A gente ficava consertando a camada errada
Nossas conexões com o banco de dados degradavam sob carga. Queries ficavam lentas, se empilhavam e eventualmente derrubavam a plataforma quando muitos usuários estavam online ao mesmo tempo.
O que a gente de fato fez: adicionou uma réplica read-only. Depois roteou cuidadosamente diferentes tipos de requisição para diferentes réplicas. Depois construiu conectores de banco separados para diferentes partes da plataforma e conectou tudo com lógica de roteamento cada vez mais elaborada.
Nada funcionou. O número de réplicas necessárias para resolver um bug na camada de aplicação é um número que não existe. Os crashes continuavam acontecendo de formas novas porque estávamos tratando sintomas em vez de causas. Cada hotfix adicionava complexidade, que adicionava novos modos de falha, que exigiam novos hotfixes. Estávamos construindo sobre uma fundação rachada e chamando isso de reforço.
Adicionamos complexidade que não precisávamos
No início da v1, nosso dashboard vivia dentro de um iframe em vez de ser empacotado como parte do app principal. Se você é engenheiro, já sabe onde isso vai dar.
Havia razões pra isso na época. Mas na prática significava que dois sistemas precisavam ficar sincronizados, se comunicar através de uma fronteira que não foram projetados pra cruzar, e ambos precisavam fazer deploy corretamente pra qualquer coisa funcionar. Cada mudança no dashboard tocava duas bases de código. Cada deploy era um problema de coordenação.
Essa é a categoria de erro mais difícil de enxergar até você pagar o preço: complexidade arquitetural que parecia flexibilidade, mas era só atrito. A gente não construía features. A gente mantinha o overhead de um sistema que foi over-engineered antes de entendermos o que estávamos construindo.
Nosso estado era uma teia de efeitos
Na v1, quase tudo acontecia como efeito colateral de outra coisa.
Usuário clica em algo → dispara um efeito → atualiza algum estado → causa uma reação → dispara um evento → eventualmente algo acontece no servidor. Geralmente a coisa certa. A cada passo, premissas implícitas se acumulavam. Você só conseguia entender por que um comportamento existia se pudesse reconstruir a cadeia completa de efeitos que o causou. Essa cadeia vivia inteiramente na cabeça das pessoas.
Sistemas reativos parecem elegantes na hora de escrever. Mas não escalam entre engenheiros, e especialmente não escalam no tempo.
Quando o produto mudava e precisávamos re-cabear comportamentos, descobríamos tarde demais que algum efeito lá no fundo da cadeia era estrutural por um motivo que ninguém havia documentado. Remove e algo não-relacionado quebra. Deixa e o comportamento novo fica errado. De qualquer forma, você está chutando.
O aprendizado: de um clique do usuário a uma ação no servidor a uma resposta na UI, tudo deveria ser rastreável — não através de mapas mentais e conhecimento tribal, mas através do próprio código. O "Go to Definition" deveria te levar pelo fluxo inteiro.
Nosso cache estava em todo lugar e em lugar nenhum
A busca de pessoas, uma feature central, tinha cache em quatro camadas diferentes: parte no banco de dados, parte no key-value store, parte na camada de API, parte no client. Cada camada foi adicionada por uma pessoa diferente resolvendo um problema diferente num momento diferente.
Ninguém conseguia raciocinar sobre o cenário completo. Quando algo estava lento, errado ou desatualizado, não havia um único lugar pra olhar. Quando tentávamos consertar, patcheávamos uma camada sem saber o que as outras três estavam fazendo.
Eventualmente, a busca de pessoas simplesmente parou de funcionar. Sem erro. Só parou. Rastreamos até a lógica de cache e não conseguíamos consertar com confiança sem arriscar tudo que tocava os mesmos dados.
Esse foi o ponto de ruptura.
Por que não dava pra refatorar
Tentamos consertar o sistema duas vezes antes da reescrita. As duas foram esforços sérios. Nenhuma funcionou. Não porque os engenheiros não eram bons, mas porque os problemas eram arquiteturais. Não dá pra consertar incrementalmente um sistema sobre o qual você não consegue raciocinar. Cada tentativa de limpar uma parte revelava o quanto o resto dependia de ela permanecer exatamente como estava.
A arquitetura não era o único problema.
Tínhamos contratado muitos engenheiros antes de ter product-market fit definido. O produto ainda estava mudando de forma, e um time grande de engenharia com um produto em evolução é uma das combinações mais difíceis de gerenciar. Cada pivô significativo exigia re-explicar contexto, re-alinhar trabalho e re-coordenar entre pessoas que carregavam modelos mentais diferentes do sistema.
Código se acumulava sem dono. Engenheiros iam e vinham. Ninguém entendia o todo, o que significava que ninguém podia mudá-lo com confiança. A gente não entregava produto. A gente mantinha algo que já tínhamos superado.
O reset
Então a empresa mudou completamente.
Ficamos em oito pessoas. O clima era de post-mortem.
Mas algo inesperado aconteceu.
Durante o Natal e o Ano Novo, apenas três de nós trabalhamos juntos por duas semanas: eu, Alex (nosso CTO) e Roy. Sem overhead de coordenação. Cada linha de código tinha um dono claro. Apenas três pessoas que entendiam o sistema inteiro, construindo juntas.
Entregamos mais nessas duas semanas do que nos meses anteriores combinados.
Esse foi o sinal.
A reescrita
Reescrever quase sempre é uma má ideia. A gente sabia disso. Já tínhamos falhado duas vezes tentando melhorar o sistema incrementalmente.
Mas estávamos gastando mais tempo lutando contra a base de código do que entregando produto. Então Alex e eu pressionamos por uma reescrita completa. Roy nos deu uma restrição: duas quatro semanas.
O que realmente mudou
Primeiro a filosofia
A base de código antiga tentava construir tudo do zero: um parser customizado de logs do macOS, um engine de drag customizado, três clientes de transcrição customizados, um sistema de cache customizado, um fluxo de autenticação customizado. A premissa implícita era sempre: a gente constrói.
A nova base de código partiu de uma pergunta diferente: o que já foi resolvido?
E quando construíamos algo do zero, forçávamos o primitivo mais simples possível: trinta linhas que fazem uma coisa bem, em vez de trezentas linhas que fazem cinco coisas mal.
Monitoramento de mic: 495 linhas → 3 imports
O arquivo monstro inteiro — o if de 56 linhas, a string de predicado de 1.000 caracteres, o registro hardcoded de apps, os nove debug logs comentados — substituído por:
import { AudioTee } from "audiotee";
import { StreamingTranscriber } from "assemblyai";
import recorder from "node-record-lpcm16";
Detecção de reunião delegada ao SDK do Recall. Transcrição para a API de streaming da AssemblyAI. Toda a lógica artesanal de detecção, eliminada. Bibliotecas que se especializam nesses problemas os resolvem melhor do que a gente jamais resolveu.
Estado: saem os efeitos, entra a clareza
Fora com MobX, Jotai e globals a nível de módulo rodando simultaneamente. Entra um único schema validado com Zod, separado de forma limpa entre estado persistido (sobrevive a reinicializações) e estado de runtime (efêmero):
export const persistedSharedStateSchema = z.object({
finishedOnboarding: z.boolean().default(false),
permissions: z.object({
microphone: z.enum(["unknown", "granted", "denied"]),
screen: z.enum(["unknown", "granted", "denied"]),
accessibility: z.enum(["unknown", "granted", "denied"]),
}).default({ microphone: "unknown", screen: "unknown", accessibility: "unknown" }),
isInvisible: z.boolean().default(false),
shortcuts: shortcutsSchema.default(DEFAULT_SHORTCUTS),
});
O processo principal é dono do estado. Persiste em disco. Propaga via IPC. O renderer lê via useSyncExternalStore. Cada fluxo é explícito. Sem efeitos implícitos. Sem invariantes escondidas.
Cache: espalhado → um único primitivo
O cache fragmentado em quatro camadas substituído por um único padrão uniforme construído sobre Cloudflare KV:
async function withCache<T>(
key: string,
ttl: number,
readThrough: () => Promise<T>
): Promise<T> {
const cached = await kv.get(key);
if (cached) return JSON.parse(cached);
const value = await readThrough();
await kv.put(key, JSON.stringify(value), { expirationTtl: ttl });
return value;
}
Trinta linhas. Cada endpoint usa o mesmo padrão. Chave, valor, read-through. Quando algo está errado, existe exatamente um lugar pra olhar.
Todo o resto
Os três serviços de transcrição copiados e colados: um só. O dashboard no iframe: eliminado, empacotado no app principal. Autenticação delegada ao Clerk. CORS server-side em vez de interceptadores de web request do Electron. Infraestrutura como código tipado e lintado em vez de um emaranhado de scripts bash.
O resultado final: ~70% menos código. 100% mais confiável.
O resultado
Fizemos o rollout em 1% → 10% → 100%, monitorando cada sinal. Esperávamos que as coisas quebrassem. Essa era a última tentativa de verdade. Não havia plano B.
As taxas de erro caíram bruscamente. A plataforma ficou de pé. E aí aconteceu a coisa que realmente importava.
As reclamações dos usuários mudaram.
De: "isso tá quebrado."
Para: "vocês podem adicionar essa feature?"
Essa é a linha divisória. É aí que você sabe que saiu do buraco.
O que realmente aprendemos
Complexidade se acumula com juros compostos. Cada atalho arquitetural — o dashboard no iframe, o cache fragmentado, o estado orientado a efeitos, os hotfixes na infra — parecia razoável isoladamente. Juntos, construíram um sistema sobre o qual ninguém conseguia raciocinar. Os erros não se somavam. Eles se multiplicavam.
Efeitos escondem invariantes. Código reativo, orientado a efeitos, parece elegante na hora de escrever e se torna impossível de manter. Quando comportamento é efeito colateral de efeito colateral, as premissas por trás dele vivem só na cabeça das pessoas. Pessoas saem. Premissas são violadas. Coisas quebram por motivos que ninguém consegue explicar.
Primitivos simples vencem sistemas espertos. Trinta linhas de wrapper sobre Cloudflare KV resolveram nosso problema de cache melhor que o elaborado sistema multicamadas que substituíram. Quase toda melhoria na v2 seguiu o mesmo padrão: encontre a coisa mais simples que de fato resolve o problema, e pare.
Clareza é a única coisa que escala. Entregamos mais com oito pessoas que entendiam o sistema inteiro do que com um time maior que não entendia. Não por processo, ferramentas ou metodologia. Porque todo mundo conseguia segurar o sistema inteiro na cabeça. Esse é o único tipo de velocidade que realmente faz juros compostos.
Contrate depois de saber o que está construindo. Um time grande de engenharia antes de product-market fit é um passivo, não um ativo. Cada pivô exigia re-alinhar pessoas que já tinham construído modelos mentais da direção antiga. Estávamos tentando manobrar um navio carregado quando precisávamos ser uma lancha.
Se você está nessa situação agora
Você vai reconhecer. Deploy parece jogo de azar. Adicionar uma feature quebra duas outras. Debugar é arqueologia. Você não está resolvendo problemas — está escavando eles.
O instinto é forçar. Contratar mais engenheiros. Adicionar mais infra. Consertar no caminho.
Às vezes isso está certo. Mas existe um ponto em que a própria base de código é o gargalo, e nenhum esforço em cima dela muda isso. Tentamos evitar essa conclusão duas vezes. Nas duas estávamos errados.
A parte mais difícil não foi a reescrita. Foi aceitar que o sistema precisava ser reconstruído, não consertado. São coisas diferentes. A distância entre elas costuma ser seis meses de negação.
A Cluely v1 nos trouxe até aqui.
A v2 é sobre o que a gente constrói daqui pra frente.
Dúvidas sobre decisões específicas, ou passando por algo parecido? Me encontre em @guigaribalde ou responda abaixo.