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.
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.
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ãoif(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";
}
elseif(nomeLower.endsWith(".jpg") || nomeLower.endsWith(".jpeg"))
{
tipo = "image";
mtype = "image/jpeg";
}
elseif(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";
}
elseif(b64.startsWith("iVBORw0KGgo"))
{
tipoReal = "image";
mtypeReal = "image/png";
}
elseif(b64.startsWith("/9j/"))
{
tipoReal = "image";
mtypeReal = "image/jpeg";
}
elseif(b64.startsWith("R0lGOD"))
{
tipoReal = "image";
mtypeReal = "image/gif";
}
elseif(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.
Limite
Motivo
Saí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.