Plugins
Crear un plugin
Guía técnica para extender Sentik con habilidades nuevas para el bot.
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:
src/plugins/core/<slug>/
├── index.ts # exporta el plugin
└── plugin.ts # manifest + invokeUn plugin implementa IPluginV2 (ver src/lib/plugins/types.ts):
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
{
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:
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,
},
},
};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:
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ónconversationId/leadId: opcional, dependiendo del flowconfig: la configuración del plugin (detenant_plugins.config_enc)supabase: cliente Supabase con service role (úsalo con tenant_id explícito)pluginId: el id de la fila entenant_plugins, útil si guardas datos por plugin
Lo que devuelves (PluginToolResult):
{
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
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
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:
node scripts/apply-migration.js supabase/migrations/0NN_<slug>_data.sqlPaso 4: Registra el plugin en el catálogo
Dos lugares:
1) Código — agrégalo a src/lib/plugins/registry.ts:
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:
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:
{slug === 'mi-plugin' && <MiPluginEditor />}Pareja típica:
<slug>-actions.server.ts— server actions conassertTenantAdminy validaciones<slug>-editor.tsx— componente cliente
Paso 6: Prueba en el Laboratorio
- Activa el plugin en
/admin/plugins/<slug>. - Ve a
/admin/laby dile al bot algo que dispare tu tool: "¿qué promociones tienen?". - La respuesta debería incluir un chip con el nombre de tu tool.
- 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:
- Activa el plugin en el marketplace.
- En su detalle, ve a Alcance por departamento.
- 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.
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 tenerON DELETE CASCADE. - Tipos JSON nunca opcionales: si
valid_untilpuede ser null, decláralastring | nullen 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
// 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}` };
},
};// src/plugins/core/<slug>/index.ts
export { miPlugin } from './plugin';-- 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 SDKsrc/lib/plugins/manager.ts— cómo se cargan los plugins por tenant y el gating por departamentosrc/plugins/core/promociones/plugin.ts— ejemplo mínimo y completosrc/plugins/core/calendar/plugin.ts— ejemplo más complejo (multi-tool)src/plugins/core/catalog-manual/plugin.ts— ejemplo consync()y datos
/admin/copilot. Te puede estructurar el manifest + tools antes de escribir código.