Catalisa.Building Blocks
Catálogo/Comunicação/Rooms

Rooms

Produção

Sala de vídeo com link de convidado, gravação e transcrição em português

8
Endpoints
5
Entidades
2
Provedores
Tenant
Escopo
Porta
2026-07
Desde

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.

Para quem é
  • 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
Substitui
  • 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
O que não é
  • 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
O que dá para fazer

9 endpoints em 4 recursos.

Explorar a API →
01

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.

AtributoValor
Identificadorrooms
CategoriaComunicação
EscopoTenant (exige organizationId no token em todas as rotas autenticadas)
Porta (standalone)Sem porta padrão atribuídarooms 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
StatusProdução desde 2026-07
Depende dePostgreSQL (schema rooms), Redis (eventos), S3, LiveKit, Deepgram (só na transcrição)
ProcessosDois: o serviço HTTP rooms e o worker rooms-agent-worker

02

O problema

negócio

O 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.


03

Proposta de valor

negócio
AntesDepois
Cliente instala aplicativo ou cria conta para entrarCliente abre um link com código curto, digita o nome e aceita a gravação
Gravação na nuvem pessoal de quem organizouGravação no seu S3, ligada à sessão, entregue por URL assinada com expiração
Transcrição é um projeto separado, depois da chamadaTranscriçã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çolgpdAccepted: 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.


04

Casos de uso reais

negócio

Caso 1 — Entrevista de crédito com evidência do que foi combinado Cenário ilustrativo

Contexto

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 dor

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 solução com o BB

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.

O resultado

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

Contexto

Operação que precisa ver o documento e o rosto do cliente antes de liberar uma conta.

A dor

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.

A solução com o BB

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.

O resultado

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

Contexto

Atendimento por vídeo em que o resultado da conversa precisa virar campo estruturado num sistema.

A dor

Depois da chamada, alguém ouvia a gravação e preenchia formulário. Levava mais tempo que a própria chamada.

A solução com o BB

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 resultado

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

Contexto

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).

A dor do mercado

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.

Como a Catalisa endereça

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 é.

O resultado

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"]

05

Mercado e diferenciais

negócio

Panorama. 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érioCatalisa RoomsLiveKit CloudDaily.co100msAgora
Modelo de preçoEm definição (§6)Build grátis; Ship US$ 50/mês; Scale US$ 500/mês10 mil min/mês grátis; US$ 0,0015–0,004/min10 mil min/mês grátis; US$ 0,004/min10 mil min/mês grátis; US$ 0,59/mil min
Sessão de negócio com donoNativa, por organizaçãoVocê constróiVocê constróiVocê constróiVocê constrói
Link de convidado sem contaNativo, com expiraçãoVocê constrói sobre o tokenVocê constróiVocê constróiVocê constrói
Aceite de gravação registradoObrigatório no schema, com dataVocê constróiVocê constróiVocê constróiVocê constrói
Transcrição persistida por falanteSim, RoomUtteranceVia Agents, você persisteOferece, você persisteOferece, você persisteOferece, você persiste
Gravação no seu bucketSim, S3 seu, URL assinadaSimSimSimSim
Permissão por organizaçãoNativa, token do IAMNão se aplicaNão se aplicaNão se aplicaNão se aplica
Agente de IA em salaSó transcrição (§15)Framework Agents completoIntegra PipecatParcialParcial
Você opera a infraestruturaNãoNão (ou sim, se auto-hospedar)NãoNãoNão
Sala de centenas de espectadoresNão (§15)SimSimSimSim

Nossos diferenciais

  1. A sala já sabe de quem ela é. RoomSession.organizationId é preenchido a partir do token e toda leitura passa por getSession, que devolve 404 quando 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.
  2. O convidado entra sem conta e o aceite fica gravado. joinByCodeSchema exige lgpdAccepted: true — literalmente z.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.
  3. 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.finalized no mesmo barramento dos outros building blocks. A chamada deixa de ser mídia e vira dado que a esteira consome.
  4. A gravação não fica órfã. Um webhook perdido deixaria o registro preso em active para 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.


06

Modelo de cobrança e ROI

negócio

Unidade 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:

DriverPesoPor quê
Minutos de gravação transcodificada (egress)AltoÉ a franquia mais apertada do provedor por uma ordem de grandeza
Minutos de agente de transcriçãoMédioConsome franquia de agente no LiveKit e minutos de STT na Deepgram
Minutos de participante em salaBaixoA 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:

DriverConsumo medidoFranquia do plano% da franquia
Minutos de participante58 min5.000 min1,2%
Minutos de transcrição (agente)~22 min1.000 min~2,2%
Minutos de egress (gravação)31,6 min60 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 RoomsLiveKit Cloud diretoDaily.co100ms
ConferênciaEm definiçãoPlano Ship (US$ 50/mês) inclui 150 mil min de WebRTC10 mil min grátis, depois US$ 0,0015–0,004/min10 mil min grátis, depois US$ 0,004/min
TranscriçãoEm definição5 mil min de agente inclusos no Ship, depois US$ 0,01/minUS$ 0,0059/min em tempo real300 min grátis, depois US$ 0,004/min pós-chamada
GravaçãoEm definiçãoEgress medido à parteUS$ 0,01349/min300 min grátis, depois US$ 0,0135/min
Camada de sessão, convidado, aceite e permissãoInclusaVocê constróiVocê constróiVocê constrói
Transcrição persistida e ligada ao registroInclusaVocê constróiVocê constróiVocê 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.


07

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"| S

Caminho 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. O rooms-agent-worker não pode: o @livekit/rtc-node tem 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 é o POST /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 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. Por isso ROOMS_SCRIBE_AGENT_NAME existe, e por isso o serviço rooms e o rooms-agent-worker precisam ler o mesmo valor.
  • A legenda ao vivo é atribuída ao falante, não ao agente. O senderIdentity do 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 o WebhookReceiver recalcula 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_ended tira um registro de active, e webhook é entrega de melhor esforço. Reconciliar no POST /end correria com o egress ainda em EGRESS_ENDING; reconciliar na leitura acontece muito depois, sem corrida. Falha na consulta ao provedor é engolida de propósito — o pior caso volta a ser o 404 de antes.
  • O limite de entrada por código é por processo, em memória. createInMemoryRateLimiter com 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.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
SessionUma chamada. Unidade de tudo: pertence a uma organização, tem código de convidado, participantes, transcrição e gravações.
Room nameNome da sala no LiveKit, no formato org_<organizationId>_<sessionId>. Único.
Join codeCódigo curto e público que o convidado usa para entrar. Único, com prazo de validade próprio.
HostQuem criou a sessão. Entra por POST /sessions/:id/tokens/host, autenticado, com identidade fixa na sessão.
GuestQuem 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.
ScribeO agente de transcrição. Entra na sala como participante, ouve o áudio e grava os trechos. Não conversa com ninguém.
UtteranceUm trecho de fala finalizado, com texto, participante, papel, idioma e marcação de tempo.
EgressO processo do LiveKit que grava a sala e escreve o MP4 no seu S3.
participantMinutesMinutos 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 PrismaPropósitoCampos-chave
RoomSessionA chamadaorganizationId, status, roomName (único), joinCode (único), joinCodeExpiresAt, hostIdentity, recordingEnabled, transcriptionEnabled, language, participantMinutes, startedAt, endedAt
RoomParticipantQuem entrouÚnico (sessionId, identity); role, displayName, lgpdAcceptedAt, joinedAt, leftAt
RoomUtteranceTrecho transcritoparticipantIdentity, role, text, language, startMs, endMs, isFinal
RoomRecordingUma gravaçãoegressId (único), status, bucketKey, durationMs, sizeBytes
RoomProviderConfigConfiguração de provedor por organizaçãoprovider, 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, o deleteRoom já foi chamado no LiveKit e os participantes pendurados são fechados com o mesmo endedAt, 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.


09

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 authMiddlewarerequirePermission(...)requireOrganization.

MétodoRotaDescriçãoPermissão
POST/rooms/api/v1/sessionsCria a sessão e a sala no provedorROOMS_WRITE
GET/rooms/api/v1/sessions/:idBusca a sessão (escopo de tenant)ROOMS_READ
POST/rooms/api/v1/sessions/:id/tokens/hostEmite o token de host da sessãoROOMS_WRITE
POST/rooms/api/v1/sessions/:id/endEncerra a sessão e apaga a sala no provedorROOMS_WRITE
GET/rooms/api/v1/sessions/:id/transcriptLista os trechos transcritosROOMS_READ
GET/rooms/api/v1/sessions/:id/recordingURL assinada da última gravação concluídaROOMS_READ

Entrada de convidado — /rooms/api/v1/join

MétodoRotaDescriçãoPermissão
POST/rooms/api/v1/join/:joinCodeEntrada pública por código, com aceite de LGPD obrigatórioNenhuma — rota pública, limitada a 20 requisições por minuto por IP

Webhook do provedor — /rooms/livekit/webhooks

MétodoRotaDescriçãoPermissão
POST/rooms/livekit/webhooksRecebe os eventos do LiveKitNenhuma — a confiança vem da assinatura do LiveKit, verificada contra o corpo cru

Saúde

MétodoRotaDescrição
GET/rooms/healthNome 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

json
{
  "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" }
}
CampoTipoObrigatórioPadrãoDescrição
titlestring (até 200)NãoTítulo livre
recordingEnabledbooleanNãofalseLiga o egress quando a sala começa. Este é o driver de custo mais caro (§6)
transcriptionEnabledbooleanNãofalseDespacha o agente de transcrição quando a sala começa
languagestringNãopt-BRIdioma repassado ao STT
joinCodeTtlHoursint (1 a 168)Não24Validade do código de convidado
metadataobjectNãoCampo livre para amarrar ao seu registro

Resposta 201 — o registro RoomSession completo, incluindo joinCode, joinCodeExpiresAt, roomName e status: "scheduled".

Erros

StatusQuando
400Corpo reprovado no schema Zod
401Token ausente ou inválido
403Falta ROOMS_WRITE, ou o token não traz organizationId
500Falha 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

json
{
  "displayName": "Maria Souza",
  "lgpdAccepted": true
}
{
  "displayName": "Maria Souza",
  "lgpdAccepted": true
}
CampoTipoObrigatórioDescrição
displayNamestring (1 a 120)SimNome exibido na sala
lgpdAcceptedtrueSimPrecisa 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

json
{
  "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

StatusCódigo em detailsQuando
400displayName vazio ou lgpdAccepted diferente de true
404Código inexistente
410SESSION_ENDEDA sessão já foi encerrada
410JOIN_CODE_EXPIREDO código passou de joinCodeExpiresAt
429Mais de 20 tentativas por minuto do mesmo IP; traz o cabeçalho Retry-After

Os dois casos de 410 chegam com code: "CONFLICT" no corpo e statusCode: 410 — a distinção precisa vem de details.code. Trate por details.code, não pelo code de topo.


GET /rooms/api/v1/sessions/:id/transcript

Resposta 200

json
{
  "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

json
{
  "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

StatusQuando
404 RECORDING_NOT_READYNão há gravação concluída, ou há uma concluída sem bucketKey
404A sessão não existe ou pertence a outra organização

10

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

bash
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.app
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.app

Esperado: $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

bash
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.

texto
sessão clx7a2b9c0001abcd · código 7K4Q2M
sessão clx7a2b9c0001abcd · código 7K4Q2M

recordingEnabled: false de 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

bash
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.

json
{
  "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

bash
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.

json
{
  "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

bash
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

bash
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.

json
{ "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-…-0001 usada em staging e nos testes ponta a ponta; consulte iam.organizations no 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 --media e --recording como opções deliberadamente não padrão. Veja o runbook §7.


11

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?

bash
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:

texto
https://<host>/rooms/livekit/webhooks
https://<host>/rooms/livekit/webhooks

O vídeo continua funcionando sem isso, o que torna a falha silenciosa.

3. O worker está de pé e recebeu o job?

bash
docker service logs bb-production_rooms-agent-worker --since 5m | grep transcribing
docker service logs bb-production_rooms-agent-worker --since 5m | grep transcribing

Esperado, uma linha por faixa de áudio assinada:

texto
scribe: transcribing track TR_... from host (host) @ 16000Hz/1ch model=nova-3 lang=pt-BR
scribe: transcribing track TR_... from host (host) @ 16000Hz/1ch model=nova-3 lang=pt-BR

4. 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

bash
curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
  -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/rooms/api/v1/sessions/$SESSION_ID/recording" \
  -H "Authorization: Bearer $TOKEN" | jq

Esperado: ainda 404 RECORDING_NOT_READY, mas o estado terminal já foi persistido.

2. Segunda chamada — devolve a URL

bash
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:

json
{ "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 mesmo 404 — olhe o log do serviço para distinguir.
  • Um registro completed sem bucketKey responde 404 igual. Não há o que assinar.

Reduzir o desperdício de franquia

Objetivo: parar de pagar sala vazia.

bash
# 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=4

E, 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 NaN ao 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:

EventoQuandoCarga principal
rooms.v1.session.startedA sala começousessionId, roomName, title, startedAt, flags de gravação e transcrição
rooms.v1.utterance.finalizedUm trecho de fala foi finalizadoutteranceId, participantIdentity, role, text, startMs, endMs
rooms.v1.session.endedA sala terminouparticipantMinutes e a lista de participantes com entrada e saída
rooms.v1.recording.readyO egress terminourecordingId, 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 em metadata.sessionEndedEmittedAt da 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 medirUseNão use
Consumo real de salaRoomParticipant (joinedAt/leftAt) e participantMinutesendedAt - startedAt
Consumo de gravaçãoRoomRecording.durationMsendedAt - startedAt

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token; o Rooms exige ROOMS_READ/ROOMS_WRITE e organizationIdSim
File StorageO Rooms usa o mesmo S3Service para assinar a URL da gravação; o egress do LiveKit escreve o MP4 direto no bucketSim, para gravação
Webhooks EngineEntrega os quatro eventos rooms.v1.* ao sistema do clienteNão
AI EngineResume ou extrai campos a partir do transcript já prontoNão
Decision PlatformUsa o resultado da conversa como entrada de regraNão
CalendarAgenda o atendimento; o Rooms executa a chamadaNão
Audit TrailRegistra quem acessou gravação e transcriçãoNã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.


13

Configuração e operação

Variáveis de ambiente — serviço rooms

VariávelDescriçãoObrigatóriaPadrão
ROOMS_PROVIDERlivekit ou mock. Ausente ou mock usa o provedor falso; qualquer outro valor derruba o boot.Nãomock
ROOMS_LIVEKIT_URLURL do projeto LiveKit (wss://...). Convertida para https:// nas chamadas de API.Com livekit
ROOMS_LIVEKIT_API_KEYChave da API do projetoCom livekit
ROOMS_LIVEKIT_API_SECRETSegredo da API do projetoCom livekit
ROOMS_EMPTY_TIMEOUT_SECONDSSegundos que a sala fica viva depois que o último participante sai. Valor inválido é ignorado.Não600
ROOMS_MAX_PARTICIPANTSTeto de participantes por salaNão4
ROOMS_SCRIBE_AGENT_NAMENome sob o qual o agente é despachado. Precisa bater com o do worker.Nãorooms-scribe
PORTPorta no modo standaloneNão3031 no main.ts; produção fixa 3000
MODULE_IAM_URLEndereço do IAM em standaloneSim
DATABASE_URL, REDIS_URLPostgres e RedisSim
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çãover src/shared/config/env.ts

Variáveis de ambiente — processo rooms-agent-worker

VariávelDescriçãoObrigatóriaPadrão
ROOMS_LIVEKIT_URL / _API_KEY / _API_SECRETRegistro do worker no projeto LiveKitSim
ROOMS_SCRIBE_AGENT_NAMEMesmo valor do serviço roomsNãorooms-scribe
DEEPGRAM_API_KEYChave da DeepgramSim
DEEPGRAM_STT_MODELModelo de STTNãonova-3
DEEPGRAM_STT_LANGUAGEIdioma; vazio usa o da sessãoNãoidioma da sessão, ou pt-BR
DATABASE_URLO worker escreve RoomUtterance direto no PostgresSim
REDIS_URLPublicação de rooms.v1.utterance.finalizedSim

Dependências de infraestrutura

DependênciaPara quê
PostgreSQL, schema roomsSessões, participantes, trechos, gravações
RedisPublicação dos eventos rooms.v1.*
S3 (ou MinIO)Destino do MP4 e origem da URL assinada
LiveKitSala, token, despacho de agente, egress, webhook
DeepgramReconhecimento de fala, só no worker
Saída para a internetOs dois processos precisam. O Rooms não precisa de extra_hosts: ele só assina a URL do S3, nunca transfere bytes por ela

Limites

LimiteValorOnde
Participantes por sala4 (configurável)ROOMS_MAX_PARTICIPANTS
Sala viva após esvaziar600s (configurável)ROOMS_EMPTY_TIMEOUT_SECONDS
Validade do token de sala2 horasFixo no provedor
Validade do código de convidado1 a 168 horas, padrão 24joinCodeTtlHours na criação
Entrada por código20 por minuto por IP, por processoLimitador em memória
Convidados simultâneos por código1A identidade guest é fixa; a segunda conexão derruba a primeira
Título da sessão200 caracteresSchema
Nome exibido120 caracteresSchema
Taxa de amostragem do STT16 kHz, monoFixo — o plugin não faz reamostragem

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo reprovado no Zod (inclui lgpdAccepted diferente de true)Confira o schema na §9
401UNAUTHORIZEDToken ausente ou inválido; ou assinatura do webhook inválidaRenove o token; confira a chave do projeto LiveKit
403FORBIDDENFalta ROOMS_READ/ROOMS_WRITE, ou falta organizationIdAutentique informando a organização
404NOT_FOUNDSessão inexistente, ou de outra organização, ou código de entrada desconhecidoConfira o identificador e o tenant
404RECORDING_NOT_READYSem gravação concluída, ou concluída sem bucketKeyChame de novo para reconciliar; veja a receita na §11
410CONFLICT + details.code: SESSION_ENDEDEntrada em sessão encerradaCrie outra sessão
410CONFLICT + details.code: JOIN_CODE_EXPIREDCódigo expiradoCrie outra sessão
429Limite de entrada por IPAplique recuo; leia Retry-After
500INTERNALFalha no provedor ou no bancoVeja o log do serviço

Observabilidade

  • GET /rooms/health devolve 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 error no log do serviço rooms, marcada como não fatal.
  • Para auditar consumo no provedor: ListRooms mostra salas abertas e há quanto tempo; ListEgress mostra 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.


14

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ãoO que ela produzNatureza do dado
recordingEnabled: trueVídeo e áudio de uma pessoa identificadaDado pessoal; a imagem e a voz identificam o titular
transcriptionEnabled: trueO conteúdo literal do que a pessoa disseDado 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 lgpdAcceptedAt preenchido, 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 RoomSessionRoomParticipant, 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 terceiroQuando entra no fluxoComo tirá-lo
LiveKitSempre — é quem transporta a mídiaAuto-hospedar o servidor, que é Apache 2.0
DeepgramSó quando transcriptionEnabled: truetranscriptionEnabled: 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.


15

Limitações conhecidas

LimitaçãoImpactoSituação
Sem porta no registry de módulosrooms 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 conversaO 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 salaPadrã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ãoA 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çãoTranscriçã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 é usadaA 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óriaAssume 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 BillingparticipantMinutes é 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 dentroCom 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 abertaUma 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ãoSó 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óprioO 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-chamadaSó 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árioO 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

16

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