Ir al contenido

PluginContext

Todo plugin exporta una función init que el servidor ejecuta al cargarlo. Su firma exacta es:

import type { PluginContext } from '@openfactu/plugin-sdk';
export const init = async (context: PluginContext) => {
// ...
};
// Firma del tipo:
// type PluginInit = (context: PluginContext) => void | Promise<void>;

PluginContext tiene 8 campos:

Campo Qué es Referencia
app Servidor HTTP (Express) para registrar rutas propias esta página
migration Añadir campos a tablas core y crear tablas propias Migraciones y tablas
hooks Registro genérico de hooks por evento Catálogo de hooks
documents Atajos onBeforeCreate / onAfterCreate para documentos Catálogo de hooks
factuApi API programática de negocio (documentos, maestros, contabilidad…) FactuAPI
db Acceso Drizzle directo (schema public y por tenant) esta página
widgets Registro programático de widgets de dashboard Manifest y UI
aiTools Registro de tools para el chat de IA esta página

Instancia del servidor HTTP (estilo Express, tipada como any). Permite al plugin exponer sus propios endpoints:

export const init = async ({ app }: PluginContext) => {
app.get('/api/plugins/mi-plugin/estado', (req, res) => {
res.json({ ok: true });
});
};

Por convención, prefija tus rutas con /api/plugins/<id-del-plugin>/ para evitar colisiones con el core y con otros plugins.

Dos métodos, ambos idempotentes (se ejecutan en cada arranque sin duplicar nada):

migration: {
addCustomField: (opts: {
pluginId: string;
tableName: CoreTableName | string;
fieldName: string;
type: 'TEXT' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'JSONB';
label: string;
}) => Promise<void>;
createTable: (opts: {
pluginId: string;
tableName: string;
columns: Array<{
name: string;
type: 'TEXT' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'JSONB' | 'UUID' | 'TIMESTAMP';
primaryKey?: boolean;
nullable?: boolean;
default?: string;
}>;
}) => Promise<void>;
}

Detalle completo, incluidas las 35 tablas core extensibles, en Migraciones y tablas.

hooks: {
register: (event: string, handler: HookHandler) => void;
};
documents: {
onBeforeCreate: (tableName: string, handler: HookHandler) => void;
onAfterCreate: (tableName: string, handler: HookHandler) => void;
};
  • hooks.register es la vía genérica: acepta cualquier evento del catálogo ('salesInvoice.posted', 'payment.created', 'partners.list.afterFetch'…).
  • documents.onBeforeCreate / onAfterCreate son atajos para documentos, y reciben el nombre de tabla en PascalCase ('SalesInvoice'), no el nombre de evento en camelCase.

El catálogo completo de 39 eventos, el contenido de HookContext y la explicación de los dos estilos están en el Catálogo de hooks.

La API programática de Keirost, con transacciones atómicas, factories de documentos, consultas de maestros, contabilidad, RRHH y pagos. Es el mismo FactuApi que se usa en scripts:

export const init = async ({ factuApi, hooks }: PluginContext) => {
hooks.register('salesInvoice.posted', async (ctx) => {
await factuApi.transaction(ctx.tenantId, ctx.db, ctx.user, async (api) => {
const partner = await api.getPartner(ctx.data.partnerId);
// ...
});
});
};
db: {
public: PluginDrizzleClient; // schema 'public' (Tenant, GlobalUser, PluginField...)
forTenant: (tenantId: string) => Promise<PluginDrizzleClient>;
schema: any; // módulo de schema del server
}
  • db.public — cliente Drizzle del schema global public.
  • db.forTenant(tenantId)la única vía para datos de un tenant: resuelve el schema físico a través de la tabla Tenant, nunca acepta un nombre de schema arbitrario (evita fugas entre tenants).
  • db.schema — módulo de schema tipado del server.
import { eq } from 'drizzle-orm';
const tenantDb = await db.forTenant(tenantId);
const items = await tenantDb.select().from(db.schema.items).limit(5);
const [partner] = await tenantDb
.select()
.from(db.schema.businessPartners)
.where(eq(db.schema.businessPartners.id, partnerId));

Usa db.schema para construir queries Drizzle en vez de SQL crudo.

Registro programático de widgets, alternativa a declararlos en manifest.json:

widgets: {
registerDashboard: (widget: PluginDashboardWidgetInput) => void;
}
widgets.registerDashboard({
id: 'mi-widget',
title: 'Mi widget',
component: 'ui/MiWidget.tsx', // ruta relativa al componente ESM del plugin
size: 'md', // grid de 4 columnas: sm=1, md=2, lg=3, full=4
order: 100,
});

Si registras dos veces el mismo id, el widget se actualiza (útil con hot reload). La variante declarativa (ui.dashboardWidgets del manifest) tiene el mismo shape — ver Manifest y UI.

Los plugins pueden aportar herramientas al chat de IA (Keiro), igual que aportan hooks o widgets:

aiTools: {
register: (name: string, factory: (ctx: AiChatToolContext) => any) => void;
}
interface AiChatToolContext {
tenantClient: PluginDrizzleClient; // cliente Drizzle del tenant de la conversación
tenantId: string;
tenantSchema: string;
user: any;
apiBase?: string;
}

La factory se invoca por petición con el contexto del tenant y usuario de la conversación, y debe devolver el resultado de tool({...}) del paquete ai (Vercel AI SDK):

import { tool } from 'ai';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
aiTools.register('mi_plugin_consulta', (ctx) =>
tool({
description: 'Consulta los puntos de fidelidad de un interlocutor.',
inputSchema: z.object({ partnerId: z.string() }),
execute: async ({ partnerId }) => {
const [row] = await ctx.tenantClient
.select()
.from(db.schema.businessPartners)
.where(eq(db.schema.businessPartners.id, partnerId));
return { points: row?.loyalty_points ?? 0 };
},
}),
);
  • Si el name ya lo usa otra tool (del core o de otro plugin), el registro se ignora — nunca sobreescribe una tool existente.
  • Si tu tool crea o modifica datos, pasa needsApproval: true a tool({...}): el chat mostrará la misma tarjeta de confirmación que usan las acciones del core.