Plugin System
Internals of the plugin loader, worker runtime, and database entities
Plugin System Internals#
How Hay Core discovers, loads, and runs plugins
This document is for developers working on the plugin system infrastructure itself — the loader, the worker runtime, the database entities, and the hook dispatch. If you're building a plugin, see Getting Started with Plugins instead.
Table of Contents#
- Architecture Overview
- Plugin Discovery and Registration
- The Worker Runtime
- Instance Lifecycle Management
- Database Entities
- Lifecycle Hooks
- Cron Scheduling
- Webhook Routing and Channel Delivery
- The Plugin API (Worker → Core)
- Dashboard-Facing tRPC Surface
- Known Gaps and Legacy Paths
Architecture Overview#
The core model in one paragraph: a plugin is a directory under plugins/core/<name>/ (or plugins/custom/{organizationId}/<name>/) whose package.json contains a hay-plugin block. The plugin ID is the npm package name (e.g. hay-plugin-klaviyo, hay-channel-instagram-meta). Plugins do not run inside the core process — for each (organizationId, pluginId) pair, core lazily spawns a separate Node.js HTTP worker via the SDK runner, talks to it over localhost HTTP, and kills it after 5 minutes of inactivity.
There is no
manifest.json. The plugin system was migrated to a TypeScript-first model where all metadata lives inpackage.json→hay-pluginand in the code itself (viadefineHayPluginfrom@hay/plugin-sdk). Nothing in core reads amanifest.jsonfile. Older docs and comments that mention one are stale.
Key services#
| Service | File | Responsibility |
|---|---|---|
| Plugin Manager | server/services/plugin-manager.service.ts |
Filesystem discovery, registry (DB + in-memory), build/install orchestration |
| Plugin Runner | server/services/plugin-runner.service.ts |
Spawning/stopping workers, env injection, worker JWT, metadata readiness |
| Plugin Instance Manager | server/services/plugin-instance-manager.service.ts |
On-demand startup, activity tracking, idle cleanup, per-plugin instance pool |
| Plugin Cron | server/services/plugin-cron.service.ts |
Core-side scheduling of plugin-declared cron jobs |
| Webhook Router | server/services/webhook-router.service.ts |
Shared-webhook fan-out for URLs that carry no org identifier |
| Channel Delivery | server/services/channel-delivery.service.ts |
Outbound message delivery to channel plugins via /deliver |
| OAuth | server/services/oauth.service.ts |
The entire OAuth dance (authorize, token exchange, refresh) on behalf of plugins |
The SDK itself lives at packages/plugin-sdk/ — types under packages/plugin-sdk/types/, the worker runner under packages/plugin-sdk/runner/.
Plugin Discovery and Registration#
File: server/services/plugin-manager.service.ts (PluginManagerService)
Initialization flow (initialize())#
discoverPlugins()— scanplugins/core/and everyplugins/custom/{organizationId}/directory.- For each subdirectory,
registerPlugin(pluginPath, sourceType, organizationId):- Read
package.json. If there is nohay-pluginkey, the directory is silently skipped — this is the sole discovery criterion. - Plugin ID =
package.json.name. Display name =hay-plugin.displayNameor a Title-Cased version of the package name with thehay-plugin-prefix stripped (parseDisplayName). - Build a manifest from the
hay-pluginblock and persist it as themanifestjsonb column. The primarytypecomes fromhay-plugin.category, plus extra types inferred from capabilities (inferTypeFromCapabilities:mcp→mcp-connector,routes/messages→channel,sources→retriever,products→products; if nothing matches, the inferred list defaults to["mcp-connector"]) anddocumentImporter: true→document_importer. Thehay-plugin.envallow-list is stored on the manifest aspermissions.env, andcapabilitiesis mirrored intopermissions.api. - Load i18n JSON files from the plugin's
i18n/directory intomanifest.i18n. - Calculate a SHA-256 checksum over the plugin's
.ts/.jsfiles (excludingnode_modulesanddist) for change detection (calculatePluginChecksum). - Upsert into
plugin_registryviapluginRegistryRepository.upsertPlugin()and the in-memoryregistrymap.
- Read
loadRegistryFromDatabase()— merge DB rows into the in-memory map.validateExistingPlugins()— plugins present in the DB but not found on disk are markedstatus = "not_found".restorePluginsFromZip()— custom/git plugins with a stored ZIP upload get re-extracted if their directory is missing.initializeAutoActivatedPlugins()— legacy path forautoActivate: trueplugins (document importers) that register a tRPC router at boot.
The hay-plugin block#
The fields core actually reads (interface HayPluginBlock in plugin-manager.service.ts):
interface HayPluginBlock {
displayName?: string;
category?: PluginType; // mcp-connector | channel | retriever | playbook | document_importer | system | products
entry?: string; // e.g. "./dist/index.js"
capabilities?: string[]; // routes | mcp | auth | config | ui | messages | customers | sources | products
config?: ...; // config schema (usually declared in code instead)
env?: string[]; // allow-list of host env vars the worker may inherit → manifest.permissions.env
auth?: ...;
channel?: string; // channel plugins: the channel slug, e.g. "instagram"
autoActivate?: boolean; // legacy (document importers)
trpcRouter?: string; // legacy (document importers)
documentImporter?: boolean; // legacy (document importers)
}
Capabilities are declarative: they drive marketplace classification, type inference, and the scope of the worker's JWT. They are not hard-enforced against what the plugin actually implements.
The manifest stored in the registry is RegistryManifest — identical to HayPluginManifest (server/types/plugin.types.ts) except capabilities is the flat string array from package.json. Consumers branch on Array.isArray(manifest.capabilities).
The Worker Runtime#
File: server/services/plugin-runner.service.ts (PluginRunnerService, singleton via getPluginRunnerService())
There is no in-process plugin loading and no generic "process manager" service — the runner service is the single source of truth for worker processes, keyed by "orgId:pluginId" in an in-memory Map<string, WorkerInfo>.
startWorker(orgId, pluginId)#
-
Load the
PluginRegistryrow and the org'sPluginInstancerow; refuse to start if the instance doesn't exist or isn'tenabled. -
If
authMethod === "oauth"and the stored token expires within 5 minutes, refresh it viaoauthService.refreshToken()before spawning. -
Allocate a port (
server/services/port-allocator.service.ts) and resolve the org's config withresolveConfigForWorker(server/lib/config-resolver.ts), which merges DB config,env:fallbacks, and auth credentials. The config schema used for env fallbacks comes from the cached worker metadata (plugin_registry.metadata.configSchema). -
Spawn the SDK runner as a child process:
node packages/plugin-sdk/dist/runner/index.js \ --plugin-path=<abs path> --org-id=<orgId> --port=<port> --mode=production(In development, if the compiled runner doesn't exist, it falls back to
npx tsx packages/plugin-sdk/runner/index.ts.) -
Environment injection (
buildSDKEnv):HAY_ORG_ID,HAY_PLUGIN_ID,HAY_WORKER_PORTHAY_ORG_CONFIG— JSON:{ org: { id }, config: <resolved config> }HAY_ORG_AUTH— JSON-serializedAuthState(decrypted credentials)HAY_API_URL+HAY_API_TOKEN— a JWT (generatePluginJWT) scoped to{ organizationId, pluginId, scope: "plugin-api", capabilities }, expiring in 24h, so the worker can call back into core- Host env vars from the plugin's allow-list (
manifest.permissions.env, i.e.hay-plugin.env) — filtered through a deny-pattern list (SECRET,PASSWORD,TOKEN,PRIVATE_KEY,CREDENTIAL,^DB_,^DATABASE,^REDIS,^AWS_,^OPENAI,^ANTHROPIC,^JWT,^SMTP,^GITHUB,^NPM,^PLUGIN_ENCRYPTION,^HAY_) so secret-looking names are blocked by default
-
Wait for the worker's
GET /metadataendpoint to respond (up to 20 attempts × 500ms) — not/health. -
Update the
PluginInstancerow:runtimeState = "ready",running = true,processId, health"healthy". On failure:runtimeState = "error"+lastError. -
Kick off MCP tool discovery in the background (
fetchAndStoreToolsinserver/services/plugin-tools.service.ts, which caches the worker'sGET /mcp/list-toolsresult).
stopWorker(orgId, pluginId)#
POST /disable on the worker (5s timeout, best-effort), then SIGTERM, then SIGKILL after 5 seconds if it hasn't exited. The port is released, the worker removed from the map, and the instance row updated (runtimeState = "stopped", running = false); the process exit handler also updates state/health when a worker dies on its own.
Worker HTTP surface#
The SDK runner's HTTP server (packages/plugin-sdk/runner/http-server.ts) exposes exactly:
| Route | Purpose |
|---|---|
GET /health |
Liveness |
GET /metadata |
Descriptors registered in onInitialize (config schema, auth methods, routes, crons, UI pages, webhook routing) — core's readiness signal and metadata cache source |
POST /validate-auth |
Runs the plugin's onValidateAuth hook |
POST /on-connected |
Runs onConnected after OAuth tokens are stored |
POST /config-update |
Runs onConfigUpdate |
POST /disable |
Runs onDisable |
POST /cron/:name |
Runs a registered cron handler |
POST /mcp/call-tool, GET /mcp/list-tools |
MCP tool invocation / discovery |
| (dynamic) | Any route the plugin declared via register.route(method, path, handler) — mounted verbatim on the Express app (this is how channel plugins expose /webhook, /deliver, /escalate) |
All dashboard/webhook traffic to a worker goes through the core proxy: ALL /v1/plugins/:pluginId/* (server/routes/v1/plugins/proxy.ts). The proxy resolves the organization from auth, subdomain, or query param, starts the worker on demand, bumps its activity timestamp, strips credential-bearing headers (authorization, cookie, proxy-authorization, etc.), and forwards the request.
Instance Lifecycle Management#
File: server/services/plugin-instance-manager.service.ts (PluginInstanceManagerService)
ensureInstanceRunning(organizationId, pluginId)— the on-demand entry point. Deduplicates concurrent startups via astartupQueuemap, checks pool limits, and delegates the actual spawn to the runner service.- Idle cleanup:
INACTIVITY_TIMEOUT_MS = 5 minutes.cleanupInactiveInstances()is not run by an internal timer — it's invoked by the platform scheduler jobplugin-instance-cleanup(seeserver/services/scheduled-jobs.registry.ts), which runs every 60 seconds. Activity is tracked both in memory (instanceActivitymap) and in thelast_activity_atcolumn. - Pool limits: each plugin's
plugin_registry.max_concurrent_instances(default 10) caps concurrent workers across orgs.ensureInstanceRunningwaits up to 30 seconds for a slot (waitForAvailableSlot) before throwing. updateActivityTimestamp()is called from the proxy on every forwarded request so active workers aren't reaped mid-conversation.stopAllForOrganization(organizationId)tears down every worker for an org.
Implication for plugin behavior: workers are ephemeral. Anything a plugin needs long-lived (schedules, webhook subscriptions) must be delegated to core (register.cron, register.webhookRouting) rather than held in worker memory.
Database Entities#
All three entities live in server/entities/ and follow the project's snake_case-in-DB / camelCase-in-TS convention (see server/database/DATABASE_CONVENTIONS.md). The field lists below reflect the entity classes — do not trust older docs showing raw SQL with SERIAL primary keys; IDs are UUIDs.
plugin_registry (plugin-registry.entity.ts)#
One row per discovered plugin (global, not per-org). Unique index on pluginId.
| Field | Type | Notes |
|---|---|---|
id |
uuid PK | |
pluginId |
varchar(255) | npm package name; unique |
name, version |
varchar | display name / package version |
pluginPath |
varchar(255) | relative to the plugins/ root, e.g. core/stripe |
manifest |
jsonb | the RegistryManifest built from package.json |
installed, built |
boolean | build/install state |
lastInstallError, lastBuildError |
text | |
installedAt, builtAt |
timestamptz | |
checksum |
varchar(64) | SHA-256 for change detection |
maxConcurrentInstances |
integer, default 10 | worker pool cap |
metadata, metadataFetchedAt, metadataState |
jsonb / timestamptz / varchar | plugin-global cache of the worker's /metadata response; metadataState defaults to "missing" |
status |
varchar | available | not_found | disabled (PluginStatus enum) |
sourceType |
varchar | core | custom | git |
organizationId |
uuid, nullable | set for custom plugins |
zipFilePath, zipUploadId, uploadedById, uploadedAt |
custom-plugin upload provenance | |
gitConnectionId, gitRepoFullName, gitBranch, gitLastCommitSha, gitLastSyncAt, gitSyncError |
git-sourced plugin fields |
plugin_instances (plugin-instance.entity.ts)#
One row per (organizationId, pluginId) — the org's enablement, config, auth, and runtime state. Unique index on (organizationId, pluginId). Note pluginId here is a uuid FK to plugin_registry.id, not the string plugin ID.
| Field | Type | Notes |
|---|---|---|
pluginId |
uuid FK → plugin_registry |
|
enabled |
boolean | |
config |
jsonb | org config values |
authState |
jsonb | AuthState — all credential fields encrypted at rest via AuthStateEncryptedTransformer (server/lib/auth/utils/encryption.ts) |
authValidatedAt |
timestamptz | |
authMethod |
varchar | api_key | oauth |
running, processId, lastStartedAt, lastStoppedAt, lastError, restartCount |
worker process bookkeeping | |
lastHealthCheck, healthStatus |
healthy | unhealthy | unknown |
|
status |
varchar | legacy field (stopped/starting/running/stopping/error), kept for backwards compatibility |
runtimeState |
varchar | the current state machine field (PluginInstanceRuntimeState) |
lastActivityAt |
timestamptz | idle-kill input |
priority |
integer |
plugin_webhook_routes (plugin-webhook-route.entity.ts)#
Plugin-agnostic index mapping (pluginId, routingKey) → (organizationId, pluginInstanceId), with a unique index on the pair. Populated from the routing keys a plugin returns from its onConnected hook; consumed by the webhook router to resolve which org a shared-webhook payload belongs to. Cascade-deleted with the plugin instance.
Lifecycle Hooks#
A plugin's entry module default-exports defineHayPlugin(factory); the factory returns a HayPluginDefinition (packages/plugin-sdk/types/plugin.ts — the only required field is name). Core drives the hooks over the worker HTTP surface:
| Hook | Triggered by | Contract |
|---|---|---|
onInitialize |
Runner boot, before the HTTP server starts | Descriptor-only: register.* calls. No network, no org data. |
onStart |
Every worker start/restart for an org | Read ctx.config / ctx.auth, gate on credentials, start MCP servers. Must not crash the worker. |
onConnected |
Core POST /on-connected right after OAuth tokens are stored (oauth.service.ts) |
May return { routingKeys }, which core persists in plugin_webhook_routes. Throwing never fails the OAuth flow. |
onValidateAuth |
Core POST /validate-auth when credentials change |
Return true/false; a thrown error is caught by the runner and treated as invalid. If absent, auth is assumed valid. |
onConfigUpdate |
Never (currently). | The runner exposes POST /config-update, but core never calls it — plugins.configure saves the config and restarts the running worker instead, so config changes arrive via a fresh onStart. |
onDisable |
Core POST /disable on disable/uninstall and before SIGTERM |
Tear down resources. |
onEnable |
Never. | Typed in the SDK (types/plugin.ts, marked "CORE-ONLY, not called by runner") — but nothing in core calls it either. Reserved for a future core-side path; do not build on it. |
The register.* API available in onInitialize (packages/plugin-sdk/types/register.ts): register.config(schema), register.auth.apiKey(...) / register.auth.oauth2(...), register.route(method, path, handler), register.ui.page(...), register.cron(options), register.webhookRouting(descriptor). Everything registered surfaces as data in GET /metadata, which core caches on plugin_registry.metadata.
OAuth is entirely core-side. server/services/oauth.service.ts runs authorization, token exchange, and refresh — including non-standard flows a plugin declares via tokenExchange/tokenRefresh descriptors (packages/plugin-sdk/types/auth.ts), executed declaratively by core (used by the Instagram plugin's ig_exchange_token/ig_refresh_token). Plugins never call OAuth endpoints themselves; the runner also refreshes expiring tokens before worker start (see The Worker Runtime).
Cron Scheduling#
File: server/services/plugin-cron.service.ts
Because workers are idle-killed, plugins must not self-schedule with setInterval/node-cron. Instead, register.cron declares the schedule as data, and core:
- Reads cron descriptors from the plugin's cached
/metadata. - Registers one platform-scheduler job per enabled org per declared cron, named
plugin-cron:<pluginId>:<orgId>:<cronName>(viaschedulerService.registerJob). - When a job fires: ensure the worker is running (waking it if needed), then
POST /cron/:nameso the handler executes in the worker. - If the handler called
ctx.auth.update(...), the updated credentials come back in the response and core persists them (encrypted) and restarts the worker.
Jobs are unregistered when the plugin is disabled for the org.
Webhook Routing and Channel Delivery#
Inbound: shared-webhook fan-out#
Some providers (e.g. Meta) deliver all events for all customers to one webhook URL that carries no org identifier. A plugin can declare a WebhookRoutingDescriptor via register.webhookRouting (packages/plugin-sdk/types/webhook-routing.ts) — pure data, no code: an HMAC-SHA256 signature spec (header, format: "sha256-hmac", secret source), an optional GET verification-challenge spec, and dot-path extraction rules (routeKeyPath: { itemsPath, keyPath }).
The dispatch point is in the proxy (server/routes/v1/plugins/proxy.ts): a request to .../webhook with no org identifier, for a plugin that declared a routing descriptor, is diverted to webhookRouterService.handle() (server/services/webhook-router.service.ts), which:
- Answers the provider's GET verification handshake if declared (echoes the challenge param after checking the verify token).
- For POSTs, verifies the HMAC over the raw body bytes (
verifyHmacSha256fromserver/services/plugin-route.service.ts). - Extracts a routing key per payload entry (
routeKeyPath), resolves key → org viaplugin_webhook_routes, and fans each entry out to the right org's workerPOST /webhook(with credential-bearing headers stripped).
Core never learns which provider it's routing for — all provider knowledge is in the plugin's declaration.
Inbound: normal per-org webhooks and message ingestion#
Webhooks that do identify the org go straight through the proxy to the worker's registered /webhook route. The plugin verifies/filters the event and then calls back into core with messages.receive (see next section). Inbound dedupe is core-side: if the plugin passes metadata.mid, core claims it atomically via Redis SET ... NX EX and drops duplicates (server/routes/v1/plugin-api/trpc.ts); channels that don't pass a mid get no dedupe.
Outbound: channel delivery#
File: server/services/channel-delivery.service.ts
Subscribes to the Redis websocket:events channel:
message_receivedevents for bot/human-agent messages (web channel skipped) → resolve the plugin by channel slug viapluginManagerService.findPluginIdByChannel(channel)(matchinghay-plugin.channel), ensure the org's worker is up, andPOST /deliverwith{ to, content, messageId, conversationId, ... }. The plugin returns{ success, providerMessageId?, error? }; on success core stores the provider message ID. Convention: non-retryable provider errors return HTTP 200 withsuccess: falseto avoid retry storms.conversation_status_changed→pending-human→POST /escalateon the worker (web channel skipped). This is best-effort: a 404 from plugins that don't implement/escalateis silently tolerated.
See Channel Architecture for the plugin-author view.
The Plugin API (Worker → Core)#
File: server/routes/v1/plugin-api/trpc.ts
Workers call back into core over tRPC-HTTP using the HAY_API_URL/HAY_API_TOKEN env vars. Every procedure is a pluginProcedure gated by the worker's JWT (org + plugin + capabilities; server/trpc/middleware/plugin-auth.ts). Current surface:
messages.receive— input{ from, content, channel, metadata?, senderType?, externalConversationId? }; finds/creates the Customer (keyed onexternal_idper channel) and conversation, adds the messagemessages.send,messages.getByConversationconversations.updateStatusByExternalIdcustomers.get,customers.findByExternalId,customers.upsertsources.registermcp.registerLocal,mcp.registerRemoteproducts.upsertMany,products.delete(source is the authenticated plugin ID — a plugin can only touch its own products)
The router self-describes as a "simplified initial implementation" — expect this surface to grow.
Dashboard-Facing tRPC Surface#
Files: server/routes/v1/plugins/index.ts (router), server/routes/v1/plugins/plugins.handler.ts (implementations). All procedures are authenticatedProcedure.
- Queries:
plugins.getAll,plugins.get,plugins.getInstances,plugins.getUITemplate,plugins.getMCPTools,plugins.getMenuItems,plugins.testConnection,plugins.getPluginTranslations,plugins.oauth.isAvailable,plugins.oauth.status - Mutations:
plugins.enable,plugins.disable,plugins.restart,plugins.configure,plugins.refreshMCPTools,plugins.validateAuth,plugins.oauth.initiate,plugins.oauth.revoke
There is no generic plugins.invokeTool mutation — MCP tool invocation happens inside the orchestrator via the worker's POST /mcp/call-tool.
Non-tRPC HTTP: the catch-all worker proxy ALL /v1/plugins/:pluginId/* (proxy.ts), and plugin UI assets served at /plugins/ui/:pluginName/:assetPath.
Known Gaps and Legacy Paths#
Things a contributor should know exist (or don't) before building on them:
onEnableis dead code — typed in the SDK but invoked by neither the runner nor core.- Document importers bypass the worker model. Plugins with
autoActivate: true+trpcRouter(e.g.plugins/core/atlassian) load a tRPC router in-process at boot viaserver/services/plugin-router-registry.service.ts, coupled to@server/*internals. This is the legacy special case, not the pattern to extend. onConfigUpdateis also uncalled — the runner exposesPOST /config-update, but core applies config changes by restarting the worker (see the hooks table).PluginApiClientis duplicated per channel plugin (plugins/core/instagram/src/plugin-api.ts, and likewise inchatwoot,email,whatsapp) instead of being an SDK export.- Webhook signature format is limited to
sha256-hmacinWebhookRoutingDescriptor. - The instance pool's
queuedRequestsstat is tracked but never incremented — pool limiting works via polling (waitForAvailableSlot), not a real queue. - The
plugin_instances.statuscolumn is legacy;runtimeStateis the field the runner actually maintains.
When contributing to any of the above: follow existing patterns in the neighboring services, keep the core plugin-agnostic (never hardcode plugin IDs — resolve behavior from the manifest/metadata), and remove legacy paths outright rather than layering compatibility shims (this codebase is in alpha; breaking changes are preferred over dead code).