Docs›IA & MCP›AI Copilot (embed)
IA & MCP

AI Copilot (embed)

Widget de chat SSE embutido no shell: tools MCP in-process, trava de confirmação de escrita, cotas de custo.

AI Copilot (embed)

O Copilot é o widget de chat com IA embutido no shell admin do MAD — o ícone 🤖 na barra superior (notch) abre um painel que carrega um iframe apontando para [ai] embed_frontend_url. É distinto da Central de Comando: o Copilot é para usuários finais do seu app conversarem com os próprios dados de negócio (via tools do servidor MCP); a Central de Comando é uma ferramenta admin-only que opera o app em si (código, menu, permissões).

As duas superfícies compartilham o mesmo motor por baixo (AgentRunner, ConfirmCoordinator, ConversationStore, SseSink, MadAi) — a diferença está só em quais tools cada uma recebe e em como a confirmação de escrita é re-validada.

Habilitando

// config/mad.php
'ai' => [
    'enabled'            => (bool) env('MAD_AI_ENABLED', false), // off => /embed/v1/* nem registra
    'apply_enabled'      => (bool) env('MAD_AGENT_APPLY_ENABLED', false), // agente APLICA mudancas
    'proxy'              => (bool) env('MAD_AI_PROXY', false),   // veja "Modo proxy" abaixo
    'coding_plan'        => (bool) env('MAD_AI_CODING_PLAN', false), // stream direto no Node do builder
    'embed_stream_url'   => env('MAD_AI_EMBED_STREAM_URL', 'http://localhost:3000/embed-llm/chat'),
    'provider'           => 'openrouter',                        // ou 'anthropic'
    'max_steps'          => '8',
    'max_tokens'         => '8192',
    'embed_frontend_url' => env('MAD_AI_EMBED_FRONTEND_URL', 'http://localhost:7400/embed.html'),
    // Micro-BI (widgets salvos + tela "Meus Dashboards") — ON por padrao,
    // herdando ai.enabled. Veja Dashboards de IA (embed).
    'widgets_enabled'     => (bool) env('MAD_AI_WIDGETS_ENABLED', true),
    'widgets_full_schema' => (bool) env('MAD_AI_WIDGETS_FULL_SCHEMA', false),
    'dashboards_frontend_url' => env('MAD_AI_DASHBOARDS_FRONTEND_URL', 'http://localhost:7400/dashboard.embed.html'),
    'anthropic_api_key'  => getenv('ANTHROPIC_API_KEY') ?: '',
    'anthropic_model'    => 'claude-haiku-4-5',
    'openrouter_api_key' => getenv('OPENROUTER_API_KEY') ?: '',
    'openrouter_model'   => 'google/gemma-4-31b-it:free', // modelos :free NAO fazem tool use
],

Precedência dos três modos de streaming: coding_plan > proxy > in-process (o app chama o provider ele mesmo).

Com a flag ligada, o token do widget é cunhado no login (LoginForm::doLogin): McpTokenService::issueEmbed($login) gera um token mcp_embed_… e guarda em session('embed_copilot_token'), revogando qualquer token anterior do mesmo usuário. O shell reusa esse token enquanto estiver ativo e reemite sozinho se ele expirar ou for revogado — você não precisa gerenciar isso manualmente.

A configuração efetiva do modelo, porém, tem uma camada acima do config/mad.php: a tela de Preferências do sistema (ai_provider, ai_anthropic_model, ai_openrouter_model, ai_max_steps, ...) tem prioridade sobre o config, que por sua vez tem prioridade sobre variáveis de ambiente. MadAi::boot() resolve essa precedência a cada boot e monta config('ai') (o namespace do pacote laravel/ai — não confundir com config('mad.ai'), que é só a camada de fallback do MAD).

Endpoints

Endpoint Auth O quê
POST /embed/v1/chat Bearer (token MCP) turno do agente → stream SSE
GET /embed/v1/conversations Bearer lista as conversas do usuário do token
GET /embed/v1/conversations/{id} Bearer mensagens/transcript de 1 conversa (404 se for de outro usuário)
GET /embed/v1/token Bearer mint do JWT do embed (server-to-server) quando coding_plan está ligado — EmbedTokenController
GET /embed/v1/tools Bearer defs de tool no formato Anthropic (name/description/input_schema) — loop de tools client-side do coding plan
POST /embed/v1/tool-call Bearer executa uma tool {name, args} → {ok, result, ms} (EmbedToolsController)

Com [ai] widgets_enabled ligado (default), o mesmo grupo registra os endpoints do micro-BI — favoritos, widgets salvos e dashboards. Eles têm página própria: Dashboards de IA (embed).

Endpoint O quê
GET/POST /embed/v1/favorites, DELETE /embed/v1/favorites/{id}, POST /embed/v1/favorites/{id}/run blocos do chat salvos como favorito + replay sem LLM
GET /embed/v1/widgets, GET /embed/v1/widgets/{id}/render, PATCH/DELETE /embed/v1/widgets/{id} widgets de BI salvos (spec determinística)
GET/POST /embed/v1/dashboards, GET/PATCH/DELETE /embed/v1/dashboards/{id}, POST /embed/v1/dashboards/{id}/home, PUT /embed/v1/dashboards/{id}/share, GET /embed/v1/share-targets dashboards do usuário, tela inicial e compartilhamento

Middleware: McpManifestAuthMiddleware — o mesmo do servidor MCP (resolve o Bearer para um SystemUser, popula sessão/permissões). CORS é deliberadamente liberal (Access-Control-Allow-Origin: *) porque o iframe é genuinamente cross-origin — a fronteira de segurança real é o token, não a origem. embed/v1/* é a única exceção de validateCsrfTokens (bootstrap/app.php): o fluxo é stateless/cross-origin, sem cookie de sessão.

Fluxo de streaming SSE

Cada frame é data: {"type": "...", "data": {...}}\n\n, terminado por data: [DONE]\n\n. Tipos de evento:

type Quando
text_delta pedaço de texto do modelo
tool_use_start uma tool MCP começou a rodar (card "executando…")
tool_use_end resultado da tool ({tool, params, ms, result, ok}) — substitui o card
block bloco visual completo ({type: 'kpis'|'bar'|'table'|...}) — veja Blocos visuais
usage tokens do turno (prompt, completion, cached, cache_write)
message_end fim da resposta do turno
error erro amigável (limite de cota, falha do provider, etc.)

Exemplo — turno de leitura

curl -N -X POST http://localhost:8912/embed/v1/chat \
  -H "Authorization: Bearer mcp_embed_xxx" -H "Content-Type: application/json" \
  -d '{"message":"quantos clientes temos em SP?"}'
data: {"type":"tool_use_start","data":{"tool":"list_clientes","params":{"filtros":{"uf":"SP"},"limite":500},"running":true}}
data: {"type":"tool_use_end","data":{"tool":"list_clientes","params":{...},"ms":6,"result":{"total":1,"rows":[{"id":5,"nome":"Construtora Horizonte","email":"contato@***",...}]}}}
data: {"type":"text_delta","data":{"text":"**1 cliente em SP.**"}}
data: {"type":"usage","data":{"prompt":1564,"completion":10,"total":1574}}
data: {"type":"message_end","data":[]}
data: [DONE]

O modelo montou o filtro uf=SP sozinho; a tool rodou in-process (6ms), PII já veio mascarada (contato@***).

Tools MCP in-process

McpToolCaller executa as tools do manifesto MCP sem round-trip de rede — chama $tool->handle() diretamente no mesmo processo PHP, reusando o mesmo McpToolFactory do servidor externo. Como a instância já é filtrada por shouldRegister() sob o McpCurrentUser da sessão, permissão (piso+matriz), mascaramento de PII e auditoria continuam acontecendo dentro da própria tool — o caller só orquestra e cronometra.

Nomes de tool no manifesto usam ponto (ns.slug, ex.: clientes.del_cliente) mas a Anthropic (direta, via OpenRouter, Bedrock ou Vertex) exige nomes de tool batendo com ^[a-zA-Z0-9_-]{1,128}$ — McpToolCaller::publicName() reescreve para ns__slug só para o loop do agente; o servidor MCP externo continua usando os nomes canônicos.

Disponibilidade de tool = manifesto × permissão do usuário corrente — o mesmo conjunto que tools/list retornaria para esse token no servidor MCP.

Trava de confirmação de escrita

O loop do agente nunca escreve. Para create/update/del (ou qualquer tool marcada confirm), McpDataTool::handle() faz self-gate: em vez de chamar a tool, registra a chamada pendente (ConfirmCoordinator::stash()) e emite um bloco confirm por SSE — a execução real só acontece no próximo turno, de forma determinística, sem chamar o modelo de novo.

# Turno 2 — "crie um cliente chamado Teste E2E"
data: {"type":"text_delta","data":{"text":"Vou criar o cliente com os dados fornecidos."}}
data: {"type":"block","data":{"type":"confirm","id":"confirm-01754ed436c7","tool":"create_cliente",
       "title":"Confirmar ação: create_cliente","danger":false,
       "fields":[{"k":"nome","v":"Teste E2E"},{"k":"email","v":"teste@e2e.com"}]}}
data: {"type":"text_delta","data":{"text":"Um cartão de confirmação foi exibido."}}
data: [DONE]

Nesse ponto nada foi escrito no banco. O front então envia um segundo POST, desta vez com confirm em vez de message:

curl -N -X POST .../embed/v1/chat -H "Authorization: Bearer ..." \
  -d '{"confirm":{"id":"confirm-01754ed436c7","approved":true}}'
# Turno 3 — aprovação
data: {"type":"tool_use_start","data":{"tool":"create_cliente","params":{"nome":"Teste E2E",...},"running":true}}
data: {"type":"tool_use_end","data":{...,"ms":6,"result":{"created":true,"id":"13"}}}
data: {"type":"text_delta","data":{"text":"Acao \"create_cliente\" executada com sucesso."}}
data: [DONE]

O id confirm-<hex> correlaciona a aprovação com a chamada original — ele é persistido em mad_ai_conversation.pending_json entre as duas requisições HTTP (cada turno SSE é uma requisição própria). Antes de executar, o controller revalida a permissão sob o usuário do request atual (McpPermissionResolver::canUseTool()) — approved: true vindo do front, sozinho, nunca é suficiente. Reprovar (approved: false) cancela sem tocar no banco. É possível também enviar values no payload de confirmação para o usuário editar campos antes de aprovar (mesclados sobre os args originais).

Blocos visuais

Além das tools de dados, o agente recebe um conjunto fixo de render tools (show_kpis, show_bar_chart, show_line_chart, show_donut_chart, show_table, show_list, show_gauge, show_funnel, show_timeline, show_progress, show_callout, show_detail, show_dashboard, confirm_action, entre outras) que emitem blocos type: "block" no SSE em vez de o modelo despejar dados como markdown cru. show_dashboard agrupa blocos já emitidos no mesmo turno (por referência de id) num grid com filtros — útil para o modelo montar uma visão composta sem reemitir JSON inteiro. Cada bloco "data-aware" carrega uma source: {tool, args} que permite ao front oferecer "salvar como favorito" (reexecuta a mesma consulta depois, sem LLM).

Tools de interação e de BI

Além das tools de dados (manifesto MCP) e das render tools, o EmbedChatController monta mais um conjunto fixo:

Tool Classe O quê
ask_user Mad\Ai\Tools\AskUserTool pergunta bloqueante com até 4 opções clicáveis — emite bloco callout + follow chips (SseSink::setFollow) e o turno encerra; a escolha volta como mensagem nova
suggest_next Mad\Ai\Tools\SuggestNextTool 2–4 próximos passos como chips no message_end — irmã leve, sem semântica de bloqueio
db_schema Mad\Ai\Tools\DbSchemaTool tabelas/colunas consultáveis + dialeto SQL; por default só o que o manifesto MCP expõe ([ai] widgets_full_schema abre o banco inteiro)
preview_widget / save_widget Mad\Ai\Tools\WidgetUpsertTool (uma instância por modo) recebe o WidgetSpec {type, sql, map, style}, valida a SQL (WidgetSqlGuard), executa, emite o bloco; em save, persiste no WidgetStore
list_widgets Mad\Ai\Tools\ListWidgetsTool lista os widgets salvos do usuário (id/título/tipo) para atualizar via save_widget.widgetId
save_dashboard Mad\Ai\Tools\SaveDashboardTool monta um dashboard persistente (até 12 widgets) a partir de widgets já salvos

As quatro últimas só entram quando [ai] widgets_enabled está ligado (default true) e independem do manifesto — db_schema degrada para introspecção nativa quando não há manifesto. Detalhe do ciclo em Dashboards de IA (embed).

Custo e cotas

Todo turno grava uma linha em mad_ai_token_usage (UsageLog::record) com tokens (prompt/completion/cache_read/cache_write) e, quando o provider informa (OpenRouter sim, Anthropic direto não), cost_usd — a mesma tabela que alimenta a tela "Consumo de IA".

Antes de chamar o modelo, Mad\Usage\QuotaService::assertWithinQuota() soma o consumo do chamador contra as regras cadastradas na tela "Cotas de IA":

  • Escopo da regra, do mais específico ao mais genérico: token > user > group > unit > global. Regra de contexto específico (ex.: embed_chat) vence regra de contexto nulo no mesmo escopo.
  • Período avaliado independentemente: day / month / total.
  • Ação: block (barra a chamada) ou warn (só sinaliza).
  • Sem regra que combine = ilimitado (degrada seguro).

A checagem é pré-chamada: como o custo da chamada atual ainda é desconhecido, ela bloqueia só quem já está no limite — a chamada que cruza a linha roda (e é contabilizada); a próxima é que é barrada. Estouro de regra block interrompe o turno antes de montar tools/chamar o modelo, emitindo error com a mensagem amigável ("Limite diario de uso de IA atingido (1.500/1.000 tokens).") — sem custo de chamada extra. Falha de infraestrutura no próprio serviço de cota não bloqueia o chat (fail-open) — só QuotaExceededException barra de fato.

Modo proxy

Quando o app foi gerado pelo MadBuilder e não tem chave de IA própria, [ai] proxy (env MAD_AI_PROXY) encaminha o chat para a nuvem: o app local não chama o provedor diretamente — faz POST {MAD_BUILDER_URL}/api/app/ai/chat autenticado com MAD_PROJECT_TOKEN e repassa o stream (passthrough). A chave de IA fica só no MadBuilder, que debita o consumo na conta do desenvolvedor.

Testando manualmente

MAD_AI_ENABLED=true OPENROUTER_API_KEY=sk-or-... php artisan serve --port=8912

php artisan tinker --execute='echo App\Service\Mcp\McpTokenService::issue("admin","manual","mcp_embed_").PHP_EOL;'

curl -N -X POST http://localhost:8912/embed/v1/chat \
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
  -d '{"message":"quem é você? responda em uma frase."}'

curl -s http://localhost:8912/embed/v1/conversations -H "Authorization: Bearer <TOKEN>"

artisan serve é single-thread — para testar SSE junto com outras requisições simultâneas, use PHP_CLI_SERVER_WORKERS=4.

Veja também