Rooms
ProduçãoSala de vídeo com link de convidado, gravação e transcrição em português
Seu cliente entra na chamada por um link, sem instalar nada e sem criar conta. Você recebe de volta o vídeo gravado e a transcrição em português, já separados por quem falou e presos à organização certa.
- Financeiras e fintechs que precisam de atendimento por vídeo com registro do que foi dito
- Operações reguladas que fazem entrevista, validação ou assinatura assistida e precisam guardar a evidência
- Plataformas de saúde, educação e seguros que já têm um produto e querem embutir vídeo sem virar empresa de WebRTC
- Assinatura de uma plataforma de reunião genérica para o atendimento ao cliente
- Integração direta com um SDK de WebRTC, feita e mantida pelo seu time
- Serviço separado de transcrição rodando depois da chamada
- Uma plataforma de reunião interna com agenda e calendário (isso é o Calendar)
- Um webinar ou live para centenas de espectadores
- Um agente de IA que conversa com o participante — o agente de sala hoje só transcreve
9 endpoints em 4 recursos.
Resumo executivo
O Rooms transforma uma videochamada em registro de negócio. Você cria uma sessão pela API, manda um link curto para o cliente, e ao final tem três coisas guardadas e presas à sua organização: quem entrou e por quanto tempo, o que foi dito — transcrito em português, separado por quem falou — e, se você pediu, o vídeo gravado em MP4.
Na prática isso resolve o atendimento que hoje acontece numa plataforma de reunião genérica e some. O analista faz a chamada, a evidência fica num arquivo pessoal dele, e três meses depois ninguém consegue provar o que foi combinado. Com o Rooms, a mesma chamada devolve GET /sessions/:id/transcript e GET /sessions/:id/recording, e quem lê precisa da permissão ROOMS_READ na organização dona da sessão.
flowchart LR A["POST /sessions<br/>(sua esteira)"] --> B["link com joinCode<br/>(o cliente clica)"] B --> C["chamada de vídeo<br/>(navegador, sem conta)"] C --> D["participantes<br/>quem entrou e por quanto tempo"] C --> E["transcrição em pt-BR<br/>separada por quem falou"] C --> F["MP4 no seu S3<br/>por URL assinada"] D --> G["tudo preso à organização<br/>e lido só com ROOMS_READ"] E --> G F --> G
Está em produção desde julho de 2026, no domínio rooms-api.bb.catalisa.app, e foi validado ponta a ponta em produção: vídeo, gravação em MP4 e transcrição em português real, com 17 trechos transcritos corretamente no teste de aceitação. O histórico completo do rollout, com armadilhas de custo e de operação, está no runbook de produção.
| Atributo | Valor |
|---|---|
| Identificador | rooms |
| Categoria | Comunicação |
| Escopo | Tenant (exige organizationId no token em todas as rotas autenticadas) |
| Porta (standalone) | Sem porta padrão atribuída — rooms não está em DEFAULT_MODULE_PORTS. O main.ts cai em 3031 e produção fixa PORT=3000. Ver §13 e §15. |
| Path alias | @rooms |
| Prefixo HTTP | /rooms |
| Status | Produção desde 2026-07 |
| Depende de | PostgreSQL (schema rooms), Redis (eventos), S3, LiveKit, Deepgram (só na transcrição) |
| Processos | Dois: o serviço HTTP rooms e o worker rooms-agent-worker |
O problema
negócioO cenário. Uma operação precisa falar com o cliente por vídeo — validação de identidade, entrevista de crédito, consulta, atendimento de sinistro — e precisa que essa conversa deixe rastro. Não é reunião interna: é atendimento com consequência contratual.
O que trava hoje.
- A plataforma de reunião genérica não conhece o seu cliente. Ela exige conta, ou instalação, ou as duas coisas. Cada fricção na entrada é um atendimento que não acontece.
- A evidência fica fora do sistema. A gravação vai para a nuvem pessoal de quem organizou a chamada. Não há permissão por organização, não há retenção definida, não há como responder a uma auditoria sem pedir favor.
- Transcrever depois é um segundo projeto. Contratar um serviço de transcrição, mover o arquivo, tratar formato de áudio, ligar ao registro certo. Cada etapa é uma integração nova.
- Construir sobre WebRTC parece barato e não é. O SDK entrega a sala. Sessão, código de convidado, papéis, contabilização de minutos, ciclo de vida da gravação e isolamento entre clientes continuam sendo código seu — e é aí que mora o esforço.
- O consentimento vira post-it. Gravar voz e imagem de uma pessoa exige aceite. Sem isso no fluxo, o aceite acaba sendo uma frase que alguém fala no começo da chamada e ninguém registra.
O desenho abaixo é o caminho que a evidência percorre hoje, numa operação que usa plataforma de reunião genérica — e os quatro pontos em que ela se perde.
flowchart TD A["atendimento marcado"] --> B["cliente instala o app<br/>ou cria conta"] B -->|desistência| X1["atendimento que não acontece"] B --> C["chamada acontece"] C --> D["gravação vai para a nuvem pessoal<br/>de quem organizou"] D --> X2["sem permissão por organização<br/>sem retenção definida"] C --> E["transcrever é outro projeto:<br/>serviço novo, mover arquivo, ligar ao registro"] E --> X3["integração adicional para cada etapa"] C --> F["aceite de gravação foi uma frase falada"] F --> X4["nada registrado — problema de LGPD<br/>que só aparece quando alguém pergunta"]
O custo de não resolver. O custo visível é o time de engenharia que passa um trimestre construindo a camada de sessão em cima de um SDK. O custo invisível é maior: gravação de voz e imagem é dado pessoal, e voz é biometria comportamental. Uma operação que grava sem aceite registrado e sem controle de acesso não tem um problema de produto — tem um problema de LGPD que só aparece quando alguém pergunta.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| Cliente instala aplicativo ou cria conta para entrar | Cliente abre um link com código curto, digita o nome e aceita a gravação |
| Gravação na nuvem pessoal de quem organizou | Gravação no seu S3, ligada à sessão, entregue por URL assinada com expiração |
| Transcrição é um projeto separado, depois da chamada | Transcrição sai durante a chamada e fica gravada por participante e por tempo |
| "Quem podia ver essa chamada?" é uma investigação | É ROOMS_READ na organização dona da sessão — sessão de outro tenant responde 404 |
| Aceite de gravação é uma frase falada no começo | lgpdAccepted: true é obrigatório no schema e a data fica no registro do participante |
Entrada sem atrito, do lado do cliente. O convidado usa POST /join/:joinCode — rota pública, sem token, protegida por limite de 20 tentativas por minuto por IP. Ele recebe o token da sala e entra pelo navegador.
Evidência com dono. Toda sessão pertence a uma organização. A transcrição e a gravação passam pela mesma checagem de tenant antes de qualquer leitura, e uma sessão de outro cliente responde 404, não 403 — não dá nem para descobrir que ela existe.
Os dois ganhos acima são o mesmo caminho visto dos dois lados — o convidado entra sem conta, e mesmo assim nada entra sem aceite nem sai sem tenant:
flowchart TD
A["convidado abre o link"] --> B["POST /join/:joinCode<br/>rota pública, sem token"]
B --> C{"20 req/min<br/>por IP?"}
C -->|excedeu| E1["429 com Retry-After"]
C -->|dentro| D{"lgpdAccepted<br/>exatamente true?"}
D -->|não| E2["400 — reprovado na validação<br/>antes de emitir qualquer token"]
D -->|sim| F{"código válido?"}
F -->|sessão encerrada| E3["410 SESSION_ENDED"]
F -->|expirado| E4["410 JOIN_CODE_EXPIRED"]
F -->|válido| G["grava lgpdAcceptedAt no participante"]
G --> H["emite token de sala + serverUrl"]
H --> I["leitura depois:<br/>transcript e recording só com ROOMS_READ<br/>na organização dona — senão 404"]Transcrição como dado, não como legenda. Cada trecho finalizado vira uma linha em RoomUtterance com texto, idioma, papel (host ou guest), identidade do participante e marcação de tempo em milissegundos. Dá para buscar, resumir e usar em regra de decisão.
Contabilização automática de minutos. O participantMinutes da sessão é acumulado a partir dos eventos de entrada e saída do LiveKit, inclusive fechando quem ficou pendurado quando a sala terminou.
Casos de uso reais
negócioCaso 1 — Entrevista de crédito com evidência do que foi combinado Cenário ilustrativo
Financeira de crédito com análise assistida: propostas acima de um valor passam por uma conversa de 10 a 15 minutos com o analista.
A conversa acontecia por telefone e virava um campo de observação digitado depois, de memória. Quando o cliente contestava a condição contratada, não havia o que mostrar.
A esteira cria a sessão com recordingEnabled: true e transcriptionEnabled: true, e envia ao cliente o link com o joinCode. O analista pega o token pelo POST /sessions/:id/tokens/host. Ao encerrar com POST /sessions/:id/end, a proposta guarda o sessionId. A transcrição fica disponível em GET /sessions/:id/transcript e o MP4 em GET /sessions/:id/recording, por URL assinada.
A observação digitada de memória vira transcrição literal com marcação de tempo, e a contestação passa a ter resposta. O aceite de gravação fica registrado em RoomParticipant.lgpdAcceptedAt, com data e hora, para o participante que entrou pelo link.
sequenceDiagram participant E as "Esteira de crédito" participant R as "Rooms" participant A as "Analista" participant C as "Cliente" E->>R: "POST /sessions (recording + transcription)" R-->>E: "joinCode e roomName" E->>C: "link com o joinCode" A->>R: "POST /sessions/:id/tokens/host" C->>R: "POST /join/:joinCode com lgpdAccepted" Note over A,C: "conversa de 10 a 15 minutos" A->>R: "POST /sessions/:id/end" E->>E: "guarda o sessionId na proposta" E->>R: "GET /sessions/:id/transcript" R-->>E: "trechos com startMs e endMs" E->>R: "GET /sessions/:id/recording" R-->>E: "URL assinada do MP4"
Caso 2 — Validação de identidade sem aplicativo Cenário ilustrativo
Operação que precisa ver o documento e o rosto do cliente antes de liberar uma conta.
A alternativa era um aplicativo de reunião corporativo: o cliente precisava instalar, criar conta, aceitar convite. A taxa de desistência na etapa de instalação era o maior buraco do funil.
POST /sessions com joinCodeTtlHours curto — o código vale só a janela do atendimento. O cliente recebe o link, digita o nome, marca o aceite e entra pelo navegador. O operador entra pelo token de host.
Uma etapa a menos no funil, e um código que expira sozinho: passado o prazo, POST /join/:joinCode responde 410 com JOIN_CODE_EXPIRED, em vez de virar link permanente circulando por aí.
flowchart LR
A["POST /sessions com joinCodeTtlHours curto"] --> B["link enviado ao cliente"]
B --> C{"dentro da janela<br/>do atendimento?"}
C -->|sim| D["digita o nome, marca o aceite<br/>e entra pelo navegador"]
D --> E["operador entra pelo token de host<br/>vê documento e rosto"]
E --> F["conta liberada"]
C -->|não| G["410 JOIN_CODE_EXPIRED<br/>o link morre sozinho"]Caso 3 — A transcrição alimenta a etapa seguinte da esteira Cenário ilustrativo
Atendimento por vídeo em que o resultado da conversa precisa virar campo estruturado num sistema.
Depois da chamada, alguém ouvia a gravação e preenchia formulário. Levava mais tempo que a própria chamada.
Cada trecho finalizado publica o evento rooms.v1.utterance.finalized no barramento, e o fim da sessão publica rooms.v1.session.ended. Um consumidor junta o transcript e chama o AI Engine para extrair os campos, ou o Decision Platform para aplicar a regra.
O preenchimento manual sai do caminho crítico. Vale registrar o que foi medido de verdade em produção: após o cadastro do webhook do LiveKit, o fluxo automático disparou cerca de 12 segundos depois do encerramento da sessão (runbook §3).
flowchart LR A["chamada em andamento"] -->|"rooms.v1.utterance.finalized<br/>(por trecho)"| B["barramento de eventos"] C["POST /end"] -->|"rooms.v1.session.ended"| B B --> D["seu consumidor<br/>junta o transcript"] D --> E["AI Engine<br/>extrai os campos"] D --> F["Decision Platform<br/>aplica a regra"] E --> G["campo estruturado no sistema<br/>~12s após o encerramento"] F --> G
Caso 4 — O mercado de vídeo virou infraestrutura, e a camada de negócio ficou vaga Referência de mercado
Os quatro fornecedores mais citados vendem minuto de mídia: o LiveKit publica plano gratuito com 5.000 minutos de WebRTC e 1.000 minutos de agente (livekit.com/pricing); o Daily cobra de US$ 0,0015 a US$ 0,004 por minuto de participante (daily.co/pricing/video-sdk); o 100ms cobra US$ 0,004 por minuto por participante (100ms.live/pricing); a Agora parte de US$ 0,59 por 1.000 minutos (agora.io/pricing).
Todos entregam a sala. Nenhum entrega a sessão de negócio: o código de convidado, o aceite registrado, a permissão por organização, a transcrição persistida e ligada ao registro certo.
O Rooms roda sobre o LiveKit — usamos o servidor Apache 2.0 e o LiveKit Cloud como provedor — e entrega a camada que falta. É uma escolha explícita: não competimos em SFU, competimos na sala que já sabe de quem ela é.
Quem quer o menor preço por minuto compra direto do fornecedor de infraestrutura e escreve a camada. Quem quer a chamada dentro da esteira, com evidência e permissão, usa o Rooms.
flowchart LR
subgraph V["o que o mercado vende: minuto de mídia"]
L["LiveKit"]
D["Daily"]
M["100ms"]
A["Agora"]
end
subgraph F["o que falta: a sessão de negócio"]
F1["código de convidado"]
F2["aceite registrado"]
F3["permissão por organização"]
F4["transcrição persistida e ligada ao registro"]
end
V --> S["sala pronta"]
S --> F
F --> R["Catalisa Rooms<br/>roda sobre o LiveKit, não compete com ele"]Mercado e diferenciais
negócioPanorama. O mercado de vídeo em tempo real se estabilizou em torno de um modelo: SDK mais nuvem, cobrança por minuto de participante, com gravação e transcrição como adicionais medidos. LiveKit, Daily, 100ms e Agora vendem essencialmente o mesmo formato, com diferenças de preço, presença geográfica e maturidade de SDK. A parte open source também amadureceu — o servidor LiveKit é Apache 2.0 (github.com/livekit/livekit), o que torna a fuga do fornecedor de nuvem uma opção real.
O que nenhum deles resolve é a distância entre "tem uma sala" e "tem um atendimento registrado". Essa distância são semanas de trabalho: modelo de sessão, código de convidado com expiração, papéis, aceite de gravação, contabilização de minutos, ciclo de vida do egress, isolamento entre clientes. O Rooms é exatamente essa camada, e por isso ele usa o LiveKit em vez de concorrer com ele.
| Critério | Catalisa Rooms | LiveKit Cloud | Daily.co | 100ms | Agora |
|---|---|---|---|---|---|
| Modelo de preço | Em definição (§6) | Build grátis; Ship US$ 50/mês; Scale US$ 500/mês | 10 mil min/mês grátis; US$ 0,0015–0,004/min | 10 mil min/mês grátis; US$ 0,004/min | 10 mil min/mês grátis; US$ 0,59/mil min |
| Sessão de negócio com dono | Nativa, por organização | Você constrói | Você constrói | Você constrói | Você constrói |
| Link de convidado sem conta | Nativo, com expiração | Você constrói sobre o token | Você constrói | Você constrói | Você constrói |
| Aceite de gravação registrado | Obrigatório no schema, com data | Você constrói | Você constrói | Você constrói | Você constrói |
| Transcrição persistida por falante | Sim, RoomUtterance | Via Agents, você persiste | Oferece, você persiste | Oferece, você persiste | Oferece, você persiste |
| Gravação no seu bucket | Sim, S3 seu, URL assinada | Sim | Sim | Sim | Sim |
| Permissão por organização | Nativa, token do IAM | Não se aplica | Não se aplica | Não se aplica | Não se aplica |
| Agente de IA em sala | Só transcrição (§15) | Framework Agents completo | Integra Pipecat | Parcial | Parcial |
| Você opera a infraestrutura | Não | Não (ou sim, se auto-hospedar) | Não | Não | Não |
| Sala de centenas de espectadores | Não (§15) | Sim | Sim | Sim | Sim |
Nossos diferenciais
- A sala já sabe de quem ela é.
RoomSession.organizationIdé preenchido a partir do token e toda leitura passa porgetSession, que devolve404quando a sessão é de outro tenant. Um SDK de vídeo não tem como oferecer isso: ele não sabe o que é uma organização no seu produto. - O convidado entra sem conta e o aceite fica gravado.
joinByCodeSchemaexigelgpdAccepted: true— literalmentez.literal(true), não um booleano qualquer. Sem aceite, a requisição é reprovada na validação, antes de qualquer token de sala ser emitido. É difícil de copiar porque exige que o consentimento esteja no caminho crítico, não num aviso na tela. - A transcrição nasce estruturada e sai como evento. Cada trecho finalizado é uma linha com participante, papel, idioma e marcação de tempo, e publica
rooms.v1.utterance.finalizedno mesmo barramento dos outros building blocks. A chamada deixa de ser mídia e vira dado que a esteira consome. - A gravação não fica órfã. Um webhook perdido deixaria o registro preso em
activepara sempre, com o MP4 no S3 e inalcançável pela API. Na leitura, o Rooms consulta o provedor e reconcilia o estado terminal. Isso veio de um problema real: havia seis registros nesse estado em staging, com zero egress em andamento no LiveKit (runbook §9).
Quando escolher o concorrente
A árvore abaixo é a resposta curta; o parágrafo em seguida é a mesma resposta com o porquê de cada ramo.
flowchart TD
A["o que você precisa?"] --> B{"centenas de participantes,<br/>transmissão ou SDK nativo do fornecedor?"}
B -->|sim| C["Daily, 100ms ou Agora<br/>o Rooms nasce com teto de 4 e não tem cliente próprio"]
B -->|não| D{"vídeo é o núcleo<br/>do seu negócio?"}
D -->|sim| E["LiveKit Cloud direto<br/>controle total, servidor Apache 2.0"]
D -->|não| F{"quer agente de IA que<br/>conversa em tempo real?"}
F -->|sim| G["LiveKit Agents<br/>o nosso agente hoje só transcreve"]
F -->|não| H["Catalisa Rooms<br/>o vídeo é uma etapa do processo<br/>e o que importa é o registro que sobra"]Se você precisa de uma sala com centenas de participantes, de transmissão para audiência, de layouts sofisticados de palco ou de SDK nativo para iOS e Android mantido pelo fornecedor, compre Daily, 100ms ou Agora — o Rooms hoje nasce com teto de 4 participantes por sala e não tem cliente próprio, você monta a interface com o SDK do LiveKit. Se o seu produto é vídeo e o vídeo é o núcleo do negócio, vá direto ao LiveKit Cloud: você terá controle total, o servidor é Apache 2.0 e não haverá camada intermediária. E se o que você quer é um agente de IA que conversa com o participante em tempo real, o framework LiveKit Agents faz isso e o nosso agente hoje só transcreve (§15). O Rooms ganha quando o vídeo é uma etapa de um processo que já vive na plataforma — e o que importa é o registro que sobra depois.
Modelo de cobrança e ROI
negócioUnidade de cobrança. Precificação em definição. O Rooms já acumula participantMinutes por sessão, o que é a base natural de medição, mas ainda não há emissão de uso para o Billing (§15).
O que dispara custo. Três drivers, e eles não pesam igual:
| Driver | Peso | Por quê |
|---|---|---|
| Minutos de gravação transcodificada (egress) | Alto | É a franquia mais apertada do provedor por uma ordem de grandeza |
| Minutos de agente de transcrição | Médio | Consome franquia de agente no LiveKit e minutos de STT na Deepgram |
| Minutos de participante em sala | Baixo | A franquia é ampla em todos os planos |
O que a franquia mostrou em staging
Vale repetir o que foi medido em staging entre 20 e 28 de julho de 2026 (runbook §9). Os três drivers consumiram franquia em escalas completamente diferentes:
| Driver | Consumo medido | Franquia do plano | % da franquia |
|---|---|---|---|
| Minutos de participante | 58 min | 5.000 min | 1,2% |
| Minutos de transcrição (agente) | ~22 min | 1.000 min | ~2,2% |
| Minutos de egress (gravação) | 31,6 min | 60 min | ~53% |
Mais da metade da franquia de egress foi consumida por testes casuais, enquanto vídeo e transcrição mal arranharam as suas. O gargalo é a gravação, com folga.
Comparação de custo
Cenário: 500 atendimentos por mês, 12 minutos cada, 2 participantes, com transcrição ligada e gravação em metade deles. São 12.000 minutos de participante, 6.000 minutos de sala transcrita e 3.000 minutos de gravação. Preços consultados em 2026-08-16.
| Catalisa Rooms | LiveKit Cloud direto | Daily.co | 100ms | |
|---|---|---|---|---|
| Conferência | Em definição | Plano Ship (US$ 50/mês) inclui 150 mil min de WebRTC | 10 mil min grátis, depois US$ 0,0015–0,004/min | 10 mil min grátis, depois US$ 0,004/min |
| Transcrição | Em definição | 5 mil min de agente inclusos no Ship, depois US$ 0,01/min | US$ 0,0059/min em tempo real | 300 min grátis, depois US$ 0,004/min pós-chamada |
| Gravação | Em definição | Egress medido à parte | US$ 0,01349/min | 300 min grátis, depois US$ 0,0135/min |
| Camada de sessão, convidado, aceite e permissão | Inclusa | Você constrói | Você constrói | Você constrói |
| Transcrição persistida e ligada ao registro | Inclusa | Você constrói | Você constrói | Você constrói |
Estimativa para orientar conversa, não proposta comercial. As tabelas dos fornecedores mudam e há descontos por volume em todos eles — consulte na data da sua análise. O custo de engenharia da coluna "você constrói" é estimativa interna.
ROI
ROI. A conta não fecha no minuto: comprar minuto direto do fornecedor de infraestrutura é sempre mais barato que comprar de qualquer intermediário. A conta fecha na camada.
Sessão com dono, código de convidado com expiração, aceite registrado, papéis, contabilização de minutos, ciclo de vida do egress com reconciliação e isolamento entre clientes são o que o Rooms entrega pronto — e é a parte que, construída do zero sobre um SDK, é medida em trimestre, não em sprint.
flowchart LR A["minuto de mídia"] -->|"mais barato direto no fornecedor"| B["o ROI não está aqui"] C["camada de sessão:<br/>dono, convidado, aceite, papéis,<br/>minutos, egress reconciliado, isolamento"] -->|"do zero sobre um SDK:<br/>trimestre, não sprint"| D["o ROI está aqui"] E["armadilhas já pagas<br/>ex.: emptyTimeout de 600s"] --> D
Atenção. Some ao cálculo o custo de descobrir as armadilhas: o emptyTimeout de 600 segundos, sozinho, fazia cada teste abandonado pagar 10 minutos de franquia, e isso só apareceu quando os dados de staging mostraram várias sessões com duração de exatos 10,3 minutos.
Arquitetura
O Rooms são dois processos, e isso não é detalhe de implantação — é a decisão arquitetural central.
As duas peças e o que cada uma fala
flowchart TD
H["navegador do host<br/>POST /sessions<br/>POST /sessions/:id/tokens/host"]
G["navegador do convidado<br/>POST /join/:joinCode (público)"]
subgraph S["serviço rooms — imagem monolito, Bun, basePath('/rooms')"]
R1["/api/v1/sessions · sessionRouter<br/>ROOMS_READ · ROOMS_WRITE"]
R2["/api/v1/join · joinRouter<br/>público, 20/min por IP"]
R3["/livekit/webhooks · livekitWebhook<br/>assinatura do LiveKit"]
R4["RoomSessionService · RoomsMediaService · RoomsWebhookService"]
end
H --> R1
G --> R2
PG[("PostgreSQL<br/>schema rooms")]
LK["LiveKit Cloud<br/>sala · token · egress · agent"]
RD[("Redis stream<br/>rooms.v1.*")]
S -->|Prisma| PG
S -->|"SDK server"| LK
S -->|EventPublisher| RD
subgraph W["processo rooms-agent-worker — imagem PRÓPRIA, Node 22"]
W1["@livekit/agents + @livekit/rtc-node<br/>entra na sala, assina só ÁUDIO,<br/>empurra frames 16kHz mono"]
W2["Deepgram STT (nova-3)"]
W3["legenda ao vivo no stream lk.transcription"]
W4["RoomUtterance no Postgres<br/>+ rooms.v1.utterance.finalized"]
W1 --> W2 --> W3
W2 --> W4
end
LK -->|"dispatch do agente"| W1
W1 -.->|"mídia (WebRTC)"| LK
W4 --> PG
W4 --> RD
LK -->|"Egress escreve MP4 direto"| S3[("seu bucket S3")]
S3 -->|"GET /sessions/:id/recording<br/>devolve URL assinada"| SCaminho de uma sessão, do POST à gravação
O diagrama acima mostra as peças; este mostra a ordem em que elas se falam numa chamada real, com transcrição e gravação ligadas.
sequenceDiagram
autonumber
participant App as "sua aplicação"
participant Rooms as "serviço rooms"
participant DB as "PostgreSQL"
participant LK as "LiveKit"
participant Guest as "convidado"
participant Scribe as "rooms-agent-worker"
participant DG as "Deepgram"
participant S3 as "seu bucket S3"
App->>Rooms: "POST /sessions"
Rooms->>DB: "cria a linha (status scheduled)"
Rooms->>LK: "createRoom org_<org>_<sessionId>"
Rooms-->>App: "201 com joinCode e roomName"
Guest->>Rooms: "POST /join/:joinCode (lgpdAccepted true)"
Rooms->>DB: "grava participante e lgpdAcceptedAt"
Rooms->>DB: "scheduled to live no primeiro join"
Rooms-->>Guest: "token de sala e serverUrl"
Guest->>LK: "conecta pelo SDK cliente"
LK->>Rooms: "webhook room_started"
Rooms->>LK: "dispatchAgent (se transcriptionEnabled)"
Rooms->>LK: "startRecording egress (se recordingEnabled)"
Rooms->>DB: "RoomRecording status active"
LK->>Scribe: "job despachado ao agente"
Scribe->>LK: "entra na sala, AUDIO_ONLY"
loop "a cada trecho de fala"
Scribe->>DG: "frames 16kHz mono"
DG-->>Scribe: "texto do trecho"
Scribe->>LK: "legenda ao vivo em lk.transcription"
Scribe->>DB: "RoomUtterance"
Scribe-->>App: "rooms.v1.utterance.finalized"
end
App->>Rooms: "POST /sessions/:id/end"
Rooms->>LK: "deleteRoom"
Rooms->>DB: "status ended e endedAt"
LK->>Rooms: "webhook room_finished"
Rooms->>DB: "fecha pendurados e soma participantMinutes"
Rooms-->>App: "rooms.v1.session.ended"
LK->>S3: "grava o MP4"
LK->>Rooms: "webhook egress_ended"
Rooms->>DB: "RoomRecording completed com bucketKey"
Rooms-->>App: "rooms.v1.recording.ready"
App->>Rooms: "GET /sessions/:id/recording"
Rooms-->>App: "URL assinada"Atenção. Sem o webhook do LiveKit cadastrado no projeto, os passos disparados por room_started, room_finished e egress_ended simplesmente não acontecem — e o vídeo continua funcionando. É a falha silenciosa mais comum; veja a receita de diagnóstico na §11.
Decisões não óbvias
- O worker de agente roda numa imagem separada, em Node 22. Todos os outros building blocks compartilham a imagem monolito e se distinguem pelo
command. Orooms-agent-workernão pode: o@livekit/rtc-nodetem bindings nativos que não rodam sob Bun. O preço é uma imagem a mais para construir e implantar; a alternativa era não ter transcrição. - O agente entra na sala como participante e assina só áudio.
AutoSubscribe.AUDIO_ONLY. Ele não precisa de vídeo para transcrever, e vídeo custaria banda e franquia. O efeito colateral é conhecido: enquanto o agente estiver na sala, ela não fecha sozinha — quem encerra é oPOST /end(runbook §3). - O nome do agente é resolvido em tempo de execução, por ambiente. Um projeto LiveKit compartilhado coloca todos os workers registrados com o mesmo nome no mesmo pool, e o LiveKit despacha a sala para qualquer um. O worker do ambiente errado grava
RoomUtterancecom umsessionIdque não existe no banco dele, viola a chave estrangeira e a transcrição não sai — nos dois ambientes. Por issoROOMS_SCRIBE_AGENT_NAMEexiste, e por isso o serviçoroomse orooms-agent-workerprecisam ler o mesmo valor. - A legenda ao vivo é atribuída ao falante, não ao agente. O
senderIdentitydo stream de texto é a identidade de quem falou. Se fosse o agente, toda legenda apareceria rotulada como o bot. - O webhook do LiveKit valida contra o corpo cru. A rota lê
c.req.text()antes de qualquer parsing, porque oWebhookReceiverrecalcula a assinatura sobre os bytes exatos recebidos. Reserializar o JSON quebraria a verificação. - Nenhum handler de webhook lança exceção. O LiveKit reentrega em resposta diferente de 2xx, e um handler que estoura viraria tempestade de reentrega. As falhas são registradas em log e a rota responde
200. Pelo mesmo motivo, falha no despacho do agente ou no início da gravação é tratada como não fatal: transcrição e gravação são aditivas, e nenhuma delas vale derrubar a chamada de vídeo. - A gravação é reconciliada na leitura, não no encerramento. Só o webhook
egress_endedtira um registro deactive, e webhook é entrega de melhor esforço. Reconciliar noPOST /endcorreria com o egress ainda emEGRESS_ENDING; reconciliar na leitura acontece muito depois, sem corrida. Falha na consulta ao provedor é engolida de propósito — o pior caso volta a ser o404de antes. - O limite de entrada por código é por processo, em memória.
createInMemoryRateLimitercom 20 tentativas por minuto por IP. Isso pressupõe uma réplica do serviço, que é o cenário atual. Com N réplicas, o teto efetivo vira 20×N. Antes de escalar, isso precisa migrar para o limitador compartilhado em Redis (§15). - O nome da sala carrega a organização.
org_<organizationId>_<sessionId>. Assim um evento de webhook que traz só o nome da sala já é rastreável até o tenant, sem consulta extra.
Monolito vs. standalone
Em monolito, o roomsApp é montado junto com os demais e os serviços vêm do container TypeDI. Em standalone — o modo de produção — o rooms sobe como serviço próprio e o rooms-agent-worker sobe como segundo serviço, com imagem própria. O worker não fala HTTP com o rooms: ele escreve direto no Postgres e publica no Redis. Se você subir só o rooms, tudo funciona menos a transcrição.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Session | Uma chamada. Unidade de tudo: pertence a uma organização, tem código de convidado, participantes, transcrição e gravações. |
| Room name | Nome da sala no LiveKit, no formato org_<organizationId>_<sessionId>. Único. |
| Join code | Código curto e público que o convidado usa para entrar. Único, com prazo de validade próprio. |
| Host | Quem criou a sessão. Entra por POST /sessions/:id/tokens/host, autenticado, com identidade fixa na sessão. |
| Guest | Quem entra pelo código. Identidade fixa em guest — uma reconexão com o mesmo código reaproveita a identidade e o LiveKit derruba a conexão antiga. |
| Scribe | O agente de transcrição. Entra na sala como participante, ouve o áudio e grava os trechos. Não conversa com ninguém. |
| Utterance | Um trecho de fala finalizado, com texto, participante, papel, idioma e marcação de tempo. |
| Egress | O processo do LiveKit que grava a sala e escreve o MP4 no seu S3. |
| participantMinutes | Minutos acumulados na sessão, somados por participante a partir dos eventos de entrada e saída, arredondados para cima. |
Modelo de dados
Schema rooms no PostgreSQL.
| Modelo Prisma | Propósito | Campos-chave |
|---|---|---|
RoomSession | A chamada | organizationId, status, roomName (único), joinCode (único), joinCodeExpiresAt, hostIdentity, recordingEnabled, transcriptionEnabled, language, participantMinutes, startedAt, endedAt |
RoomParticipant | Quem entrou | Único (sessionId, identity); role, displayName, lgpdAcceptedAt, joinedAt, leftAt |
RoomUtterance | Trecho transcrito | participantIdentity, role, text, language, startMs, endMs, isFinal |
RoomRecording | Uma gravação | egressId (único), status, bucketKey, durationMs, sizeBytes |
RoomProviderConfig | Configuração de provedor por organização | provider, url, apiKeyEnc, apiSecretEnc. Existe no schema e não é lido por nenhum código — ver §15 |
Como as cinco tabelas se ligam
Tudo pendura na sessão. Participantes, trechos e gravações caem por cascata quando a RoomSession é apagada — e o RoomProviderConfig fica de fora justamente porque nenhum código o lê hoje.
erDiagram
RoomSession ||--o{ RoomParticipant : "quem entrou"
RoomSession ||--o{ RoomUtterance : "o que foi dito"
RoomSession ||--o{ RoomRecording : "o que foi gravado"
RoomProviderConfig }o..o| RoomSession : "previsto, não usado hoje"
RoomSession {
string organizationId
enum status "scheduled live ended"
string roomName UK
string joinCode UK
datetime joinCodeExpiresAt
boolean recordingEnabled
boolean transcriptionEnabled
int participantMinutes
}
RoomParticipant {
string identity UK "único com sessionId"
string role "host ou guest"
datetime lgpdAcceptedAt
datetime joinedAt
datetime leftAt
}
RoomUtterance {
string participantIdentity
string text
int startMs
int endMs
boolean isFinal
}
RoomRecording {
string egressId UK
string status "active completed failed"
string bucketKey
int durationMs
}Ciclo de vida da sessão
O enum RoomSessionStatus tem exatamente três valores — scheduled, live e ended — e a sessão nasce em scheduled.
stateDiagram-v2 [*] --> scheduled : "POST /sessions" scheduled --> live : "primeiro join OU webhook room_started" live --> ended : "POST /end OU webhook room_finished" scheduled --> ended : "POST /end OU webhook room_finished" scheduled --> scheduled : "join com código expirado → 410 JOIN_CODE_EXPIRED" ended --> ended : "join em sessão encerrada → 410 SESSION_ENDED"
Em
ended, odeleteRoomjá foi chamado no LiveKit e os participantes pendurados são fechados com o mesmoendedAt, somando os minutos.
Ciclo de vida da gravação
A gravação tem máquina própria, com três estados na coluna status do RoomRecording: active, completed e failed.
stateDiagram-v2 [*] --> active : "webhook room_started + recordingEnabled → startRecording (egress)" active --> completed : "webhook egress_ended OU reconciliação na leitura" active --> failed : "webhook egress_ended com status de falha" completed --> [*] : "GET /sessions/:id/recording → URL assinada"
Atenção. Sem bucketKey, mesmo um registro completed responde 404 RECORDING_NOT_READY: não há o que assinar.
Referência da API
Prefixo: /rooms. Em staging, a base é https://rooms-api.bb.stg.catalisa.app; em produção, https://rooms-api.bb.catalisa.app.
Sessões — /rooms/api/v1/sessions
Todas exigem authMiddleware → requirePermission(...) → requireOrganization.
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /rooms/api/v1/sessions | Cria a sessão e a sala no provedor | ROOMS_WRITE |
GET | /rooms/api/v1/sessions/:id | Busca a sessão (escopo de tenant) | ROOMS_READ |
POST | /rooms/api/v1/sessions/:id/tokens/host | Emite o token de host da sessão | ROOMS_WRITE |
POST | /rooms/api/v1/sessions/:id/end | Encerra a sessão e apaga a sala no provedor | ROOMS_WRITE |
GET | /rooms/api/v1/sessions/:id/transcript | Lista os trechos transcritos | ROOMS_READ |
GET | /rooms/api/v1/sessions/:id/recording | URL assinada da última gravação concluída | ROOMS_READ |
Entrada de convidado — /rooms/api/v1/join
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /rooms/api/v1/join/:joinCode | Entrada pública por código, com aceite de LGPD obrigatório | Nenhuma — rota pública, limitada a 20 requisições por minuto por IP |
Webhook do provedor — /rooms/livekit/webhooks
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /rooms/livekit/webhooks | Recebe os eventos do LiveKit | Nenhuma — a confiança vem da assinatura do LiveKit, verificada contra o corpo cru |
Saúde
| Método | Rota | Descrição |
|---|---|---|
GET | /rooms/health | Nome do serviço e versão |
POST /rooms/api/v1/sessions
Cria a sessão no banco, cria a sala no provedor e devolve o registro completo. Se a criação da sala falhar, a linha do banco é removida — não fica sessão sem sala.
Request
{
"title": "Entrevista de crédito — proposta 88213",
"recordingEnabled": true,
"transcriptionEnabled": true,
"language": "pt-BR",
"joinCodeTtlHours": 4,
"metadata": { "propostaId": "88213" }
}{
"title": "Entrevista de crédito — proposta 88213",
"recordingEnabled": true,
"transcriptionEnabled": true,
"language": "pt-BR",
"joinCodeTtlHours": 4,
"metadata": { "propostaId": "88213" }
}| Campo | Tipo | Obrigatório | Padrão | Descrição |
|---|---|---|---|---|
title | string (até 200) | Não | — | Título livre |
recordingEnabled | boolean | Não | false | Liga o egress quando a sala começa. Este é o driver de custo mais caro (§6) |
transcriptionEnabled | boolean | Não | false | Despacha o agente de transcrição quando a sala começa |
language | string | Não | pt-BR | Idioma repassado ao STT |
joinCodeTtlHours | int (1 a 168) | Não | 24 | Validade do código de convidado |
metadata | object | Não | — | Campo livre para amarrar ao seu registro |
Resposta 201 — o registro RoomSession completo, incluindo joinCode, joinCodeExpiresAt, roomName e status: "scheduled".
Erros
| Status | Quando |
|---|---|
400 | Corpo reprovado no schema Zod |
401 | Token ausente ou inválido |
403 | Falta ROOMS_WRITE, ou o token não traz organizationId |
500 | Falha ao criar a sala no provedor (a linha do banco é desfeita) |
POST /rooms/api/v1/join/:joinCode
Rota pública. É por onde o convidado entra.
Request
{
"displayName": "Maria Souza",
"lgpdAccepted": true
}{
"displayName": "Maria Souza",
"lgpdAccepted": true
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
displayName | string (1 a 120) | Sim | Nome exibido na sala |
lgpdAccepted | true | Sim | Precisa ser exatamente true. O schema usa z.literal(true) — false é reprovado na validação, e a data do aceite é gravada em RoomParticipant.lgpdAcceptedAt |
Resposta 200
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Entrevista de crédito", "status": "live" }
}{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Entrevista de crédito", "status": "live" }
}Use token e serverUrl com o SDK cliente do LiveKit. O token vale 2 horas.
Erros
| Status | Código em details | Quando |
|---|---|---|
400 | — | displayName vazio ou lgpdAccepted diferente de true |
404 | — | Código inexistente |
410 | SESSION_ENDED | A sessão já foi encerrada |
410 | JOIN_CODE_EXPIRED | O código passou de joinCodeExpiresAt |
429 | — | Mais de 20 tentativas por minuto do mesmo IP; traz o cabeçalho Retry-After |
Os dois casos de
410chegam comcode: "CONFLICT"no corpo estatusCode: 410— a distinção precisa vem dedetails.code. Trate pordetails.code, não pelocodede topo.
GET /rooms/api/v1/sessions/:id/transcript
Resposta 200
{
"utterances": [
{
"id": "clx...",
"sessionId": "clx...",
"participantIdentity": "host",
"role": "host",
"text": "Boa tarde, vamos confirmar os dados da proposta.",
"language": "pt-BR",
"startMs": 1200,
"endMs": 4800,
"isFinal": true,
"createdAt": "2026-08-16T14:02:11.000Z"
}
]
}{
"utterances": [
{
"id": "clx...",
"sessionId": "clx...",
"participantIdentity": "host",
"role": "host",
"text": "Boa tarde, vamos confirmar os dados da proposta.",
"language": "pt-BR",
"startMs": 1200,
"endMs": 4800,
"isFinal": true,
"createdAt": "2026-08-16T14:02:11.000Z"
}
]
}Lista vazia significa uma de três coisas: a sessão foi criada com transcriptionEnabled: false; o webhook do LiveKit não está cadastrado, então o agente nunca foi despachado; ou o worker não está de pé. Veja a receita de diagnóstico na §11.
GET /rooms/api/v1/sessions/:id/recording
Devolve URL assinada da última gravação concluída da sessão. Se não houver nenhuma concluída, o serviço consulta o provedor antes de desistir, para reconciliar gravação presa em active.
Resposta 200
{
"url": "https://s3.bb.catalisa.app/files/rooms/recordings/...?X-Amz-Signature=...",
"status": "completed",
"durationMs": 726000,
"sizeBytes": 184320512,
"expiresIn": 3600
}{
"url": "https://s3.bb.catalisa.app/files/rooms/recordings/...?X-Amz-Signature=...",
"status": "completed",
"durationMs": 726000,
"sizeBytes": 184320512,
"expiresIn": 3600
}Erros
| Status | Quando |
|---|---|
404 RECORDING_NOT_READY | Não há gravação concluída, ou há uma concluída sem bucketKey |
404 | A sessão não existe ou pertence a outra organização |
Início rápido
Do zero à sala com convidado, em staging. Seis passos, um bloco por passo, com o que esperar de volta em cada um.
flowchart LR P1["1. autenticar<br/>no IAM"] --> P2["2. criar a sessão<br/>sem gravação"] P2 --> P3["3. token de host"] P3 --> P4["4. entrar como convidado<br/>pelo caminho público"] P4 --> P5["5. confirmar que o aceite<br/>é obrigatório"] P5 --> P6["6. encerrar — sempre"]
1. Autenticar no IAM
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
BASE=https://rooms-api.bb.stg.catalisa.appTOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
BASE=https://rooms-api.bb.stg.catalisa.appEsperado: $TOKEN preenchido com o JWT. Se sair null, a organização ou a senha está errada — confira antes de seguir.
2. Criar a sessão — sem gravação no primeiro teste
SESSION=$(curl -s -X POST "$BASE/rooms/api/v1/sessions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Teste","transcriptionEnabled":true,"recordingEnabled":false,"joinCodeTtlHours":1}')
SESSION_ID=$(echo "$SESSION" | jq -r '.id')
JOIN_CODE=$(echo "$SESSION" | jq -r '.joinCode')
echo "sessão $SESSION_ID · código $JOIN_CODE"SESSION=$(curl -s -X POST "$BASE/rooms/api/v1/sessions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Teste","transcriptionEnabled":true,"recordingEnabled":false,"joinCodeTtlHours":1}')
SESSION_ID=$(echo "$SESSION" | jq -r '.id')
JOIN_CODE=$(echo "$SESSION" | jq -r '.joinCode')
echo "sessão $SESSION_ID · código $JOIN_CODE"Esperado: 201, com a sessão em scheduled e o código já emitido.
sessão clx7a2b9c0001abcd · código 7K4Q2Msessão clx7a2b9c0001abcd · código 7K4Q2M
recordingEnabled: falsede propósito. Gravação é a franquia mais apertada do provedor (§6) e o padrão do schema já éfalse— quem liga é quem chama.
3. Pegar o token de host
curl -s -X POST "$BASE/rooms/api/v1/sessions/$SESSION_ID/tokens/host" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"displayName":"Analista"}' | jq '{serverUrl, session}'curl -s -X POST "$BASE/rooms/api/v1/sessions/$SESSION_ID/tokens/host" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"displayName":"Analista"}' | jq '{serverUrl, session}'Esperado: serverUrl do projeto LiveKit e a sessão ainda em scheduled — o host não muda o status; quem vira live é o primeiro join.
{
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Teste", "status": "scheduled" }
}{
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Teste", "status": "scheduled" }
}4. Entrar como convidado, pelo caminho público
curl -s -X POST "$BASE/rooms/api/v1/join/$JOIN_CODE" \
-H "Content-Type: application/json" \
-d '{"displayName":"Maria Souza","lgpdAccepted":true}' | jq '{serverUrl, session}'curl -s -X POST "$BASE/rooms/api/v1/join/$JOIN_CODE" \
-H "Content-Type: application/json" \
-d '{"displayName":"Maria Souza","lgpdAccepted":true}' | jq '{serverUrl, session}'Esperado: 200, e agora a sessão aparece como live — este é o join que fez a transição.
{
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Teste", "status": "live" }
}{
"serverUrl": "wss://<projeto>.livekit.cloud",
"session": { "id": "clx...", "title": "Teste", "status": "live" }
}O token e o serverUrl das respostas 3 e 4 vão para o SDK cliente do LiveKit. A partir daí é vídeo.
5. Confirmar que o aceite é obrigatório
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/rooms/api/v1/join/$JOIN_CODE" \
-H "Content-Type: application/json" \
-d '{"displayName":"Maria Souza","lgpdAccepted":false}'curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/rooms/api/v1/join/$JOIN_CODE" \
-H "Content-Type: application/json" \
-d '{"displayName":"Maria Souza","lgpdAccepted":false}'Retorna 400. Não existe caminho em que alguém entre sem o aceite registrado.
6. Encerrar — sempre
curl -s -X POST "$BASE/rooms/api/v1/sessions/$SESSION_ID/end" \
-H "Authorization: Bearer $TOKEN" | jq '{status, endedAt, participantMinutes}'curl -s -X POST "$BASE/rooms/api/v1/sessions/$SESSION_ID/end" \
-H "Authorization: Bearer $TOKEN" | jq '{status, endedAt, participantMinutes}'Esperado: a sessão em ended, com a data de encerramento e os minutos já somados.
{ "status": "ended", "endedAt": "2026-08-16T14:22:03.000Z", "participantMinutes": 2 }{ "status": "ended", "endedAt": "2026-08-16T14:22:03.000Z", "participantMinutes": 2 }Encerre sempre ao terminar o teste. O POST /end apaga a sala no provedor na hora; sem ele, a sala fica viva pelo emptyTimeout inteiro — 600 segundos no padrão — e paga franquia por isso.
Credenciais de staging, conforme AMBIENTES.md. Produção não tem a organização
b0000000-…-0001usada em staging e nos testes ponta a ponta; consulteiam.organizationsno ambiente alvo antes de montar qualquer smoke.Existe um smoke versionado e executável que cobre esse caminho inteiro mais os casos de erro:
bun scripts/rooms-smoke.ts --env=prod, com--mediae--recordingcomo opções deliberadamente não padrão. Veja o runbook §7.
Receitas
Diagnosticar transcript vazio
Objetivo: descobrir por que GET /sessions/:id/transcript volta sem nada.
São quatro causas possíveis, e elas se checam em ordem — o desenho é a mesma sequência de cima para baixo:
flowchart TD
A["transcript vazio"] --> B{"a sessão pediu<br/>transcriptionEnabled?"}
B -->|não| B1["o agente nunca foi despachado<br/>não há como ligar depois — crie outra sessão"]
B -->|sim| C{"webhook do LiveKit<br/>cadastrado no projeto?"}
C -->|não| C1["zero eventos: a sessão não vira live,<br/>o agente não é despachado,<br/>a gravação não começa"]
C -->|sim| D{"worker de pé<br/>e recebeu o job?"}
D -->|não| D1["suba o rooms-agent-worker<br/>e confira o log"]
D -->|sim| E{"ROOMS_SCRIBE_AGENT_NAME<br/>igual nos dois lados?"}
E -->|não| E1["job despachado para um pool sem worker"]
E -->|sim| F["veja as armadilhas de pool compartilhado"]Na ordem, e pare no primeiro que falhar:
1. A sessão pediu transcrição?
curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID" \
-H "Authorization: Bearer $TOKEN" | jq '{transcriptionEnabled, status}'curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID" \
-H "Authorization: Bearer $TOKEN" | jq '{transcriptionEnabled, status}'Esperado: transcriptionEnabled: true. Se for false, o agente nunca foi despachado, e não há como ligar depois — crie outra sessão.
2. O webhook do LiveKit está cadastrado no projeto?
Sem ele o Rooms recebe zero eventos: a sessão não vira live sozinha, o agente nunca é despachado e a gravação nunca começa. O cadastro é só pelo painel do LiveKit Cloud, em Settings → Webhooks, apontando para:
https://<host>/rooms/livekit/webhookshttps://<host>/rooms/livekit/webhooksO vídeo continua funcionando sem isso, o que torna a falha silenciosa.
3. O worker está de pé e recebeu o job?
docker service logs bb-production_rooms-agent-worker --since 5m | grep transcribingdocker service logs bb-production_rooms-agent-worker --since 5m | grep transcribingEsperado, uma linha por faixa de áudio assinada:
scribe: transcribing track TR_... from host (host) @ 16000Hz/1ch model=nova-3 lang=pt-BRscribe: transcribing track TR_... from host (host) @ 16000Hz/1ch model=nova-3 lang=pt-BR4. Os dois lados concordam no nome do agente?
ROOMS_SCRIBE_AGENT_NAME precisa ter o mesmo valor no serviço rooms (que despacha) e no rooms-agent-worker (que se registra). Divergir significa job despachado para um pool sem worker.
Armadilhas. Se dois ambientes compartilham o mesmo projeto LiveKit e o mesmo nome de agente, os workers caem no mesmo pool e o do ambiente errado pega o job. Ele grava RoomUtterance com um sessionId que não existe no banco dele, viola a chave estrangeira e a transcrição não sai — nos dois ambientes, em cerca de metade das reuniões. Nome distinto por ambiente resolve; projeto separado por ambiente continua sendo o recomendado (runbook §2).
Recuperar uma gravação que parece perdida
Objetivo: obter o MP4 quando GET /recording responde RECORDING_NOT_READY.
A própria chamada de leitura já tenta a reconciliação: se não há gravação completed, o serviço consulta o provedor pelo egressId e persiste o estado terminal. Por isso o procedimento é chamar duas vezes — a primeira reconcilia, a segunda devolve a URL.
sequenceDiagram participant U as "você" participant Rooms as "serviço rooms" participant LK as "LiveKit" participant DB as "PostgreSQL" U->>Rooms: "GET /sessions/:id/recording (1ª vez)" Rooms->>DB: "procura gravação completed" DB-->>Rooms: "só uma linha em active" Rooms->>LK: "getRecordingResult(egressId)" LK-->>Rooms: "estado terminal + bucketKey" Rooms->>DB: "persiste completed" Rooms-->>U: "404 RECORDING_NOT_READY" U->>Rooms: "GET /sessions/:id/recording (2ª vez)" Rooms->>DB: "agora há completed com bucketKey" Rooms-->>U: "200 com URL assinada"
1. Primeira chamada — reconcilia
curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
-H "Authorization: Bearer $TOKEN" | jqcurl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
-H "Authorization: Bearer $TOKEN" | jqEsperado: ainda 404 RECORDING_NOT_READY, mas o estado terminal já foi persistido.
2. Segunda chamada — devolve a URL
curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
-H "Authorization: Bearer $TOKEN" | jq '{status, durationMs, expiresIn}'curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
-H "Authorization: Bearer $TOKEN" | jq '{status, durationMs, expiresIn}'Esperado:
{ "status": "completed", "durationMs": 726000, "expiresIn": 3600 }{ "status": "completed", "durationMs": 726000, "expiresIn": 3600 }Armadilhas.
- Se o egress ainda está rodando de verdade, a consulta devolve "em andamento" e nada muda. Espere o fim da chamada.
- Falha na consulta ao provedor é engolida de propósito, para que uma indisponibilidade do LiveKit não derrube a leitura de uma gravação que já esteja
completed. O sintoma é o mesmo404— olhe o log do serviço para distinguir. - Um registro
completedsembucketKeyresponde404igual. Não há o que assinar.
Reduzir o desperdício de franquia
Objetivo: parar de pagar sala vazia.
# no ambiente do serviço rooms
ROOMS_EMPTY_TIMEOUT_SECONDS=90 # staging usa 90; produção fica em 600 de propósito
ROOMS_MAX_PARTICIPANTS=4# no ambiente do serviço rooms
ROOMS_EMPTY_TIMEOUT_SECONDS=90 # staging usa 90; produção fica em 600 de propósito
ROOMS_MAX_PARTICIPANTS=4E, no fluxo da aplicação, sempre POST /sessions/:id/end ao terminar.
Armadilhas.
- Produção fica no padrão de 600 segundos de propósito: é a janela de reconexão de uma reunião real. Baixar é decisão de produto, não de infraestrutura.
- Valor inválido na variável (vazio, negativo, não inteiro) é ignorado e cai no padrão. Isso é deliberado — repassar
NaNao LiveKit deixaria salas órfãs consumindo franquia. - Com transcrição ligada, a sala não fecha sozinha enquanto o agente estiver dentro. O
POST /endé o que encerra.
Consumir os eventos da sala em outro sistema
O Rooms publica quatro eventos públicos no stream iam-events, entregues por assinatura de webhook através do Webhooks Engine:
| Evento | Quando | Carga principal |
|---|---|---|
rooms.v1.session.started | A sala começou | sessionId, roomName, title, startedAt, flags de gravação e transcrição |
rooms.v1.utterance.finalized | Um trecho de fala foi finalizado | utteranceId, participantIdentity, role, text, startMs, endMs |
rooms.v1.session.ended | A sala terminou | participantMinutes e a lista de participantes com entrada e saída |
rooms.v1.recording.ready | O egress terminou | recordingId, egressId, status, bucketKey, durationMs, sizeBytes |
Armadilhas.
- Todo evento carrega
metadata.organizationId. Sem ele o consumidor de webhook descarta o evento — é o que amarra o evento ao tenant. session.endedé emitido uma vez só, guardado por uma marca emmetadata.sessionEndedEmittedAtda própria sessão. Reentrega do webhook do LiveKit não duplica.- A emissão é do tipo dispare-e-esqueça: falha ao publicar é registrada em log e não derruba a chamada de vídeo. Se o seu processo depende do evento, reconcilie por consulta.
Encerrar sessões esquecidas
Sessões abandonadas ficam live até o room_finished chegar.
Atenção. Um detalhe medido em staging: a diferença entre startedAt e endedAt não mede tempo de sala aberta no LiveKit — uma sessão aparentava 3.792 minutos porque ficou live num dia e só teve o room_finished processado dois dias depois. Para consumo real, use RoomParticipant e RoomRecording, nunca a janela da sessão.
| Você quer medir | Use | Não use |
|---|---|---|
| Consumo real de sala | RoomParticipant (joinedAt/leftAt) e participantMinutes | endedAt - startedAt |
| Consumo de gravação | RoomRecording.durationMs | endedAt - startedAt |
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token; o Rooms exige ROOMS_READ/ROOMS_WRITE e organizationId | Sim |
| File Storage | O Rooms usa o mesmo S3Service para assinar a URL da gravação; o egress do LiveKit escreve o MP4 direto no bucket | Sim, para gravação |
| Webhooks Engine | Entrega os quatro eventos rooms.v1.* ao sistema do cliente | Não |
| AI Engine | Resume ou extrai campos a partir do transcript já pronto | Não |
| Decision Platform | Usa o resultado da conversa como entrada de regra | Não |
| Calendar | Agenda o atendimento; o Rooms executa a chamada | Não |
| Audit Trail | Registra quem acessou gravação e transcrição | Não |
O Rooms não usa nenhum MODULE_*_URL além do IAM — não há chamada de fachada para outros building blocks.
flowchart TD CAL["Calendar"] -->|agenda| APP["sua aplicação"] APP -->|"cria sessão"| ROOMS["Rooms"] IAM["IAM"] -->|"token com organizationId<br/>ROOMS_READ / ROOMS_WRITE"| ROOMS ROOMS -->|MP4| FS["File Storage<br/>(seu S3)"] ROOMS -->|"rooms.v1.utterance.finalized<br/>rooms.v1.session.ended"| AI["AI Engine<br/>resumo e extração"] FS -->|transcript e gravação| AI AI --> DP["Decision Platform"] DP --> WH["Webhooks Engine"] WH --> CLI["sistema do cliente"] ROOMS -.->|"quem leu gravação e transcrição"| AUD["Audit Trail"]
Este é o argumento comercial em uma imagem: a videochamada não é um produto isolado, é uma etapa. O que entra pela chamada sai como dado estruturado para o resto da esteira, com a mesma identidade e a mesma organização do começo ao fim.
Configuração e operação
Variáveis de ambiente — serviço rooms
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
ROOMS_PROVIDER | livekit ou mock. Ausente ou mock usa o provedor falso; qualquer outro valor derruba o boot. | Não | mock |
ROOMS_LIVEKIT_URL | URL do projeto LiveKit (wss://...). Convertida para https:// nas chamadas de API. | Com livekit | — |
ROOMS_LIVEKIT_API_KEY | Chave da API do projeto | Com livekit | — |
ROOMS_LIVEKIT_API_SECRET | Segredo da API do projeto | Com livekit | — |
ROOMS_EMPTY_TIMEOUT_SECONDS | Segundos que a sala fica viva depois que o último participante sai. Valor inválido é ignorado. | Não | 600 |
ROOMS_MAX_PARTICIPANTS | Teto de participantes por sala | Não | 4 |
ROOMS_SCRIBE_AGENT_NAME | Nome sob o qual o agente é despachado. Precisa bater com o do worker. | Não | rooms-scribe |
PORT | Porta no modo standalone | Não | 3031 no main.ts; produção fixa 3000 |
MODULE_IAM_URL | Endereço do IAM em standalone | Sim | — |
DATABASE_URL, REDIS_URL | Postgres e Redis | Sim | — |
S3_* | Bucket, credenciais, região e endpoint público. O endpoint público é o que vai para o egress do LiveKit — precisa ser resolvível de fora. | Para gravação | ver src/shared/config/env.ts |
Variáveis de ambiente — processo rooms-agent-worker
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
ROOMS_LIVEKIT_URL / _API_KEY / _API_SECRET | Registro do worker no projeto LiveKit | Sim | — |
ROOMS_SCRIBE_AGENT_NAME | Mesmo valor do serviço rooms | Não | rooms-scribe |
DEEPGRAM_API_KEY | Chave da Deepgram | Sim | — |
DEEPGRAM_STT_MODEL | Modelo de STT | Não | nova-3 |
DEEPGRAM_STT_LANGUAGE | Idioma; vazio usa o da sessão | Não | idioma da sessão, ou pt-BR |
DATABASE_URL | O worker escreve RoomUtterance direto no Postgres | Sim | — |
REDIS_URL | Publicação de rooms.v1.utterance.finalized | Sim | — |
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
PostgreSQL, schema rooms | Sessões, participantes, trechos, gravações |
| Redis | Publicação dos eventos rooms.v1.* |
| S3 (ou MinIO) | Destino do MP4 e origem da URL assinada |
| LiveKit | Sala, token, despacho de agente, egress, webhook |
| Deepgram | Reconhecimento de fala, só no worker |
| Saída para a internet | Os dois processos precisam. O Rooms não precisa de extra_hosts: ele só assina a URL do S3, nunca transfere bytes por ela |
Limites
| Limite | Valor | Onde |
|---|---|---|
| Participantes por sala | 4 (configurável) | ROOMS_MAX_PARTICIPANTS |
| Sala viva após esvaziar | 600s (configurável) | ROOMS_EMPTY_TIMEOUT_SECONDS |
| Validade do token de sala | 2 horas | Fixo no provedor |
| Validade do código de convidado | 1 a 168 horas, padrão 24 | joinCodeTtlHours na criação |
| Entrada por código | 20 por minuto por IP, por processo | Limitador em memória |
| Convidados simultâneos por código | 1 | A identidade guest é fixa; a segunda conexão derruba a primeira |
| Título da sessão | 200 caracteres | Schema |
| Nome exibido | 120 caracteres | Schema |
| Taxa de amostragem do STT | 16 kHz, mono | Fixo — o plugin não faz reamostragem |
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod (inclui lgpdAccepted diferente de true) | Confira o schema na §9 |
401 | UNAUTHORIZED | Token ausente ou inválido; ou assinatura do webhook inválida | Renove o token; confira a chave do projeto LiveKit |
403 | FORBIDDEN | Falta ROOMS_READ/ROOMS_WRITE, ou falta organizationId | Autentique informando a organização |
404 | NOT_FOUND | Sessão inexistente, ou de outra organização, ou código de entrada desconhecido | Confira o identificador e o tenant |
404 | RECORDING_NOT_READY | Sem gravação concluída, ou concluída sem bucketKey | Chame de novo para reconciliar; veja a receita na §11 |
410 | CONFLICT + details.code: SESSION_ENDED | Entrada em sessão encerrada | Crie outra sessão |
410 | CONFLICT + details.code: JOIN_CODE_EXPIRED | Código expirado | Crie outra sessão |
429 | — | Limite de entrada por IP | Aplique recuo; leia Retry-After |
500 | INTERNAL | Falha no provedor ou no banco | Veja o log do serviço |
Observabilidade
GET /rooms/healthdevolve nome e versão. Não testa LiveKit nem Deepgram.- Todo handler de webhook registra em log o motivo de ignorar um evento — sala desconhecida, campo faltando, tipo não tratado. A rota sempre responde
200. - O worker registra a linha
scribe: transcribing track ...por faixa de áudio assinada, com taxa, modelo e idioma. É a confirmação mais rápida de que a transcrição está viva. - Falha de despacho do agente e de início de gravação aparece como
errorno log do serviçorooms, marcada como não fatal. - Para auditar consumo no provedor:
ListRoomsmostra salas abertas e há quanto tempo;ListEgressmostra gravações e permite achar as que não estão em estado terminal. O consumo agregado só aparece no painel do LiveKit — a API não expõe.
Operação em produção
O runbook de produção cobre o que não cabe aqui: procedimento de implantação cirúrgica, migrações, DNS e TLS com router isolado no Traefik, o smoke de mídia ponta a ponta com áudio sintético, o mapa de franquia e as armadilhas já pagas. Leia antes de mexer em produção.
Segurança e compliance
O ponto central: gravação e transcrição são dado pessoal, e voz é dado biométrico comportamental. Uma sessão com recordingEnabled: true produz vídeo e áudio de uma pessoa identificada. Uma sessão com transcriptionEnabled: true produz o conteúdo literal do que ela disse. Os dois são tratamento de dado pessoal sob a LGPD, e a base legal precisa existir antes de a chamada começar — não depois.
| Flag na sessão | O que ela produz | Natureza do dado |
|---|---|---|
recordingEnabled: true | Vídeo e áudio de uma pessoa identificada | Dado pessoal; a imagem e a voz identificam o titular |
transcriptionEnabled: true | O conteúdo literal do que a pessoa disse | Dado pessoal; a voz é dado biométrico comportamental |
Consentimento, no caminho crítico
O convidado só entra se enviar lgpdAccepted: true, e o schema exige o literal true. A data do aceite é gravada em RoomParticipant.lgpdAcceptedAt, por participante. Não existe caminho de código em que alguém entre pelo link sem esse registro. Duas ressalvas honestas:
- O aceite do host é implícito. Quem cria a sessão também recebe
lgpdAcceptedAtpreenchido, sem tela de aceite — a premissa é que quem criou sabe que a chamada existe. Se a sua operação exige aceite explícito do operador, colete na sua interface. - O aceite é um booleano, não um texto versionado. O sistema registra que houve aceite e quando, não a qual texto a pessoa aceitou. Se a sua exigência é provar o conteúdo do termo, guarde a versão do seu lado e amarre pelo
sessionId.
Isolamento entre tenants
Toda rota autenticada aplica requireOrganization, e toda leitura de sessão, transcrição e gravação passa por RoomSessionService.getSession, que compara session.organizationId com o do token e devolve 404 — não 403 — quando difere. A escolha do 404 é deliberada: 403 confirmaria que a sessão existe. O nome da sala no provedor também carrega a organização (org_<organizationId>_<sessionId>), o que torna qualquer evento rastreável até o tenant.
flowchart TD
A["GET /sessions/:id/transcript<br/>com token da org B"] --> B["authMiddleware"]
B --> C["requirePermission(ROOMS_READ)"]
C --> D["requireOrganization"]
D --> E["RoomSessionService.getSession"]
E --> F{"session.organizationId<br/>== org do token?"}
F -->|não| G["404 NOT_FOUND<br/>não 403 — 403 confirmaria que existe"]
F -->|sim| H["devolve os trechos"]A rota pública de entrada
POST /join/:joinCode não exige token — é o ponto de entrada do cliente final. As proteções são três: o código é gerado aleatoriamente e é único; ele tem prazo de validade próprio, definido na criação; e há limite de 20 tentativas por minuto por IP. Use joinCodeTtlHours curto para atendimento agendado — um código de 168 horas é um link válido por uma semana.
Autenticidade do webhook
POST /livekit/webhooks é público por necessidade: quem chama é o LiveKit, não um usuário. A confiança vem da assinatura, verificada pelo WebhookReceiver contra o corpo cru recebido. Assinatura ausente ou inválida responde 401 e nada é processado.
Gravação
O MP4 é escrito pelo egress do LiveKit direto no seu bucket S3, com as credenciais que você configurou — o arquivo nunca passa pela nossa infraestrutura. A leitura sai por URL assinada com expiração; não há endpoint que devolva o arquivo. O caminho no bucket inclui a organização: rooms/recordings/<organizationId>/<sessionId>.mp4.
Retenção
Não há política automática de retenção. Sessões, participantes, trechos transcritos e registros de gravação permanecem no banco indefinidamente, e o MP4 permanece no S3 até que a política do seu bucket o remova. Isso é uma lacuna consciente e está na §15. Para atender a pedido de eliminação hoje, o caminho é manual: apagar a RoomSession — RoomParticipant, RoomUtterance e RoomRecording caem junto por cascata — e remover o objeto do S3 separadamente, porque o banco não o apaga.
flowchart TD A["pedido de eliminação do titular"] --> B["apagar a RoomSession"] B --> C["RoomParticipant"] B --> D["RoomUtterance"] B --> E["RoomRecording"] C -.->|cascata| F["removidos junto"] D -.->|cascata| F E -.->|cascata| F A --> G["remover o objeto do S3<br/>SEPARADAMENTE — o banco não o apaga"]
O que sai da sua infraestrutura
O áudio da sala trafega pelo LiveKit e, quando a transcrição está ligada, é enviado à Deepgram para reconhecimento de fala. Isso são dois operadores terceiros no fluxo de dado pessoal e precisa estar no seu inventário de tratamento. Se essa transferência não for aceitável, transcriptionEnabled: false mantém o áudio fora da Deepgram — e o servidor LiveKit é Apache 2.0, o que abre o caminho de auto-hospedagem para tirar também o vídeo da nuvem de terceiro.
flowchart LR P["voz e imagem do participante"] --> LK["LiveKit<br/>operador terceiro"] LK -->|"sempre"| M["mídia da sala"] LK -->|"só com transcriptionEnabled: true"| DG["Deepgram<br/>operador terceiro"] LK -->|"só com recordingEnabled: true"| S3["seu bucket S3<br/>(sua infraestrutura)"] DG --> T["texto transcrito<br/>volta e é gravado no seu Postgres"]
| Operador terceiro | Quando entra no fluxo | Como tirá-lo |
|---|---|---|
| LiveKit | Sempre — é quem transporta a mídia | Auto-hospedar o servidor, que é Apache 2.0 |
| Deepgram | Só quando transcriptionEnabled: true | transcriptionEnabled: false mantém o áudio fora da Deepgram |
Atenção. Os dois precisam estar no seu inventário de tratamento de dado pessoal.
Credenciais
As chaves do LiveKit e da Deepgram vivem em variável de ambiente, guardadas com SOPS (SECRETS-SOPS-REFERENCE.md). Editar o arquivo de ambiente no servidor não persiste: toda implantação sobrescreve.
Limitações conhecidas
| Limitação | Impacto | Situação |
|---|---|---|
| Sem porta no registry de módulos | rooms não aparece em DEFAULT_MODULE_PORTS (src/shared/registry/types.ts), junto com email e slack — são os três sem porta padrão atribuída. Não há descoberta de serviço para ele; quem precisa alcançá-lo usa a URL diretamente. O main.ts cai em 3031 e produção fixa PORT=3000. | Conhecido — não deduza a porta, configure |
| O agente só transcreve, não conversa | O main-agent-worker.ts é um scribe: assina áudio, manda para o STT, grava o trecho e publica legenda. Não há LLM no laço, não há síntese de voz, o agente não fala. | Por design nesta fase — não venda "agente de IA em sala" |
| Máximo de 4 participantes por sala | Padrão do provedor de sala, ajustável por ROOMS_MAX_PARTICIPANTS. Não serve para webinar nem para audiência. | Configurável, mas o produto não foi desenhado para escala de auditório |
| Uma identidade de convidado por sessão | A identidade é fixa em guest. Uma segunda pessoa entrando com o mesmo código derruba a primeira. Não há como ter dois convidados distintos na mesma sala. | Conhecido — para mais de um convidado, hoje não há caminho |
| Sem política de retenção | Transcrição, participantes e gravação ficam indefinidamente. Eliminação por pedido do titular é manual, e o objeto no S3 precisa ser removido à parte. | Roadmap — é a lacuna mais relevante de LGPD (§14) |
RoomProviderConfig existe no schema e não é usada | A tabela prevê provedor por organização com credenciais cifradas. Nenhum código a lê: o provedor vem de variável de ambiente e é único para todo o deployment. Um cliente não pode usar o próprio projeto LiveKit. | Especificado, não implementado |
| Limite de entrada é por processo, em memória | Assume uma réplica única. Com N réplicas o teto vira 20×N e os contadores não são compartilhados. | Precisa migrar para o limitador em Redis antes de escalar |
| Sem emissão de uso para o Billing | participantMinutes é acumulado, mas nada é enviado ao Billing. Não há como cobrar por consumo hoje. | Roadmap — é o que bloqueia a precificação (§6) |
| A sala não fecha sozinha com o agente dentro | Com transcrição ligada, o agente mantém a sala viva. O encerramento depende do POST /end. | Comportamento conhecido, documentado no runbook §3 |
startedAt/endedAt não medem sala aberta | Uma sessão pode ficar live por dias se o room_finished demorar a ser processado. Para consumo real, use RoomParticipant e RoomRecording. | Conhecido |
| Webhook do provedor é cadastrado à mão | Só pelo painel do LiveKit Cloud, por projeto. Sem ele, o Rooms recebe zero eventos e o vídeo continua funcionando — a falha é silenciosa. | Limitação do provedor |
| Sem cliente próprio | O Rooms devolve token e serverUrl; a interface é sua, com o SDK do LiveKit. Não há componente pronto de React nem SDK móvel nosso. | Por design |
| Sem transcrição pós-chamada | Só há transcrição ao vivo. Sessão criada com transcriptionEnabled: false não pode ser transcrita depois, mesmo com a gravação disponível. | Não implementado |
| O worker não é coberto por teste unitário | O main-agent-worker.ts depende de bindings nativos e é validado por smoke em ambiente real, não em teste automatizado. O contexto testável foi isolado em lib/agent-worker-context.ts. | Consciente — veja o smoke de mídia no runbook §8 |
Perguntas frequentes
O meu cliente precisa instalar alguma coisa ou criar conta?
Não. Ele abre o link com o código, digita o nome, marca o aceite de gravação e entra pelo navegador. A rota POST /join/:joinCode é pública e não exige token. O que você precisa construir é a tela — o Rooms devolve token e serverUrl para o SDK cliente do LiveKit.
Tem agente de IA na sala?
Tem um agente, e ele só transcreve. Ele entra na sala como participante, assina apenas o áudio, manda para o reconhecimento de fala e grava cada trecho finalizado. Não há LLM no laço, ele não responde e não fala. Um agente conversacional é possível sobre a mesma base — o framework LiveKit Agents suporta — mas hoje não existe. Não prometa isso a cliente (§15).
A transcrição funciona bem em português?
Funciona, e isso foi validado em produção, não em laboratório: o teste de aceitação do rollout produziu 17 trechos com o texto correto, incluindo um código de verificação combinado antes (runbook §8). O modelo padrão é o nova-3 da Deepgram e o idioma vem da sessão, com pt-BR como padrão.
Onde fica a gravação?
No seu bucket S3. O egress do LiveKit escreve o MP4 direto lá, com as suas credenciais, em rooms/recordings/<organizationId>/<sessionId>.mp4. O arquivo nunca passa pela nossa infraestrutura. Para baixar, GET /sessions/:id/recording devolve URL assinada com expiração.
Quanto custa deixar a gravação ligada?
Muito mais do que parece, e é a resposta mais importante desta seção. A gravação exige transcodificação, e essa é a franquia mais apertada do provedor por uma ordem de grandeza. Em staging, testes casuais consumiram 31,6 dos 60 minutos mensais, enquanto vídeo e transcrição ficaram na casa de 1% das franquias deles (runbook §9). Deixe recordingEnabled: false salvo quando a gravação for o objetivo.
Por que a sala continua viva depois que todo mundo saiu?
Por causa do emptyTimeout, que no padrão é 600 segundos. É uma janela de reconexão deliberada para reunião real — cair a internet não deve encerrar o atendimento. O custo é que sala abandonada paga esses 10 minutos. Sempre chame POST /sessions/:id/end ao terminar; para ambiente de teste, baixe ROOMS_EMPTY_TIMEOUT_SECONDS.
Posso usar meu próprio projeto LiveKit, ou hospedar o servidor?
Hoje não, pela API. O provedor é configurado por variável de ambiente e vale para o deployment inteiro. A tabela RoomProviderConfig prevê provedor por organização, mas nenhum código a lê (§15). Como o servidor LiveKit é Apache 2.0, auto-hospedar é tecnicamente viável — e faz sentido econômico justamente porque o gargalo é a gravação, que vira CPU sua em vez de franquia. Mas é uma mudança de implantação, não uma configuração de cliente.
Dá para ter dois convidados na mesma sala?
Hoje não. A identidade do convidado é fixa em guest, então uma segunda pessoa com o mesmo código assume a mesma identidade e o LiveKit derruba a conexão anterior. A sala suporta 4 participantes, mas hoje só há um host e um convidado distinguíveis.
Quanto tempo os dados ficam guardados?
Indefinidamente. Não há política automática de retenção (§14 e §15). Transcrição, participantes e registros de gravação ficam no banco, e o MP4 fica no S3 até que a política do seu bucket o remova. Para atender a pedido de eliminação, apague a RoomSession — o resto cai por cascata — e remova o objeto do S3 separadamente. Se a sua operação é regulada, defina essa política antes de entrar em produção.
A transcrição sai sozinha ou preciso pedir?
Sai sozinha, desde que três coisas estejam certas: a sessão foi criada com transcriptionEnabled: true, o webhook do LiveKit está cadastrado no projeto, e o rooms-agent-worker está de pé com o mesmo ROOMS_SCRIBE_AGENT_NAME do serviço. Faltando qualquer uma, o vídeo funciona e a transcrição não — a falha é silenciosa. A receita de diagnóstico está na §11.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md · Operação: runbook de produção