Pular para o conteúdo
MÓDULO 4.1

🛡️ Mods que mudam o fluxo

Até aqui o mod observou e desenhou. Agora ele decide: barra uma chamada de ferramenta, pergunta ao humano antes de apagar, troca o modelo de um subagente. Tudo isso sem passar por cima das permissões que a pessoa já configurou.

6
Tópicos
~35
Minutos
Avançado
Nível
Prático
Tipo
0 de 60%
Versão conferida: os trechos deste módulo vêm do Claude Code 2.1.289 (05/10/2026), da referência oficial e dos mods do inema-mods. A API está em acesso antecipado: confira cada nome no .claude-plugin/types/claude-code/index.d.ts da sua instalação.
1

Intercepte uma chamada de ferramenta

Todo mod que muda o fluxo começa no mesmo lugar: o evento tool.call. Ele dispara quando o Claude Code está prestes a rodar uma ferramenta (Bash, Edit, Write, Read, uma ferramenta MCP).

Os tipos dizem o que acontece por baixo: next(e) roda os hooks dos outros plugins e depois o núcleo, que é o pedido de permissão e a própria ferramenta. O seu hook fica antes de tudo isso, com três saídas possíveis.

modelo pedeBash: rm -rf build tool.callo seu hook { deny } outrosplugins permissãoregras e diálogo ferramentaroda { result } next(e): o caminho normal, com permissão { result }: pula permissão e ferramenta

Como ler o desenho: o hook âmbar está antes da permissão. Para cima, { deny } encerra a chamada. Para a direita, next(e) segue o caminho de sempre. Para baixo, { result } responde no lugar da ferramenta: é o único caminho que pula o pedido de permissão, por isso fica pontilhado.

🆕 Novo aqui? As respostas de um tool.call

  • { deny: motivo } — recusa a chamada. O modelo recebe o texto como resultado de erro.
  • { result } — responde sem rodar a ferramenta. É como um mod serve a própria ferramenta MCP (módulo 3.4).
  • next(e) / next({ ...e, command }) — deixa seguir, igual ou reescrito.
📄 plugin-authoring/examples/tool-call.ts (exemplo oficial, inteiro)
import type { Register } from 'claude-code'

const PROTECTED = /(^|\/)\.env(\.|$)/

export const register: Register = on => {
  on('tool.call', { tool: 'Edit' }, ($, e, next) =>
    PROTECTED.test(e.file_path)
      ? { deny: `${$.plugin.name}: ${e.file_path} is protected here.` }
      : next(e),
  )

  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    const ran = await next({ ...e, command: e.command.trim() })
    const hasFailed = ran.deny === undefined && ran.isError === true

    $.ui.status(hasFailed ? `failed: ${e.command.slice(0, 40)}` : undefined)

    return ran
  })
}
O que olhar: o matcher { tool: 'Edit' } estreita o e: dentro do hook, e.file_path já tem tipo. O segundo hook reescreve o comando (tira espaços) e só depois olha o resultado.

✓ Bom uso do matcher

  • ✓ { tool: 'Bash' } para analisar comandos
  • ✓ Um hook por ferramenta de edição (Edit, Write, NotebookEdit)
  • ✓ Sem matcher só quando a regra vale para todas (ex.: ferramentas MCP pagas)

✗ Armadilhas

  • ✗ Hook sem matcher que trata todo e como Bash
  • ✗ Devolver antes do next terminar: aborta o que roda por baixo
  • ✗ Contar sucesso antes de await next(e)
2

Negue com motivo claro

O texto do deny não é para você: é para o modelo. Ele lê a recusa como resultado de erro da ferramenta e decide o próximo passo com base nela.

Um "negado" seco faz o modelo tentar de novo por outro caminho: find -delete no lugar de rm, um script Python que apaga. Um bom motivo diz quem barrou, o que teria acontecido e o que fazer agora.

Parte do motivoPara quêNo freio-de-mao
Quem barrouo modelo e a pessoa sabem que foi um mod, não um errofreio-de-mao: no começo
O que aconteceriao modelo explica o risco com númerosEle faria: ${resumo}
O que fazer agorafecha a porta do "outro caminho"Não tente apagar por outro caminho
📄 inema-mods/mods/freio-de-mao/hooks/register.tsx (trecho: o deny do Cancelar)
    // Cancelar ou texto livre: não roda.
    return {
      deny: `freio-de-mao: o usuário cancelou. O comando faria: ${resumo}. Não tente apagar por outro caminho; pergunte ao usuário como seguir.`,
    }
O que olhar: as três partes da tabela numa frase só. O resumo vem da medição feita antes (quantos arquivos, quanto espaço).

✓ Motivo que funciona

  • ✓ Começa com o nome do mod
  • ✓ Diz o efeito que foi evitado
  • ✓ Manda devolver a decisão ao usuário

✗ Motivo que gera insistência

  • ✗ "negado"
  • ✗ "erro" (parece falha passageira)
  • ✗ Texto que não diz o que fazer em seguida

💡 Negar é sempre seguro

Um deny só tira poder: a ferramenta não roda e ninguém precisa aprovar nada. Por isso ele é o padrão certo quando o mod não sabe o que fazer (sem tela, medição que falhou, backup que deu erro).

3

Pergunte ao humano com ui.ask

Entre deixar e negar existe perguntar. $.ui.ask(pergunta, opções) abre o mesmo diálogo que o Claude usa para fazer perguntas e devolve o rótulo escolhido, ou o texto livre digitado em "Other".

Segundo os tipos, a chamada rejeita quando a pessoa dispensa o diálogo e numa execução -p, onde não há ninguém para perguntar. É por isso que todo ui.ask do kit INEMA termina em .catch.

🆕 Novo aqui? AskOptions

O segundo argumento do $.ui.ask é uma lista de 2 a 4 rótulos ou um objeto { options, header, multiSelect }. O header é a etiqueta curta ao lado da pergunta (até 12 caracteres). Com multiSelect, a resposta volta com os rótulos separados por vírgula.

📄 inema-mods/mods/vigia-api/hooks/register.tsx (trecho da função vigiar)
  const resposta = await $.ui
    .ask(`Isso usa serviço pago (${achado.nome}). Autoriza agora?`, {
      header: 'API paga',
      options: [NEGAR, SO_ESTA, POR_HORA],
    })
    .catch(() => undefined)
  if (resposta === undefined) {
    return {
      deny:
        `vigia-api: "${achado.nome}" usa serviço pago e não há ninguém na tela para autorizar. ` +
        'Não tente por outro caminho: peça autorização ao usuário em texto, dizendo qual serviço e para quê. ' +
        `Ele pode liberar com /vigia autorizar ${achado.padrao.trim()}`,
    }
  }
O que olhar: NEGAR é a primeira opção. O .catch transforma "sem tela" em undefined, e undefined vira um deny explicado.

⚠️ A opção segura vai em primeiro lugar

O diálogo pode se resolver sozinho quando a pessoa está longe do teclado (o resultado do AskUserQuestion traz afkTimeoutMs nesse caso). O COMO-FAZER-UM-MOD.md do inema-mods registra isso em "Visto ao vivo": ponha Cancelar ou Negar primeiro, para que uma resposta automática nunca seja a destrutiva.

🥇
Seguro primeiro

Cancelar, Negar

🧯
.catch

claude -p e Esc

🟰
Compare exato

texto livre existe

🏷️
header

até 12 caracteres

4

Troque o modelo de um subagente

Mudar o fluxo não é só barrar. O Rafa quer que as buscas do subagente Explore rodem num modelo menor, para poupar cota. Os dois kits resolvem isso em pontos diferentes da cadeia.

O roteador-subagente do inema-mods age uma vez, quando o subagente nasce, no evento agent.spawn. O model-router do kit da Prompt Advisers age passo a passo, no stream turn.step.

🆕 Novo aqui? agent.spawn

Evento que dispara quando um subagente vai ser criado. O e traz subagentType (Explore, general-purpose...) e model, o parâmetro como o modelo pediu na chamada do Agent. Os tipos dizem: um hook define model para escolher o modelo do subagente; forks sempre herdam e ignoram esse campo.

📄 inema-mods/mods/roteador-subagente/hooks/register.tsx (trecho)
  on('agent.spawn', async ($, e, next) => {
    // Respeita o que foi pedido: modelo explícito, fork (sempre herda) e teammate.
    if (e.model !== undefined || e.fork || e.isTeammate === true) return next(e)
    const alvo = destino(await read($, modo), mapa, e.subagentType)
    if (alvo === undefined) return next(e)
    const r = await next({ ...e, model: alvo })
    const id = r.agentId
    if (id !== undefined) await update($, roteados, x => ({ ...x, [id]: alvo }))
    return r
  })
O que olhar: a primeira linha útil é de respeito. Se alguém pediu um modelo explicitamente, o mod não mexe. Só depois ele reescreve model e passa adiante com next.
roteador-subagente (inema-mods)model-router (Prompt Advisers, MIT)
Eventoagent.spawnturn.step (stream, async function*)
Quando decideuma vez, ao criar o subagentea cada passo; pelo e.agentId sabe se é subagente
Como trocanext({ ...e, model: alvo })yield* next(...) com a entrada reescrita
Arquivohooks/register.tsxplugins/model-router/hooks/model-router.mjs

O que olhar na tabela: o agent.spawn é mais simples e basta para "este tipo de subagente usa aquele modelo". O turn.step dá controle fino (até passos do turno principal), mas exige tratar stream, assunto do módulo 2.1.

✓ Troque quando

  • ✓ O modelo não foi pedido na chamada
  • ✓ O tipo de subagente está no mapa do /config
  • ✓ O usuário ligou o roteador (/router on)

✗ Não troque

  • ✗ Fork (sempre herda o modelo)
  • ✗ Teammate
  • ✗ Modelo escolhido explicitamente
5

Preserve as permissões do usuário

A pessoa já tem regras: o que o Claude pode rodar sem perguntar, o que sempre pede aprovação, hooks clássicos de PreToolUse. Um mod de controle deve somar a essas regras, nunca contorná-las.

A regra prática sai do desenho do tópico 1: para ferramentas do próprio Claude Code, termine sempre em next(e) ou em { deny }. Uma reescrita (next({ ...e, command: novo })) também passa pela permissão, já com o comando novo.

CamadaOnde ficaPode
Hooks das configurações gerenciadasantes de tudoum deny deles é o resultado final
Seu tool.callacima da permissãodeny, reescrever, next
classic.PreToolUsedentro do tool.call, abaixo de todos os pluginsallow, ask, deny ou nada
Diálogo de permissãodesenhado só pelo engineo mod só acrescenta uma linha com $.ui.notice

⚠️ Recusa de permissão chega como erro

Pegadinha 22 do COMO-FAZER-UM-MOD.md: quando a pessoa recusa a permissão, o tool.call recebe isError: true, igual a uma ferramenta que falhou. Não dá para separar os dois. O teste certo de sucesso é o da pegadinha 6: ran.deny === undefined && ran.isError !== true.

📄 inema-mods/mods/guarda-colisao/hooks/register.tsx (trecho)
  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
    const barrar = await decidir($, e.file_path, cfg)
    if (barrar) return barrar
    const ran = await next(e)
    if (ran.deny === undefined && ran.isError !== true) await marcar($, e.file_path, cfg)
    return ran
  })
O que olhar: ou deny, ou next(e). O mod nunca responde { result } por uma edição, então a regra de permissão da pessoa continua decidindo. E só marca o arquivo quando a edição de fato aconteceu.

💡 Perguntar sem abrir outro diálogo

Se a ferramenta já vai pedir permissão, às vezes basta enriquecer esse pedido: $.ui.notice(e.tool_use_id, "conferido pelo meu-mod") mostra uma linha sob o diálogo aberto e o engine a remove quando a chamada termina. E $.tool.check pergunta qual seria a decisão de permissão sem rodar nada.

6

Estude a guarda do freio-de-mão

O freio-de-mao junta tudo deste módulo. Antes de um Bash que apaga ou descarta (rm -rf, git reset --hard, git clean, push --force), ele mede o estrago sem apagar e pergunta: Cancelar, Mandar para a lixeira, Fazer backup e prosseguir, ou Prosseguir.

O hook inteiro tem umas 60 linhas em mods/freio-de-mao/hooks/register.tsx. As funções que recebem $ (medir, registrar, fazerBackup) ficam no topo do arquivo, fora do register (pegadinha 1).

Bashrm -rf build analisarhá perigo? não: next(e) medir$.process.run ui.ask.catch sem tela / Cancelar: deny Lixeira: next(reescrito) Backup: copia, next(e) Prosseguir: next(e) toda decisão é registrada em $.state e aparece no /freio

Como ler o desenho: o caminho azul só chega ao ui.ask quando há algo a perder. Das quatro saídas, só uma (vermelha) nega; as outras terminam em next, então a permissão da pessoa ainda decide depois.

1

analisar(e.command, cwd)

Regra pura, sem $, em hooks/regras.ts. Lista vazia: return next(e).

2

medir($, p)

Conta arquivos e tamanho com $.process.run, com timeoutMs. Nada a perder (alvo não existe): segue sem perguntar.

3

Monta as opções

[CANCELAR, ...lixeira, ...backup, PROSSEGUIR]: Cancelar sempre primeiro; Lixeira e Backup só quando dá.

4

$.ui.ask(...).catch(() => undefined)

Sem tela: a opção sem_tela do /config decide; o padrão é negar.

5

registrar($, ...)

Guarda a decisão com update($, paradas, ...) e a hora de $.clock.now().

6

Age conforme a resposta

Lixeira: next({ ...e, command: novo }). Backup que falha: deny, o comando não roda. Cancelar: deny com o resumo.

🎯 Objetivo: ver o freio-de-mão passar nos próprios testes

No terminal, dentro da pasta inema-mods que você clonou:

claude plugin validate mods/freio-de-mao
claude plugin test mods/freio-de-mao

Depois abra claude na mesma pasta e cole:

Leia mods/freio-de-mao/hooks/register.tsx e mods/freio-de-mao/tests/freio.test.ts. Para cada saída do hook de tool.call (deny ou next), me diga qual teste a cobre. Não rode nem instale nada.
Como verificar: o validate termina em ✔ Validation passed; o test termina com uma linha N pass e outra 0 fail. Na resposta do Claude, os testes "Cancelar", "sem tela: padrão nega" e "backup que falha" aparecem ligados a um deny.

Teste rápido (opcional): o mod do Rafa pergunta antes de um git push --force. Numa execução claude -p, o que deve acontecer?

🎓 Resumo do módulo

✓
tool.call fica antes da permissão — deny, next ou result.
✓
O deny é lido pelo modelo — quem barrou, o que evitou, o que fazer.
✓
ui.ask com .catch e opção segura primeiro — claude -p não tem tela.
✓
agent.spawn troca o modelo do subagente — respeitando o pedido explícito.
✓
Nunca { result } por ferramenta do engine — a permissão continua decidindo.

Próximo módulo:

4.2 — Testar com claude plugin test