Zoho CRM · Claude API · Caso real

Um botão no CRM
que lê os documentos
e resume o processo.

Um escritório de imigração guardava os documentos de cada cliente numa pasta do WorkDrive. Ler tudo antes de cada atendimento levava minutos. Agora leva um clique.

Ver como funciona Ler o código
1
clique na tela do negócio
14
chamadas encadeadas por execução
9
documentos lidos por vez, em lotes de 3
~60s
do clique ao resumo gravado
🗂️

Lê a pasta inteira

O botão pega o link do WorkDrive que já existe no registro, lista os arquivos e seleciona o que a IA consegue ler.

👁️

PDF e imagem

Passaporte fotografado, carteira de residência, contrato assinado. A leitura é visual — não depende de OCR prévio.

🧩

Lotes de 3

Cada lote vira um resumo parcial e uma última chamada consolida tudo. Contorna os limites de execução do Deluge.

🏠

Sem infra nova

Roda inteiro dentro do Zoho. Nenhum servidor, fila ou serviço externo para manter — só uma função e uma conexão.

Como funciona
1

Clique no registro

Um botão customizado na página do negócio dispara uma função Deluge, recebendo o ID do registro e o link da pasta como argumentos.

2

Baixa e envia

A função lista a pasta pela API do WorkDrive, baixa cada arquivo, converte para base64 e envia em lotes de três para a Claude API.

3

Consolida e grava

Os resumos parciais passam por uma última chamada que elimina repetições, e o texto final é gravado num campo do próprio registro.

Arquitetura

Tudo dentro do CRM

Nenhum componente novo para operar. A função vive no Zoho e conversa com dois serviços externos.

ZOHO CRM · DELUGE ZOHO WORKDRIVE ANTHROPIC Botão "AI Summary" no registro argumentos: Deal Id · Documents Folder Função Deluge extrai o folderId do link monta os lotes · trata os erros base64 + detecção de tipo media_type vem dos bytes, não da extensão Campo "AI Summary" do registro resumo final gravado no próprio negócio Listar pasta /files/{id}/files Baixar arquivo /download/{id} Lote de 3 docs document + image → resumo parcial Consolidação junta os parciais → resumo único lista baixa 3 documentos por chamada ×3 lotes grava no registro arquivos inferência gravação
Código

As três chamadas que importam

As três partes que levaram tempo para acertar — e, na última aba, a função inteira como está em produção.

O header Accept não é opcional: a API do WorkDrive segue o padrão JSON:API e, sem ele, a resposta volta sem a chave data — sem erro, só vazia.

// o folderId sai do próprio link salvo no registro folderId = linkPasta.getSuffix("/folder/"); cabWd = Map(); cabWd.put("Accept","application/vnd.api+json"); listaResp = invokeurl [ url :"https://www.zohoapis.eu/workdrive/api/v1/files/" + folderId + "/files" type :GET headers :cabWd connection:"workdrive_conn" ]; arquivos = listaResp.get("data");

A extensão do arquivo mente com frequência — fotos de celular renomeadas viram .jpeg que na verdade são PNG. O tipo real é decidido pela assinatura dos bytes.

conteudo = invokeurl [ url :"https://www.zohoapis.eu/workdrive/api/v1/download/" + item.get("id") type :GET connection:"workdrive_conn" ]; b64 = zoho.encryption.base64Encode(conteudo); // o tipo real vem dos bytes, não da extensão if(b64.startsWith("JVBERi0")) { tipo = "document"; mtype = "application/pdf"; } else if(b64.startsWith("iVBORw0KGgo")) { tipo = "image"; mtype = "image/png"; } else if(b64.startsWith("/9j/")) { tipo = "image"; mtype = "image/jpeg"; }

Cada lote vira uma mensagem com três blocos de documento ou imagem mais a instrução. O effort baixo mantém a latência aceitável para uma ação síncrona de botão.

bloco = Map(); bloco.put("type",tipo); // "document" ou "image" bloco.put("source",fonte); // base64 + media_type lote.add(bloco); corpo = Map(); corpo.put("model","claude-opus-5"); corpo.put("max_tokens",6000); corpo.put("output_config",config); // effort: low corpo.put("messages",mensagens); respLote = invokeurl [ url :"https://api.anthropic.com/v1/messages" type :POST parameters :corpo.toString() headers :cabecalhos // x-api-key vem de uma org variable ];

A função completa, como está em produção. Recebe o ID do registro e o link da pasta, e devolve a mensagem que o usuário vê ao clicar no botão.

string button.ai_summary(String dealId, String pastaUrl) { // ===== 0. Blindagem dos argumentos ===== linkPasta = ""; if(pastaUrl != null && pastaUrl.contains("/folder/")) { linkPasta = pastaUrl; } else { deal = zoho.crm.getRecordById("Deals",dealId); linkPasta = ifnull(deal.get("Documents_Folder"),""); } if(linkPasta.contains("/folder/") == false) { return "Este negócio não tem um link válido do WorkDrive no campo Documents Folder."; } // ===== 1. Extrair o ID da pasta ===== folderId = linkPasta.trim().getSuffix("/folder/"); if(folderId == null || folderId.trim() == "") { return "Não consegui extrair o ID da pasta de: " + linkPasta; } if(folderId.contains("?")) { folderId = folderId.getPrefix("?"); } if(folderId.contains("/")) { folderId = folderId.getPrefix("/"); } // ===== 2. Listar os arquivos da pasta ===== cabWd = Map(); cabWd.put("Accept","application/vnd.api+json"); listaResp = invokeurl [ url :"https://www.zohoapis.eu/workdrive/api/v1/files/" + folderId + "/files" type :GET headers:cabWd connection:"workdrive_conn" ]; arquivos = listaResp.get("data"); if(arquivos == null || arquivos.size() == 0) { return "A pasta do WorkDrive está vazia, não existe mais ou você não tem acesso a ela."; } // ===== 3. Selecionar PDFs e imagens (máximo 9 = 3 lotes de 3) ===== selecionados = List(); nomes = List(); for each arq in arquivos { attr = arq.get("attributes"); nome = attr.get("name"); nomeLower = nome.toLowerCase(); tipo = ""; mtype = ""; if(nomeLower.endsWith(".pdf")) { tipo = "document"; mtype = "application/pdf"; } else if(nomeLower.endsWith(".jpg") || nomeLower.endsWith(".jpeg")) { tipo = "image"; mtype = "image/jpeg"; } else if(nomeLower.endsWith(".png")) { tipo = "image"; mtype = "image/png"; } if(attr.get("is_folder") != true && tipo != "" && selecionados.size() < 9) { item = Map(); item.put("id",arq.get("id")); item.put("nome",nome); item.put("tipo",tipo); item.put("mtype",mtype); selecionados.add(item); nomes.add(nome); } } if(selecionados.size() == 0) { return "Nenhum PDF ou imagem encontrado na pasta. Formatos suportados: PDF, JPG, PNG."; } // ===== 4. Processar em lotes de 3 ===== cabecalhos = Map(); cabecalhos.put("x-api-key",zoho.crm.getOrgVariable("claude_api_key")); cabecalhos.put("anthropic-version","2023-06-01"); cabecalhos.put("content-type","application/json"); config = Map(); config.put("effort","low"); parciais = List(); lote = List(); nomesLote = List(); for each index idx in selecionados { item = selecionados.get(idx); conteudo = invokeurl [ url :"https://www.zohoapis.eu/workdrive/api/v1/download/" + item.get("id") type :GET connection:"workdrive_conn" ]; b64 = zoho.encryption.base64Encode(conteudo); // o tipo real vem dos bytes, nao da extensao (arquivos com extensao errada sao comuns) tipoReal = item.get("tipo"); mtypeReal = item.get("mtype"); if(b64.startsWith("JVBERi0")) { tipoReal = "document"; mtypeReal = "application/pdf"; } else if(b64.startsWith("iVBORw0KGgo")) { tipoReal = "image"; mtypeReal = "image/png"; } else if(b64.startsWith("/9j/")) { tipoReal = "image"; mtypeReal = "image/jpeg"; } else if(b64.startsWith("R0lGOD")) { tipoReal = "image"; mtypeReal = "image/gif"; } else if(b64.startsWith("UklGR")) { tipoReal = "image"; mtypeReal = "image/webp"; } fonte = Map(); fonte.put("type","base64"); fonte.put("media_type",mtypeReal); fonte.put("data",b64); bloco = Map(); bloco.put("type",tipoReal); bloco.put("source",fonte); lote.add(bloco); nomesLote.add(item.get("nome")); if(lote.size() == 3 || idx == selecionados.size() - 1) { instrucao = Map(); instrucao.put("type","text"); instrucao.put("text","Documentos deste lote, na ordem em que foram anexados: " + nomesLote.toString() + ".\nDescreva, em português do Brasil, o que é cada documento e liste os dados relevantes que aparecem nele (nomes, datas, números de documento e referência, valores, validades). Aponte o que estiver ilegível ou incompleto. Seja factual e não invente nada."); lote.add(instrucao); msg = Map(); msg.put("role","user"); msg.put("content",lote); mensagens = List(); mensagens.add(msg); corpo = Map(); corpo.put("model","claude-opus-5"); corpo.put("max_tokens",6000); corpo.put("output_config",config); corpo.put("messages",mensagens); respLote = invokeurl [ url :"https://api.anthropic.com/v1/messages" type :POST parameters:corpo.toString() headers:cabecalhos ]; if(respLote.get("content") == null) { dbg = Map(); dbg.put("AI_Summary","ERRO NA CLAUDE API\nLote: " + nomesLote.toString() + "\n\n" + respLote.toString()); zoho.crm.updateRecord("Deals",dealId,dbg); return "Erro na Claude API. Detalhes gravados no campo AI Summary."; } textoLote = ""; for each b in respLote.get("content") { if(b.get("type") == "text") { textoLote = textoLote + b.get("text"); } } parciais.add(textoLote); lote = List(); nomesLote = List(); } } // ===== 5. Consolidar os lotes ===== if(parciais.size() == 1) { resumo = parciais.get(0); } else { juntos = ""; for each p in parciais { juntos = juntos + "\n\n---\n\n" + p; } instFinal = Map(); instFinal.put("type","text"); instFinal.put("text","Abaixo estão análises parciais dos documentos de um mesmo processo de imigração, feitas em lotes separados. Consolide tudo em um único resumo em português do Brasil, com estes tópicos:\n1. Que documentos existem no processo.\n2. Dados principais identificados (pessoa, datas, números de documento, validades).\n3. O que está pendente, vencido ou faltando.\n4. Próximos passos sugeridos.\nElimine repetições e não invente informação.\n\nANÁLISES PARCIAIS:" + juntos); blocosFinal = List(); blocosFinal.add(instFinal); msgFinal = Map(); msgFinal.put("role","user"); msgFinal.put("content",blocosFinal); msgsFinal = List(); msgsFinal.add(msgFinal); corpoFinal = Map(); corpoFinal.put("model","claude-opus-5"); corpoFinal.put("max_tokens",6000); corpoFinal.put("output_config",config); corpoFinal.put("messages",msgsFinal); respFinal = invokeurl [ url :"https://api.anthropic.com/v1/messages" type :POST parameters:corpoFinal.toString() headers:cabecalhos ]; if(respFinal.get("content") == null) { dbg2 = Map(); dbg2.put("AI_Summary","ERRO NA CONSOLIDACAO\n\n" + respFinal.toString()); zoho.crm.updateRecord("Deals",dealId,dbg2); return "Erro na consolidação. Detalhes gravados no campo AI Summary."; } resumo = ""; for each b in respFinal.get("content") { if(b.get("type") == "text") { resumo = resumo + b.get("text"); } } } if(resumo.trim() == "") { return "A API respondeu vazio. Aumente o max_tokens."; } // ===== 6. Gravar no campo AI Summary ===== atualiza = Map(); atualiza.put("AI_Summary","Documentos analisados (" + nomes.size() + "): " + nomes.toString() + "\n\n" + resumo); zoho.crm.updateRecord("Deals",dealId,atualiza); return "Resumo gerado com " + nomes.size() + " documento(s). Atualize a página para ver o campo AI Summary."; }
Armadilhas

O que não está na documentação

Cinco problemas que consumiram a maior parte do tempo de implementação. Nenhum deles aparece numa busca óbvia.

01

O mapeamento de argumentos aceita texto que parece campo

Na tela de Argument Mapping, digitar Deal Id e selecionar Deal Id produzem telas idênticas — mas a primeira passa a string literal para a função.

Escolher pelo seletor. Quando está certo, o valor fica verde no formato Deals - Deal Id.

02

O endpoint de download "oficial" não aceita o token

O domínio download.zoho.eu devolve INVALID_OAUTHSCOPE mesmo com todos os escopos concedidos — é um serviço separado, herdado do Zoho Docs.

Usar zohoapis.eu/workdrive/api/v1/download/{id}, o mesmo host da listagem.

03

O info não aparece no log

Em funções de botão, o log exportado traz "Info": "-" mesmo com dezenas de info no código. Depurar às cegas custa horas.

Gravar o diagnóstico num campo de texto do próprio registro e ler na tela.

04

A extensão do arquivo mente

Um Signature.jpeg era PNG. A API de inferência valida os bytes e recusa a requisição inteira por causa de um arquivo.

Detectar o media_type pelo prefixo do base64 e tratar a extensão só como pré-filtro.

05

O editor embaralha o código ao colar

Colar por cima do conteúdo existente faz o editor auto-inserir chaves, misturando blocos e gerando erro numa linha que não existe no original.

Sempre esvaziar o editor antes de colar.

06

O botão some com o zoom alto

Botões customizados ficam na barra de ações do topo, não no menu de três pontos. Com o zoom do navegador alto, a barra colapsa e o botão desaparece sem aviso.

Verificar em 100% antes de concluir que a configuração está errada.

Limites

Onde essa abordagem para

Rodar tudo dentro do CRM elimina infraestrutura, mas impõe tetos. Vale conhecê-los antes de escolher esse caminho.

LimiteMotivoSaída
9 arquivos por execução teto de chamadas HTTP por função middleware externo
PDF, JPG e PNG formatos aceitos via base64 converter antes de enviar
30 a 90 segundos chamadas sequenciais e síncronas processamento assíncrono
Ação síncrona o usuário espera o botão retornar fila + notificação

Tem um CRM cheio de
documentos parados?

Esse caso saiu do zero ao ar em um dia, sem infraestrutura nova. Se o seu processo tem um passo de "alguém precisa ler tudo isso antes", provavelmente dá para fazer o mesmo.

Conversar sobre o seu caso