Plugins

Crear un plugin

Guía técnica para extender Sentik con habilidades nuevas para el bot.

TL;DR: un plugin son tres cosas — un manifest que se registra en la DB, un archivo TypeScript con un invoke() que ejecuta lógica de tools, y una entrada en el registry. Si guarda datos del tenant, añade una migración y opcionalmente una UI en /admin/plugins/[slug].

1. Anatomía de un plugin

Cada plugin tiene esta estructura:

text
src/plugins/core/<slug>/
├── index.ts        # exporta el plugin
└── plugin.ts       # manifest + invoke

Un plugin implementa IPluginV2 (ver src/lib/plugins/types.ts):

typescript
export interface IPluginV2 {
    manifest: PluginManifest;
    invoke(toolName, args, ctx): Promise<PluginToolResult>;
    sync?(ctx): Promise<SyncResult>;       // opcional, para sync con APIs externas
    onConfigSaved?(ctx): Promise<void>;    // opcional, hook post-config
}

El manifest

typescript
{
    slug: 'promociones',           // ID único, kebab-case
    name: 'Promociones',
    description: 'Una frase clara y vendible.',
    category: 'sales',             // 'catalog' | 'sales' | 'scheduling' | 'crm' | ...
    version: '1.0.0',
    icon: 'tag',                   // nombre de icono Lucide en minúsculas
    configSchema: [...],           // qué pide el admin para activarlo
    requiredSecrets: [],           // claves que deben estar en config_enc
    tools: [...],                  // funciones que el LLM puede llamar
    supportsIngestion: false,      // true si tiene sync() con fuente externa
}

Las tools

Cada tool es un ChatCompletionFunctionTool de OpenAI:

typescript
const LIST_PROMOS: ChatCompletionTool = {
    type: 'function',
    function: {
        name: 'list_active_promotions',   // único globalmente, en snake_case
        description: 'Cuándo y por qué llamarla. Sé directo.',
        parameters: {
            type: 'object',
            properties: {
                limit: { type: 'integer', minimum: 1, maximum: 10, default: 5 },
            },
            additionalProperties: false,
        },
    },
};
Reglas para el nombre del tool: en snake_case. Único en TODO Sentik (los nombres se colapsan en un único namespace). Empieza con un verbo: search_catalog, book_appointment, list_active_promotions.

El invoke

invoke() ejecuta una tool concreta:

typescript
async invoke(toolName, args, ctx): Promise<PluginToolResult> {
    if (toolName === 'list_active_promotions') {
        const { data } = await ctx.supabase
            .from('promotions')
            .select('...')
            .eq('tenant_id', ctx.tenantId)
            .eq('is_active', true);

        return {
            ok: true,
            summary: `${data.length} promos vigentes.`,
            data: { promotions: data },
        };
    }
    return { ok: false, summary: `Tool desconocida: ${toolName}` };
}

El campo ctx te da:

  • tenantId: el tenant donde corre la conversación
  • conversationId / leadId: opcional, dependiendo del flow
  • config: la configuración del plugin (de tenant_plugins.config_enc)
  • supabase: cliente Supabase con service role (úsalo con tenant_id explícito)
  • pluginId: el id de la fila en tenant_plugins, útil si guardas datos por plugin

Lo que devuelves (PluginToolResult):

typescript
{
    ok: boolean,                   // true = la tool corrió sin error
    summary: string,               // resumen humano (se loguea + va al LLM)
    data?: Record<string, unknown>, // estructurado, va al LLM en el siguiente turn
    customerMessage?: string,      // opcional, se envía AL cliente sin pasar por el LLM
    attachments?: [{ kind: 'pdf', url, filename? }], // archivos a enviar al cliente
}

2. Pasos para crear un plugin nuevo

Paso 1: Decide qué hace

Buenas habilidades para plugins:

  • Lookup de datos externos: catálogo, inventario, CRM, calendario
  • Acciones transaccionales: crear cita, cotizar, registrar venta
  • Habilidades creativas: generar resúmenes, traducir, calcular envío
Antinegocio: cosas que ya cubre department_commands o knowledge_base. Si es un FAQ estático o un trigger texto→texto fijo, no necesitas un plugin.

Paso 2: Crea la carpeta

bash
mkdir -p src/plugins/core/<slug>

Crea plugin.ts y index.ts. Para arrancar rápido usa la plantilla más abajo.

Paso 3: Si necesitas guardar datos, crea una migración

Mínimo: tabla con tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, RLS habilitada, y dos policies (super_admin all + tenant own).

Aplica con:

bash
node scripts/apply-migration.js supabase/migrations/0NN_<slug>_data.sql

Paso 4: Registra el plugin en el catálogo

Dos lugares:

1) Código — agrégalo a src/lib/plugins/registry.ts:

typescript
import { miPlugin } from '@/plugins/core/mi-plugin/plugin';

export const CORE_PLUGIN_REGISTRY: Record<string, IPluginV2> = {
    ...
    [miPlugin.manifest.slug]: miPlugin,
};

2) DB — agrega una fila a plugin_registry. Lo más cómodo: en tu mismo SQL de migración, al final:

sql
INSERT INTO plugin_registry (slug, name, description, version, category, icon_url, supports_ingestion, is_active, is_public)
SELECT 'mi-plugin', 'Mi Plugin', 'Descripción.', '1.0.0', 'sales', 'tag', false, true, true
WHERE NOT EXISTS (SELECT 1 FROM plugin_registry WHERE slug = 'mi-plugin');

Paso 5: (Opcional) UI de administración

Si el plugin necesita que el admin gestione datos (lista de promos, items, slots), agrega un editor en src/app/(dashboards)/admin/plugins/[slug]/ y móntalo condicionalmente en page.tsx:

tsx
{slug === 'mi-plugin' && <MiPluginEditor />}

Pareja típica:

  • <slug>-actions.server.ts — server actions con assertTenantAdmin y validaciones
  • <slug>-editor.tsx — componente cliente

Paso 6: Prueba en el Laboratorio

  1. Activa el plugin en /admin/plugins/<slug>.
  2. Ve a /admin/lab y dile al bot algo que dispare tu tool: "¿qué promociones tienen?".
  3. La respuesta debería incluir un chip con el nombre de tu tool.
  4. Si quieres restringir el plugin a un departamento, usa el panel Alcance por departamento en la página del plugin.

3. Restringir a departamentos

Por defecto un plugin activado funciona en cualquier conversación del tenant. Si quieres limitarlo:

  1. Activa el plugin en el marketplace.
  2. En su detalle, ve a Alcance por departamento.
  3. Marca los departamentos donde debe correr.

El gating ocurre en getActivePlugins() (src/lib/plugins/manager.ts): si tenant_plugin_departments tiene rows para ese plugin, las tools sólo se exponen al modelo cuando la conversación está enrutada a un departamento de esa lista.

No necesitas hacer nada en el plugin. El gating es a nivel del manager, tu invoke() ni se entera de los departamentos.

4. Convenciones que ahorran sangre

  • Multi-tenancy estricto: TODA query debe filtrar por tenant_id. Si guardas datos, el FK a tenants debe tener ON DELETE CASCADE.
  • Tipos JSON nunca opcionales: si valid_until puede ser null, declárala string | null en TypeScript desde el inicio.
  • Errores claros en summary: el LLM ve el summary en el siguiente turn. Si pones "Falló la query" no sabe qué decirle al cliente. Mejor: "No encontré productos con ese nombre. ¿Quieres ver el catálogo completo?".
  • Idempotencia: si el bot puede llamar tu tool dos veces seguidas (común cuando reformula), el segundo call no debe romper nada.
  • Sin secretos en logs: todos los args y outputs se persisten en plugin_invocations. No metas API keys en el response.

5. Plantilla copy-paste

plugin.tstypescript
// src/plugins/core/<slug>/plugin.ts

import type {
    ChatCompletionTool,
    IPluginV2,
    PluginContext,
    PluginToolResult,
} from '@/lib/plugins/types';

const HELLO_TOOL: ChatCompletionTool = {
    type: 'function',
    function: {
        name: '<slug>_hello',
        description: 'Saluda al cliente con su nombre, en el tono del bot.',
        parameters: {
            type: 'object',
            properties: {
                customer_name: { type: 'string' },
            },
            required: ['customer_name'],
            additionalProperties: false,
        },
    },
};

export const miPlugin: IPluginV2 = {
    manifest: {
        slug: '<slug>',
        name: 'Mi Plugin',
        description: 'Hace X cuando el cliente Y.',
        category: 'misc',
        version: '0.1.0',
        icon: 'sparkles',
        configSchema: [
            {
                key: 'greeting',
                label: 'Frase de saludo',
                type: 'text',
                required: false,
                placeholder: 'Hola, {customer_name}!',
            },
        ],
        requiredSecrets: [],
        tools: [HELLO_TOOL],
        supportsIngestion: false,
    },

    async invoke(toolName, args, ctx): Promise<PluginToolResult> {
        if (toolName === '<slug>_hello') {
            const name = String(args.customer_name ?? 'cliente');
            const greeting = (ctx.config?.greeting as string) || `Hola, ${name}!`;
            return {
                ok: true,
                summary: `Saludado al cliente "${name}".`,
                data: { greeting },
            };
        }
        return { ok: false, summary: `Tool desconocida: ${toolName}` };
    },
};
index.tstypescript
// src/plugins/core/<slug>/index.ts
export { miPlugin } from './plugin';
migration (opcional)sql
-- supabase/migrations/0NN_<slug>.sql (sólo si guardas datos)
CREATE TABLE IF NOT EXISTS <slug>_data (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    -- ...
    created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE <slug>_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY "super_admin <slug>" ON <slug>_data FOR ALL USING (public.get_user_role() = 'super_admin');
CREATE POLICY "tenant <slug> own" ON <slug>_data FOR ALL USING (tenant_id = public.get_tenant_id());

INSERT INTO plugin_registry (slug, name, description, version, category, icon_url, supports_ingestion, is_active, is_public)
SELECT '<slug>', 'Mi Plugin', 'Descripción.', '0.1.0', 'misc', 'sparkles', false, true, true
WHERE NOT EXISTS (SELECT 1 FROM plugin_registry WHERE slug = '<slug>');

7. Recursos

  • src/lib/plugins/types.ts — todos los tipos del SDK
  • src/lib/plugins/manager.ts — cómo se cargan los plugins por tenant y el gating por departamento
  • src/plugins/core/promociones/plugin.ts — ejemplo mínimo y completo
  • src/plugins/core/calendar/plugin.ts — ejemplo más complejo (multi-tool)
  • src/plugins/core/catalog-manual/plugin.ts — ejemplo con sync() y datos
Si tienes una idea de plugin y no estás seguro de cómo encajarla, pregúntale al copilot en /admin/copilot. Te puede estructurar el manifest + tools antes de escribir código.