Plugins
Extiende Pepe con herramientas y canales propios instalando plugins con su propia configuración.
Un plugin añade una herramienta que el modelo puede invocar, un
proveedor de canal (una nueva plataforma de mensajería basada en
webhook), un canal de conexión persistente (uno que necesita un websocket
de larga duración, no solo un webhook - ver Slots), un
adaptador de protocolo de modelo, un observador de ejecución (observa
el bucle de llamadas a herramientas de un agente desde fuera, de solo
lectura), o ocupa un slot (búsqueda de memoria, búsqueda
web) - todo Elixir compilado en tiempo de ejecución desde ~/.pepe/plugins/,
sin rebuild. Un módulo se compara con la(s) forma(s) que implementa; esta
página cubre en profundidad las dos primeras, con diferencia las más comunes,
más un vistazo más breve al observador de ejecución más abajo.
El behaviour Tool
@callback name() :: String.t()
@callback spec() :: map()
@callback run(args :: map(), ctx :: map()) ::
{:ok, String.t()} | {:error, String.t()}
| Callback | Propósito |
|---|---|
name/0 |
El nombre de función que invoca el modelo, por ejemplo "read_file". Debe ser único entre todas las herramientas: en caso de conflicto de nombre, la herramienta integrada siempre prevalece. |
spec/0 |
La especificación de función al estilo OpenAI: nombre, descripción en lenguaje llano y un JSON Schema para los parámetros. Es lo que el modelo lee para decidir cuándo y cómo invocar la herramienta. |
run/2 |
Ejecuta la llamada. args son los argumentos decodificados (un mapa con claves de tipo cadena); ctx lleva el contexto de la ejecución actual (abajo). Devuelve {:ok, text} o {:error, message}: en cualquier caso se convierte en cadena y vuelve al modelo, así que escríbelo para que el modelo lo lea. |
Pepe.Tools.Tool.function/3 construye el sobre de la especificación por ti,
así que solo rellenas el nombre, la descripción y los parámetros.
Una herramienta completa y funcional: guárdala como un .exs e instálala
(ver abajo):
defmodule MyPlugin.Reverse do
@behaviour Pepe.Tools.Tool
import Pepe.Tools.Tool, only: [function: 3]
@impl true
def name, do: "reverse_text"
@impl true
def spec do
function("reverse_text", "Reverse the characters in a piece of text.", %{
"type" => "object",
"properties" => %{
"text" => %{"type" => "string", "description" => "The text to reverse."}
},
"required" => ["text"]
})
end
@impl true
def run(%{"text" => text}, _ctx) do
{:ok, String.reverse(text)}
end
def run(_args, _ctx), do: {:error, "missing 'text'"}
end
La segunda cláusula de run/2 es buena práctica: si el modelo omite un
argumento obligatorio, devuelve un error claro en vez de fallar (un fallo
también se captura, pero un mensaje a medida ayuda al modelo a recuperarse en
la siguiente vuelta).
ctx, el segundo argumento de run/2, lleva la ejecución actual:
ctx[:agent] (el agente en ejecución, por ejemplo %{name: "assistant"}),
ctx[:session_key] (la conversación en vivo, ausente en ejecuciones de un
solo turno), ctx[:cwd] (el directorio de trabajo). Trata cada clave como
opcional. Las herramientas que leen/escriben archivos resuelven rutas con
Pepe.Agent.Workspace; las que llaman a una API externa suelen ignorar ctx
por completo y usar directamente el cliente HTTP Req ya incluido, sin
dependencia extra.
El behaviour Channel provider
Un proveedor de canal le enseña a Pepe a hablar una nueva plataforma de mensajería sobre el webhook de entrada genérico ya existente: ninguna ruta nueva, solo un módulo nuevo en el registro.
@callback name() :: String.t()
@callback verify(config :: map(), params :: map()) :: {:ok, String.t()} | :error
@callback authenticate(config :: map(), raw_body :: binary(), headers :: map()) :: :ok | :error
@callback parse(payload :: map()) :: {:ok, [inbound]} | :ignore
@callback deliver(config :: map(), to :: String.t(), text :: String.t()) :: :ok | {:error, term()}
| Callback | ¿Obligatorio? | Propósito |
|---|---|---|
name/0 |
sí | Clave de registro y el segmento :provider de la URL del webhook, ej. "whatsapp". |
verify/2 |
sí | Responde el handshake GET de la plataforma cuando registras la URL del webhook. {:ok, challenge} o :error si el proveedor no tiene uno. |
authenticate/3 |
sí | Comprueba la firma de un POST entrante contra el secreto de la conexión. :ok para aceptar, :error para descartarlo. |
parse/1 |
sí | Normaliza un payload decodificado en cero o más mensajes %{from, text, id}, o :ignore para lo que no tiene nada que hacer (recibos, actualizaciones de estado). |
deliver/3 |
sí | Envía una respuesta de texto a to (una dirección del proveedor: número de teléfono, id de canal, …). |
label/0 |
no | Etiqueta humana para el panel (usa name/0 por defecto). |
config_schema/0 |
no | Campos que el panel renderiza para configurar una conexión: la misma forma que el array config de un manifiesto de plugin (abajo). |
respond/3 |
no | Una respuesta HTTP síncrona al POST sin procesar, para protocolos que necesitan una antes de cualquier trabajo del agente (el desafío de verificación de URL de Slack, el PING de Discord). {:reply, status, content_type, body} o :cont para caer en parse/1. |
deliver_file/4 |
no | Envía un archivo como adjunto. Omítelo y send_file simplemente reporta que el canal no recibe archivos. |
addressed?/2 |
no | ¿Este payload se dirige al bot, así que debería recibir respuesta? Permite que un proveedor honre require_mention en grupos (por defecto cuando se omite: siempre dirigido). |
deliver_blocks/3 |
no | Renderiza contenido estructurado (ver Bloques de presentación abajo) en la UI nativa de la plataforma. Omítelo y la tool send_presentation igual entrega - aplanado a texto simple vía deliver/3. |
Bloques de presentación
Una tool puede enviar contenido más rico que texto simple - una tabla, una fila de
botones - a través de la tool send_presentation y el schema de bloque compartido
Pepe.Presentation:
%{"type" => "text", "text" => "..."}
%{"type" => "table", "headers" => [...], "rows" => [[...], ...]}
%{"type" => "buttons", "buttons" => [%{"label" => "...", "value" => "..."}]}
Slack renderiza esto como Block Kit real hoy (una section por bloque de texto/tabla,
un bloque actions con botones reales). Un proveedor que aún no añadió
deliver_blocks/3 sigue recibiendo el contenido - Pepe.Presentation.to_text/1 lo
aplana a texto simple legible, enviado por el deliver/3 normal del proveedor - así que
una tool que envía bloques funciona en cada canal de inmediato, de forma rica solo donde
un proveedor se tomó el trabajo de renderizarlos.
El behaviour PluginRoute - la ruta HTTP propia de un plugin
El contrato de eventos entrantes de Pepe.Webhooks.Provider es fijo - una sola forma,
para plataformas de chat. Pepe.PluginRoute es para lo que necesita la suya propia: un
callback de redirección OAuth que debe caer en el dominio público del propio Pepe, un
endpoint REST/RPC a medida.
@callback route_prefix() :: String.t()
@callback call(conn :: Plug.Conn.t(), path :: [String.t()]) :: Plug.Conn.t()
call/2 recibe el Plug.Conn sin procesar (ya pasado el parseo de body del propio
endpoint) y los segmentos de ruta después de tu propio prefijo - control total, igual que
cualquier Plug escrito a mano, ya que Pepe no puede anticipar cada forma que necesite el
propio protocolo de un plugin. Un call/2 que falla responde 500, nunca se lleva por
delante el proceso de la petición (ni ninguna otra cosa).
Construir uno, paso a paso:
-
Escribe un módulo que implemente
route_prefix/0ycall/2:defmodule MyPlugin.OAuthCallback do @behaviour Pepe.PluginRoute @impl true def route_prefix, do: "weather_oauth" @impl true def call(conn, _path) do # maneja la redirección del proveedor, intercambia el código, etc. Plug.Conn.send_resp(conn, 200, "connected") end end -
Guárdalo como
~/.pepe/plugins/weather_oauth.exse instálalo:pepe plugin install ~/.pepe/plugins/weather_oauth.exs. -
Activa la ruta explícitamente - reclamar un prefijo en código no expone nada por sí solo, se requiere un segundo opt-in, deliberado, porque una ruta (a diferencia de una herramienta) responde a cualquier petición entrante, no solo a la que decidió hacer el propio modelo del agente:
pepe plugin route list # cada plugin instalado que reclama ruta, activo o no pepe plugin route enable weather_oauth # ahora accesible en /plugin-routes/weather_oauth/... pepe plugin route disable weather_oauth -
Apunta lo que necesite llegar a ella (la URL de redirección de una app OAuth, un emisor de webhooks) a
https://tu-dominio/plugin-routes/weather_oauth/...- los segmentos de ruta después del prefijo llegan como segundo argumento decall/2.
El behaviour Realtime provider - audio dúplex
Ningún otro punto de extensión de Pepe sostiene un flujo continuo y bidireccional - una
llamada a herramienta, un webhook, un ocupante de slot son todos de petición/respuesta o
de una sola vez. Pepe.Realtime.Provider es esa primitiva: un plugin controla por
completo cómo el audio entrante se convierte en una respuesta saliente (un modelo de
tiempo real alojado, una tubería de STT en streaming seguido de TTS), y un canal
WebSocket nuevo lleva los bytes.
@callback name() :: String.t()
@callback start(agent :: map(), opts :: keyword(), sink :: pid()) :: {:ok, session :: term()} | {:error, term()}
@callback push_audio(session :: term(), chunk :: binary()) :: :ok | {:error, term()}
@callback push_text(session :: term(), text :: String.t()) :: :ok | {:error, term()} # opcional
@callback stop(session :: term()) :: :ok
Un cliente se une a realtime:<agent_name> (realtime:default para el agente
predeterminado) con {"provider": "your_provider_name"} en el payload de unión, y luego
envía fragmentos binarios en el evento "audio". El sink de start/3 es el pid al que
enviar eventos de vuelta durante toda la vida de la sesión: {:realtime_audio, chunk},
{:realtime_text, text}, o {:realtime_stopped, reason} si el proveedor termina la
sesión por su cuenta. Es aditivo, no un slot - se pueden instalar varios proveedores, y un
cliente elige uno por nombre por conexión; nada necesita habilitarse de forma global como
sí ocurre con un Pepe.PluginRoute. Pepe no incluye ningún proveedor realtime propio -
este es el punto de extensión que un plugin rellena.
Construir uno, paso a paso:
-
Escribe un módulo que implemente
name/0,start/3,push_audio/2,stop/1, y opcionalmentepush_text/2. El ejemplo de abajo es un proveedor de eco - devuelve cualquier audio que reciba, más un subtítulo por cada fragmento. Suficiente para desarrollar un cliente contra él antes de que exista un backend real de STT/TTS o de modelo alojado:defmodule EchoRealtime do @behaviour Pepe.Realtime.Provider @impl true def name, do: "echo_realtime" @impl true def start(agent, _opts, sink) do send(sink, {:realtime_text, "session started for #{agent.name}"}) {:ok, sink} end @impl true def push_audio(sink, chunk) do send(sink, {:realtime_text, "echoing #{byte_size(chunk)} bytes"}) send(sink, {:realtime_audio, chunk}) :ok end @impl true def push_text(sink, text) do send(sink, {:realtime_text, "echo: " <> text}) :ok end @impl true def stop(_sink), do: :ok endEl propio argumento
sinkdestart/3funciona aquí también como el término de sesión, ya que este proveedor no tiene una conexión/proceso real propio que rastrear - un proveedor que hable con un upstream de verdad (un modelo alojado, una tubería local de STT/TTS) devolvería algo que identifique eso, y usaríasinksolo para enviar eventos de vuelta. -
Guárdalo como
~/.pepe/plugins/echo_realtime.exse instálalo:pepe plugin install ~/.pepe/plugins/echo_realtime.exs. No hay nada más que activar - un proveedor realtime no tiene slot que fijar ni ruta que activar; queda vivo en el momento en que se instala, esperando a que un cliente lo pida por su nombre. -
Desde un cliente, únete a
realtime:<agent_name>en el WebSocket ya existente (/socket/websocket) nombrándolo en el payload:let ws = new WebSocket("ws://localhost:4000/socket/websocket"); ws.onmessage = (e) => console.log(JSON.parse(e.data)); ws.onopen = () => { ws.send(JSON.stringify({ topic: "realtime:default", event: "phx_join", payload: { provider: "echo_realtime" }, ref: 1 })); }; -
Envía fragmentos binarios en el evento
"audio"una vez conectado; los eventos{:realtime_audio, ...}/{:realtime_text, ...}vuelven de la misma manera que cualquier otro push de canal.
El behaviour Hook - mutación real de contenido
Un hook reescribe de verdad el contenido de la conversación, en línea, en el mismo
camino síncrono en el que ya corren pii_redact/llm_redact/http_redact/presidio.
Así es como un plugin de compactación de contexto o redacción de contenido hace trabajo
real - no confundir con un run observer (abajo), que solo puede observar.
@callback name() :: String.t()
@callback stages() :: [:inbound | :outbound | :learn | :tool_result]
@callback run(stage, text :: String.t(), settings :: map(), ctx :: map()) ::
{:ok, String.t()} | {:ok, String.t(), [%{"fake" => String.t(), "real" => String.t()}]}
:inbound corre sobre el texto del usuario antes de que el modelo lo vea; :outbound
sobre la respuesta antes de enviarla de vuelta; :tool_result sobre la salida cruda de
una tool antes de que se una a la conversación. Un agente se suscribe a hooks por nombre
(mix pepe agent add NOMBRE --hooks tu_hook,pii_redact) - un hook de plugin es aditivo
junto a los cuatro integrados, y uno integrado siempre gana un choque de nombre, así que
elige un nombre distinto de pii_redact/llm_redact/http_redact/presidio.
Los hooks se encadenan: con --hooks bracket,exclaim, exclaim ve el texto ya mutado
por bracket, en orden - secuencial, cada uno viendo la salida del anterior, no un
fan-out. Devuelve el texto (posiblemente sin cambios) y, opcionalmente, una lista de
entradas de mapa reversible (fake un token, real el valor que reemplazó) si quieres
que se restauren a la salida.
Fail-open, a propósito: un hook que lanza una excepción cae de vuelta al texto de
entrada en lugar de romper el turno. Un hook muta o redacta - nunca bloquea. Para vetar
una llamada por completo, mira Pepe.Permissions.Policy abajo, un mecanismo
deliberadamente distinto y más estrecho.
El behaviour Policy - vetar una llamada a una tool
Un plugin de policy puede rechazar una llamada a una tool antes de que corra, por una razón que solo tu plugin conoce (una regla de la empresa, un servicio de allowlist externo, un limitador de tasa).
@callback name() :: String.t()
@callback check(tool_name :: String.t(), args :: map(), ctx :: map()) ::
:allow | :ask | {:ask, String.t()} | :deny | {:deny, String.t()}
Cada policy instalada se consulta en cada llamada de gate, para cada agente - no es
opt-in como un hook, ya que instalar una solo añade restricción. Se comprueba antes de
la propia lógica de pre-aprobación de Pepe, así que una policy puede vetar incluso una
llamada que el operador ya marcó como aprobada con :always. Sin llegar a un rechazo
total, :ask/{:ask, reason} obliga a un humano a mirar una llamada que de otro modo
habría sido pre-aprobada en silencio - el motivo aparece junto al prompt. Gana lo más
restrictivo entre todas las policies instaladas: :deny vence a :ask vence a :allow.
Fail-closed - la única excepción deliberada en todo este sistema de plugins. Cualquier
otra superficie de plugin en Pepe degrada a “como si no estuviera instalado” ante un
fallo o timeout. Un plugin de policy es lo contrario: un check/3 que lanza una
excepción, se queda colgado más allá de su timeout, o devuelve algo distinto de un
:allow explícito deniega la llamada. Una comprobación de seguridad que no pudo
correr no es lo mismo que una que pasó.
defmodule MyPlugin.NoBashPolicy do
@behaviour Pepe.Permissions.Policy
@impl true
def name, do: "no_bash_policy"
@impl true
def check("bash", _args, _ctx), do: {:deny, "bash is blocked on this instance"}
def check(_name, _args, _ctx), do: :allow
end
Añade un check_run/3 opcional para vetar toda una ejecución, antes de cualquier llamada
a herramienta y antes de la primera llamada al modelo - la única forma de decir “no
proceses este mensaje en absoluto” (un remitente vetado, un límite de tasa a nivel de
mensaje), ya que check/3 nunca se dispara para un turno que jamás llama a una
herramienta:
@callback check_run(agent :: map(), first_message :: String.t(), ctx :: map()) ::
:allow | :ask | {:ask, String.t()} | :deny | {:deny, String.t()}
“Se aplica a” todavía puede acotarse - por el operador, nunca por el propio agente, lo que anularía el propósito:
pepe policy list # cada policy instalada + su alcance
pepe policy scope no_bash_policy --agents support --projects acme
pepe policy scope no_bash_policy --clear # vuelve a aplicarse en todas partes
o directamente en config.json ("policy_scope", por el nombre de la policy). Ninguna
entrada para el nombre de una policy significa sin alcance - todos los agentes, el
predeterminado y el comportamiento original. Un agente nunca puede excluirse a sí mismo;
solo quien configura el alcance decide dónde se consulta una policy.
El behaviour RunObserver
Un observador de ejecución observa el turno de un agente desde fuera - útil para logging, métricas o alertas sobre lo que hace un agente, sin tocar lo que hace. Es estrictamente de solo observación: nunca ve el historial de mensajes de la conversación, no puede bloquear un turno y no puede cambiar nada de él, solo enterarse de lo que ya pasó, después del hecho.
@callback name() :: String.t()
@callback subscriptions() :: [atom()]
@callback handle_event(event :: atom(), payload :: term(), meta :: map()) :: any()
subscriptions/0 nombra qué tipos de evento quieres - cualquiera de :run_start,
:tool_call, :tool_denied, :tool_result, :assistant, :assistant_delta,
:failover, :output_cap, :usage, :inline, :done, :error, :run_end.
handle_event/3 se llama una vez por cada evento al que te suscribiste, en el orden
en que el turno los produjo. payload es la propia tupla del evento (p. ej.
{:tool_result, "web_search", "..."}) - con una excepción: :tool_call llega como
{:tool_call, name}, sin sus argumentos, porque esos todavía no han pasado por la
redacción y pueden llevar secretos.
Un ejemplo mínimo que registra cada llamada a herramienta y la respuesta final:
defmodule MyPlugin.ToolLogger do
@behaviour Pepe.Agent.RunObserver
require Logger
@impl true
def name, do: "tool_logger"
@impl true
def subscriptions, do: [:tool_call, :done]
@impl true
def handle_event(:tool_call, {:tool_call, name}, _meta), do: Logger.info("tool called: #{name}")
def handle_event(:done, {:done, content}, _meta), do: Logger.info("run finished: #{String.slice(content, 0, 80)}")
end
El despacho es asíncrono y está aislado: un observador colgado o que falla nunca ralentiza ni rompe la conversación que observa. Uno que falla 3 veces seguidas queda deshabilitado - en todas las ejecuciones futuras, no solo en la que lo disparó - para que un observador roto nunca siga pagando su propio costo de detección para siempre, ni sature tus logs con el mismo fallo. No hay nada que conceder a un agente para esto: instalado es habilitado.
El registro
Pepe.Tools.all/0 devuelve las herramientas integradas seguidas de cada
herramienta de plugin cargada; Pepe.Webhooks hace lo mismo con los
proveedores de canal. Las integradas y los plugins se unen en un único
registro, y las dos formas resuelven un conflicto de nombre de maneras
opuestas. En las herramientas, la integrada siempre prevalece, así que elige un
nombre de herramienta distinto de read_file, web_search y el resto de pepe tools. En los proveedores de canal, prevalece el plugin con el mismo nombre, y
así es como reemplazas un proveedor ya incluido por tu propia versión de él.
Conceder una herramienta a un agente
Instalar un plugin no entrega sus herramientas a todos los agentes: solo las herramientas listadas en un agente quedan expuestas a él, con el mismo control que una integrada.
CLI: pepe agent add assistant --tools reverse_text,web_search,read_file
Panel: abre el agente en Agentes y marca la herramienta. Las herramientas de plugin aparecen junto a las integradas.
Por chat: un agente con enable_tool puede activar una herramienta para
sí mismo:
Tú: activa la herramienta reverse_text
Agente: reverse_text activada; ya puedes usarla desde tu próximo mensaje
Para conceder una herramienta a un agente distinto, la acción add_tool de
manage_agent lo hace (limitada a los agentes que quien pide tiene permiso
de gestionar, y confirma contigo antes):
Tú: dale al agente de soporte la herramienta gmail_search
Agente: Voy a añadir gmail_search al agente “support”. ¿Confirmas?
Dónde viven los plugins y cómo se cargan
Los plugins viven en ~/.pepe/plugins/ (sigue PEPE_HOME). Pepe recorre esa
carpeta de forma recursiva buscando archivos .exs, compila cada uno una vez
y solo recompila cuando cambia su fecha de modificación: suelta un archivo y
funciona sin reiniciar; edítalo y el cambio se aplica en la siguiente llamada
a herramienta. Un archivo puede definir varios módulos (el ejemplo de Google
de abajo trae cuatro).
Un plugin tiene una de dos formas: un archivo .exs suelto, o un
paquete: un directorio con un manifest.json y uno o más archivos
.exs.
Compilar en tiempo de ejecución trae un límite honesto: un plugin no puede
traer consigo una dependencia externa nueva. Elixir resuelve y compila las
dependencias en tiempo de build, así que un plugin solo puede usar las
bibliotecas que Pepe ya incluye (Req, Jason, la biblioteca estándar y el
resto de sus dependencias). Un plugin que necesita una biblioteca inédita no es
un drop-in; exigiría recompilar Pepe. En la práctica rara vez estorba, porque
una herramienta que llama a una API HTTP y un proveedor de canal como Chatwoot
no necesitan nada más allá de lo que ya viene incluido, y por eso se instalan
sin problema.
Instalar un plugin
La fuente es un archivo local, un directorio local, un .tar.gz, una URL a
cualquiera de esos, o una referencia de PepeHub,
y install desempaqueta lo que le des en la carpeta de plugins. Una URL de
repositorio de GitHub se descarga como su archivo fuente y se extrae, tomando
la rama por defecto (main, luego master) cuando no se indica ninguna;
añade /tree/<branch> a la URL para tomar otra. Un .tar.gz, local o remoto,
se extrae y el paquete se coloca bajo el name de su manifiesto. Un
directorio se copia tal cual, y un .exs suelto se copia directamente.
Una referencia de PepeHub es la forma corta @handle/nombre o la propia URL
de la página del paquete, copiada directamente de hub.pepe-agent.com: ambas
resuelven al mismo paquete. Apuntar plugin install a un nombre que en
realidad es una skill en PepeHub, no un plugin, falla con un mensaje claro
que indica usar skill install en su lugar.
CLI:
pepe plugin install ./my_plugin.exs
pepe plugin install https://github.com/you/pepe-myplugin
pepe plugin install @jhonathas/backup-tool
pepe plugin list
pepe plugin remove google
Panel: la página de Plugins acepta una URL de GitHub, una URL .tar.gz o
una ruta local; marcas una casilla confirmando que confías en la fuente y
pulsas Instalar. Los plugins instalados se listan con un botón Eliminar y,
cuando el plugin declara ajustes, un botón Configurar.
Por chat, con manage_plugin: un agente con esta herramienta puede
instalar en tu nombre: haz scan de una fuente primero para ver qué hace,
luego install, list, remove. Pasa por el mismo escaneo de seguridad que
la CLI, pero sin la salida de emergencia --force: un veredicto peligroso
siempre se rechaza desde el chat, y el agente te dirá que revises el código y
ejecutes --force tú mismo en una terminal si aun así lo quieres.
El escaneo de seguridad
Un plugin es Elixir corriente con acceso total a la aplicación en ejecución: instalar uno es una decisión de confianza, igual que añadir cualquier dependencia. Instala solo desde una fuente en la que confíes, y prefiere fijar una versión o un commit concreto.
Antes de colocarlo en disco, Pepe.Skills.Sentinel lo escanea de forma
estática. Recorre el árbol sintáctico en vez del texto en bruto, así que
señala las llamadas peligrosas con precisión:
- lanzar shells (
System.cmd,:os.cmd), - eval dinámico (
Code.eval_string), - deserialización insegura (
:erlang.binary_to_term), - llamadas destructivas al sistema de archivos (
File.rm_rf), - agotamiento de átomos (
String.to_atom), - lectura del entorno o de rutas con secretos (
~/.ssh, la configuración de Pepe), - acceso a red.
Como lee el AST, también atrapa las formas con alias y las formas Erlang de esas llamadas, y no tropieza con esas mismas palabras cuando aparecen en un comentario o en una cadena. Nunca ejecuta el código, y devuelve uno de tres veredictos:
- limpio: sin hallazgos.
- precaución: señalado pero a menudo legítimo (un plugin de canal debería hacer llamadas de red); se muestra, no bloquea.
- peligro: ninguna buena razón para estar ahí; bloquea la instalación.
pepe plugin scan ./my_plugin.exs # escanea sin instalar
pepe plugin install ./risky.exs --force # continúa de todos modos, tras revisarlo
El manifiesto y el diálogo de Configurar
El manifest.json de un paquete lo nombra, lo describe y (lo más útil)
declara los ajustes que necesita. Del ejemplo de Google incluido:
{
"name": "google",
"version": "0.1.0",
"description": "Google Workspace tools: read/create Calendar events and search/send Gmail, as agent tools.",
"provides": ["tool:gcal_upcoming", "tool:gcal_create_event", "tool:gmail_search", "tool:gmail_send"],
"files": ["google.exs"],
"config": [
{"key": "access_token", "label": "Access token", "type": "secret", "hint": "ya29... (expires in ~1h); or fill the refresh trio below. Store as ${ENV_VAR} to keep it out of the file."},
{"key": "client_id", "label": "OAuth client ID", "type": "text", "hint": "...apps.googleusercontent.com"},
{"key": "client_secret", "label": "OAuth client secret", "type": "secret"},
{"key": "refresh_token", "label": "Refresh token", "type": "secret", "hint": "minted once from the consent flow; survives access-token expiry"}
]
}
Cada entrada de config es un campo: key (el nombre que lee tu código),
label (mostrado en el formulario), type ("text", "secret" para una
entrada enmascarada, o "select" con una lista "options"), y un hint
opcional. El panel lee este array y renderiza el diálogo de Configurar. Un
plugin nuevo no necesita pantalla nueva. Un valor puede ser una referencia
${ENV_VAR}, guardada tal cual y resuelta desde el entorno solo al leerla,
así que los secretos nunca quedan expandidos en el archivo de configuración.
Lee un ajuste guardado desde el código de tu plugin con
Pepe.Plugins.config/3 (el nombre es el nombre del paquete en el manifiesto;
el tercer argumento es un valor por defecto):
token = Pepe.Plugins.config("google", "access_token")
region = Pepe.Plugins.config("myplugin", "region", "us-east-1")
Un patrón común: prefiere el valor del panel, recurre a una variable de entorno, para que el plugin funcione tanto si el operador rellena el formulario como si exporta una variable (el ejemplo de Google de abajo hace exactamente eso).
Ejemplo: el plugin de herramientas Google Workspace
examples/plugins/google/google.exs trae cuatro herramientas en un solo
archivo:
| Herramienta | Qué hace |
|---|---|
gcal_upcoming |
Lista los próximos eventos del Google Calendar principal |
gcal_create_event |
Crea un evento (resumen, inicio, fin, descripción) |
gmail_search |
Busca en Gmail y devuelve remitente y asunto de las coincidencias |
gmail_send |
Envía un correo en texto plano |
pepe plugin install ./examples/plugins/google
pepe agent add assistant --tools gcal_upcoming,gcal_create_event,gmail_search,gmail_send
Se autentica con un token bearer OAuth2 resuelto en el momento de la llamada: nada sensible embebido en el código. Exporta un token de acceso listo (más rápido, expira en ~1h):
export GOOGLE_ACCESS_TOKEN=ya29....
o un refresh token (sobrevive a la expiración; el plugin genera un token de acceso por llamada):
export GOOGLE_CLIENT_ID=...apps.googleusercontent.com
export GOOGLE_CLIENT_SECRET=...
export GOOGLE_REFRESH_TOKEN=...
Consigue estos valores creando un cliente OAuth (tipo “Desktop app”) en un
proyecto de Google Cloud, con las API de Calendar y Gmail habilitadas, tras
ejecutar el flujo de consentimiento una vez para los ámbitos que uses. O
rellena los mismos campos en el diálogo de Configurar del plugin, guardando
los secretos como referencias ${ENV_VAR}.
El código completo de una de las herramientas, mostrando el patrón de principio a fin:
defmodule Pepe.Plugins.GCalUpcoming do
@behaviour Pepe.Tools.Tool
import Pepe.Tools.Tool, only: [function: 3]
alias Pepe.Plugins.Google.API
@impl true
def name, do: "gcal_upcoming"
@impl true
def spec do
function("gcal_upcoming", "List upcoming events on the user's primary Google Calendar.", %{
"type" => "object",
"properties" => %{
"max" => %{"type" => "integer", "description" => "How many events to return (default 10)."}
}
})
end
@impl true
def run(args, _ctx) do
max = args["max"] || 10
now = DateTime.utc_now() |> DateTime.to_iso8601()
API.with_token(fn token ->
params = [maxResults: max, orderBy: "startTime", singleEvents: true, timeMin: now]
case API.get("https://www.googleapis.com/calendar/v3/calendars/primary/events", token, params) do
{:ok, %{"items" => items}} -> {:ok, format_events(items)}
{:ok, _} -> {:ok, "No upcoming events."}
error -> error
end
end)
end
end
Tú: ¿Qué tengo mañana en el calendario? Envía un resumen por correo a sam@example.com
Agente: (invoca gcal_upcoming, luego gmail_send) Tienes 3 eventos mañana. Envié el resumen por correo a sam@example.com.
Ejemplo: el plugin de canal Chatwoot
examples/plugins/chatwoot/ muestra la otra forma: un canal, no una
herramienta. Registra un proveedor chatwoot para que Pepe se siente detrás
de una bandeja de Chatwoot como el agente de IA,
en todos los canales que Chatwoot ya cubre (WhatsApp, widget web, Instagram,
…).
pepe plugin install ./examples/plugins/chatwoot
Traspaso nativo a un humano, sin pegamento extra. Chatwoot lleva la señal
de traspaso en cada webhook: el status de la conversación. El plugin
implementa parse/1 para responder solo conversaciones marcadas pending
(propiedad del bot); en el momento en que un agente humano la toma (open),
Pepe se calla, y retoma cuando vuelve a pending.
Configuración, en Chatwoot: crea un AgentBot, apunta su webhook saliente
a https://TU_HOST/webhooks/<project>/chatwoot/<slug>. La conexión guarda
base_url, account_id y un api_token (como ${ENV_VAR}) vía
config_schema/0, rellenados desde el panel, el mismo patrón de Configurar
que cualquier plugin.
Esta es una de dos formas mutuamente excluyentes de operar WhatsApp: o bien WhatsApp directo en Pepe (el proveedor integrado
Entregar un archivo, no solo texto
El run/2 de una herramienta solo devuelve texto. Para entregar un archivo
real (una hoja de cálculo, un PDF) a la persona en la conversación, no
reinventes la entrega: invoca la herramienta integrada send_file con una
ruta; Pepe resuelve el canal a partir de la sesión y lo entrega ahí. Concede
send_file a un agente y simplemente funciona desde el chat, en cualquier
canal cuyo proveedor implemente deliver_file/4.
Checklist
Escribir una herramienta:
- Implementa
name/0,spec/0,run/2; dale un nombre distinto de toda integrada. - Devuelve
{:ok, text}/{:error, message}desderun/2, escrito para que el modelo lo lea. - ¿Necesita credenciales u opciones? Incluye un
manifest.jsoncon un arrayconfig, léelas conPepe.Plugins.config/3.
Escribir un canal:
- Implementa
name/0,verify/2,authenticate/3,parse/1,deliver/3; añadeconfig_schema/0si necesita credenciales configuradas desde el panel. - Añade
respond/3solo si el protocolo de la plataforma exige una respuesta síncrona antes de cualquier trabajo del agente;deliver_file/4solo si puede recibir adjuntos.
En cualquier caso: escanéalo (pepe plugin scan SRC o manage_plugin scan), instálalo, revisa lo que encontró el escaneo, y luego concede la
herramienta a un agente (CLI, panel, o enable_tool/manage_agent desde el
chat). Un canal no necesita concesión, queda activo en cuanto se instala.