Agentes multi-tenant:
um schema dedicado para cada cliente seu
Se você faz um CRM, um ERP vertical ou qualquer SaaS B2B e quer embutir agentes de IA para os seus clientes, a pergunta difícil não é qual modelo usar — é onde vivem os dados de cada cliente. Este post percorre o espectro de multi-tenancy, explica por que schema dedicado por tenant é o meio-termo certo, mostra a chamada real que provisiona um tenant e como isso funciona na prática num SaaS de gestão de clínicas.

Mapa de isolamento
Da conta à pessoa, sem misturar fronteiras
- ContaIdentidade e orçamento globais.
- ProjetoAmbiente que organiza os agentes.
- TenantSchema dedicado por cliente.
- Usuário finalMemória separada dentro do tenant.
Existe uma distância grande entre "colocar um chatbot no site" e "dar um agente de IA para cada cliente do seu SaaS". No segundo caso — o caso de software houses, CRMs e verticais que vendem para outras empresas — o agente da clínica A vai acumular memória sobre os pacientes da clínica A, indexar os documentos da clínica A e chamar ferramentas com as credenciais da clínica A. Se qualquer um desses bytes aparecer para a clínica B, você não tem um bug: tem um incidente de dados com nome e sobrenome.
Por isso a decisão mais importante dessa arquitetura não envolve LLM nenhum. É uma decisão de banco de dados: como os dados de um cliente final ficam separados dos dados de outro. E é uma decisão que se toma no dia zero, porque migrar de um modelo de isolamento para outro com clientes em produção é cirurgia de coração aberto.
O espectro de multi-tenancy
Há três formas clássicas de isolar tenants, em ordem crescente de isolamento — e de custo operacional:
1. Tabela compartilhada com coluna tenant_id (row-level)
Todos os clientes nas mesmas tabelas; cada linha carrega um tenant_id; cada query filtra por ele. É o mais barato de começar e o mais barato de errar: basta um WHERE esquecido — num join, num relatório, numa query gerada pelo ORM — para dados de um cliente vazarem para outro. A superfície de erro não é o código de hoje: é cada query que o sistema fará para sempre. E apagar um cliente vira uma caça: DELETEs cuidadosos em todas as tabelas, torcendo para não sobrar linha órfã em tabela auxiliar.
2. Schema dedicado por tenant
Cada cliente ganha um schema próprio dentro da mesma instância de banco — tabelas fisicamente separadas, conexão resolvida pelo tenant antes de qualquer query. O isolamento deixa de depender da disciplina de cada WHERE e vira uma propriedade do layout: uma query aberta no schema da clínica A não tem como retornar linhas da clínica B, porque as linhas da B não existem naquele schema.
3. Banco (ou instância) por tenant
O isolamento máximo — e a explosão operacional máxima: N instâncias para provisionar, monitorar, fazer backup, atualizar e pagar. Faz sentido para contratos enterprise com exigência regulatória específica; como padrão para dezenas ou centenas de clientes de um SaaS, é custo sem retorno proporcional.
Lado a lado, o que cada degrau troca:
| Modelo | Isolamento | Falha típica | Custo operacional |
|---|---|---|---|
Coluna tenant_id |
Lógico, por disciplina de query | WHERE esquecido vaza dados — em silêncio |
Mínimo: um schema, uma migração |
| Schema por tenant | Estrutural, pelo layout do banco | Roteamento errado quebra alto: o dado não está lá | Baixo: uma instância, um pool, migração automatizada schema a schema |
| Instância por tenant | Físico, por infraestrutura | Deriva de config e backup entre N instâncias | Alto: N instâncias para operar e pagar |
Isolamento por linha confia que toda query futura vai lembrar do WHERE. Isolamento por schema torna o vazamento estruturalmente impossível dentro do banco. Essa diferença não aparece na demo — aparece no incidente que você não teve.
Schema por tenant é o meio-termo deliberado por dois motivos. O modo de falha muda de natureza: no row-level, o erro devolve dados de outro cliente e parece sucesso; no schema dedicado, um erro tende a cair num schema onde o dado não existe — quebra alto, vira bug report em vez de incidente. E a superfície de erro encolhe de todas as queries para sempre para um único ponto de resolução, executado uma vez antes de qualquer query — pequeno, central, testável. Tudo com uma conta de infraestrutura só.
Conta → Projeto → Tenant: como isso se organiza
Na Catcher Agents, essa decisão está expressa na hierarquia do produto:
- Conta — a sua empresa (a software house, o CRM, o vertical). É onde vivem os usuários do Console e as chaves de API.
- Projeto — um produto ou ambiente seu (o app de clínicas, o app de imobiliárias, staging versus produção). Agentes, ferramentas e bases de conhecimento são definidos neste nível.
- Tenant — cada cliente final seu dentro de um projeto. É a unidade de isolamento de dados.
Em desenho:
Conta (sua software house)
└─ Projeto (seu produto — o app de clínicas)
├─ Tenant "clinica-vila-nova" → schema ctc_agents_<uuid>
│ └─ Agente da clínica
│ ├─ paciente A → memória escopada ao usuário final
│ └─ paciente B → memória escopada ao usuário final
└─ Tenant "clinica-jardins" → schema ctc_agents_<uuid>
└─ Agente da clínica
└─ pacientes…
Quando um tenant é criado, ele ganha um schema de banco dedicado — ctc_agents_<uuid> — onde vivem as conversas, a memória e o estado daquele cliente. A conexão é resolvida pelo tenant antes de qualquer query; não existe caminho de código em que uma consulta "esquece o filtro", porque não há filtro a esquecer: há schemas distintos. Esse desenho está em produção desde o fim de junho.
Um invariante completa o desenho: todo projeto nasce com um tenant default ("tenant 1") — não existe caminho solto, todo recurso pertence a um tenant. Enquanto o seu produto não é multi-tenant, tudo vive no default; a estrutura já está pronta para o segundo cliente.
Provisionar um tenant é uma chamada, não um ticket
O provisionamento faz parte da API — criar, renomear e apagar tenant são operações de owner/admin do projeto. Você registra o cliente com um external_id, o identificador estável que ele já tem no seu sistema, e o schema dedicado é criado na mesma chamada:
curl -X POST https://agents-api.catcher.one/v1/tenants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "external_id": "clinica-vila-nova", "name": "Clínica Vila Nova" }'
{
"id": 12,
"company_id": 4,
"external_id": "clinica-vila-nova",
"name": "Clínica Vila Nova",
"uuid": "9f4c2e81-…",
"status": "active",
"is_default": false,
"provisioned": true,
"created_at": "2026-07-01T14:32:07Z"
}
Três mecânicas valem registro. provisioned: true significa que o schema existe quando a resposta chega — não é promessa assíncrona. Se a criação do schema falhar no meio, o registro do tenant é desfeito junto e a resposta é TENANT_PROVISION_FAILED: o retry começa limpo, sem tenant órfão pela metade. E o external_id é obrigatório e validado (^[A-Za-z0-9_.:@+-]{1,120}$; erros TENANT_EXTERNAL_ID_REQUIRED/INVALID), único por projeto — repetir a chamada responde 409 TENANT_EXTERNAL_ID_EXISTS, que a sua integração trata como "já existe" e segue.
O agente nasce ligado a um tenant: na criação, tenant_id aponta um tenant existente (0 usa o default do projeto) — ou tenant_external_id faz resolve-or-create, criando e provisionando o tenant na mesma chamada se ele ainda não existir. É assim que o SaaS de clínicas roda em produção: o backend dele só conhece o id que já usava para cada clínica — clínica = tenant — e a primeira chamada que cita uma clínica nova provisiona o schema dela como efeito.
O ciclo fecha com PATCH (renomear) e DELETE — 204, com o tenant default protegido (TENANT_DEFAULT_LOCKED). Um detalhe deliberado: o DELETE nunca dropa o schema. Destruir estrutura de banco não é efeito colateral de endpoint HTTP; é operação separada e guardada.
O caso concreto: um SaaS de gestão de clínicas
O exemplo que usamos é o que roda em produção: um SaaS de gestão de clínicas embute agentes nossos para as clínicas dele. No mapeamento:
- Cada clínica é um tenant — schema dedicado, agente próprio, base de conhecimento própria.
- Dentro da clínica, cada paciente é um usuário final — e a memória do agente é isolada por usuário final dentro do tenant.
São duas camadas de isolamento, e vale separá-las com precisão: o schema isola uma clínica da outra; o escopo de memória isola um paciente do outro dentro da mesma clínica. O agente lembra que uma paciente prefere horários de manhã sem misturar isso com o histórico de outro paciente — e sem que nada disso exista no schema de outra clínica. Como essa memória funciona por dentro (camadas diária e de longo prazo, verbatim e o ciclo que consolida o que importa) está em memória de agentes em camadas.
Embutir sem expor a credencial da conta
A outra metade do B2B2C é como o agente chega à tela do usuário do seu cliente. O seu backend guarda um runtime token (prt_…) ligado ao agente — sem expor a API key completa da conta — e cria a sessão com o end_user_external_id da pessoa. O frontend fala com o seu backend; o token não deve ir para o navegador. Ele pode ser revogado, mas o token persistente atual não traz expiração automática. Como o agente já pertence ao tenant correto, a sessão herda essa fronteira e acrescenta a separação por usuário final. O passo a passo está em embutir um agente em 3 chamadas.
Custo por tenant: medir antes, travar no teto
Multi-tenant sem contabilidade por tenant é uma bomba-relógio de custo: um único cliente com uso pesado consome o orçamento de todos os outros. Cada execução de agente registra o custo em USD (cost_usd) por run, por agente e por modelo, com janelas de 7, 30 e 90 dias — então você sabe exatamente quanto cada cliente custa antes de precisar da resposta. E a trava é dura, em dois níveis: um teto mensal da conta inteira e um teto mensal por agente (monthly_budget_usd). Atingido o teto, as execuções param de gastar e respondem HTTP 402 — BUDGET_EXCEEDED no nível da conta, AGENT_BUDGET_EXCEEDED no do agente. Como no desenho B2B2C cada cliente final tem o seu agente, o teto por agente funciona na prática como teto por cliente: a clínica pesada esbarra no próprio limite sem drenar o orçamento das vizinhas. Se esse teto é margem sua ou plano vendido ao seu cliente, é decisão comercial; a mecânica é a mesma.
LGPD: a fronteira ajuda, mas a operação continua explícita
Quem vende para clínicas, escolas ou qualquer vertical que toque dado pessoal não trata LGPD como rodapé. O schema dedicado ajuda porque dá ao tenant uma fronteira nomeada, mas isso não transforma retenção, exportação e descarte em efeitos colaterais automáticos. A API pública atual exporta os dados da conta, não um tenant isolado; e DELETE /v1/tenants/{id} remove o registro do tenant, mas retém deliberadamente o schema dedicado.
Essa assimetria é uma proteção: uma chamada HTTP de gestão nunca destrói estrutura de banco nem dados difíceis de recuperar. A eliminação do conteúdo retido exige uma operação separada, guardada e auditável, conforme a política do controlador. O layout por schema reduz o espaço a tratar e torna a fronteira explícita; não substitui o runbook de privacidade nem deve ser vendido como exclusão em cascata já automatizada.
A parte honesta: isso é infraestrutura, não diferencial seu
Nada do que está acima é o produto que você vende. Seu cliente escolhe o seu CRM ou o seu app de clínicas pelo que ele resolve — não pelo layout de schemas por trás. E a lista do que você teria que construir para ter o mesmo chão é concreta:
- provisionamento de schema por cliente, com rollback quando falha no meio;
- migração aplicada em N schemas a cada mudança sua, automatizada;
- resolução de conexão por tenant em todos os caminhos de código;
- metering de custo por execução, por agente e por modelo — e a trava 402 em cima;
- runbooks guardados de exportação, retenção e eliminação de dados;
- runtime token ligado ao agente no backend e sessão escopada por usuário final.
É trabalho pesado e indiferenciado, do tipo que atrasa roadmap sem gerar uma linha de pitch. A escolha real é entre construir esse chão ou consumi-lo pronto e auditável, e gastar o tempo no agente em si — o prompt, as ferramentas, o conhecimento que fazem sentido no seu domínio. Se você prefere construir, o espectro acima é o mapa. Se prefere embutir, ele já está de pé.
Perguntas frequentes
Qual a diferença entre schema dedicado e uma coluna tenant_id?
Com uma coluna tenant_id, todos os clientes dividem as mesmas tabelas e o isolamento depende de cada query filtrar corretamente — um WHERE esquecido vaza dados de um cliente para outro. Com schema dedicado, os dados de cada tenant vivem em tabelas fisicamente separadas (ctc_agents_<uuid>) e uma query aberta no schema de um cliente não tem como retornar linhas de outro.
Schema por tenant não explode a operação com muitos clientes?
Não nesse ponto da curva: os schemas dividem a mesma instância de banco e o mesmo pool de conexões, e migrações são aplicadas schema a schema de forma automatizada. A explosão operacional está no degrau seguinte — uma instância de banco por cliente —, que só se justifica em requisitos enterprise específicos.
Como a memória por paciente se relaciona com o schema por clínica?
São camadas diferentes de isolamento. O schema dedicado isola uma clínica (tenant) da outra; dentro do tenant, a memória do agente é escopada por usuário final, então o histórico de um paciente nunca contamina o de outro. As duas camadas juntas isolam clientes entre si e usuários do mesmo cliente entre si.
Como um agente fica ligado a um tenant?
Na criação do agente, tenant_id aponta para um tenant existente (0 usa o tenant default do projeto) — ou tenant_external_id resolve o tenant pelo identificador estável do seu sistema e o cria, já provisionado, se ainda não existir. No caso das clínicas, o backend integrador só conhece o id da própria clínica: clínica = tenant.
Agentes para os clientes do seu SaaS, sem construir o isolamento
Conta → Projeto → Tenant, schema dedicado por cliente e budget com trava já rodam em produção. Se você quer embutir agentes no seu produto, o programa de design partners conecta a sua aplicação a essa infraestrutura.