API Reference
Complete reference for the Hay plugin SDK and plugin API endpoints
Hay Plugin API Reference#
The complete reference for the
@hay/plugin-sdksurface, the plugin worker contract, and the plugin-related API endpoints
Hay plugins are code-first: a plugin is an npm package whose package.json carries a hay-plugin block and whose entry module default-exports the result of defineHayPlugin(...). There is no manifest.json — if you find docs or examples referencing one, they describe the old system and are stale.
Table of Contents#
- Plugin Model
- The
hay-pluginPackage Block - defineHayPlugin and Lifecycle Hooks
- Hook Contexts
- The register API
- Runtime APIs
- Worker HTTP Contract
- Plugin → Core Callback API
- Channel Delivery Contract
- Dashboard tRPC Endpoints
- Internationalization (i18n)
- Migrating from the Old Manifest System
Plugin Model#
Discovery#
A directory under plugins/core/<name>/ (or plugins/custom/{orgId}/<name>/) is recognized as a plugin if and only if its package.json contains a hay-plugin key. Discovery lives in server/services/plugin-manager.service.ts (scanPluginDirectory → registerPlugin); directories without the block are skipped.
- Plugin ID = the npm package
name(e.g.hay-plugin-klaviyo,hay-channel-instagram-meta). - Entry point =
hay-plugin.entry(e.g../dist/index.js), which must default-exportdefineHayPlugin(factory). - Packages must be ESM (
"type": "module") — the loader dynamicallyimport()s them. - The SDK is always referenced as
"@hay/plugin-sdk": "file:../../../packages/plugin-sdk"(note thepackages/segment).
Execution: one worker per org per plugin#
Plugins do not run inside the core process. For each (organizationId, pluginId) pair, core lazily spawns a separate HTTP worker process:
node packages/plugin-sdk/dist/runner/index.js --plugin-path=… --org-id=… --port=…
(server/services/plugin-runner.service.ts). Key facts about workers:
- Config and auth are injected as env JSON (
HAY_ORG_CONFIG,HAY_ORG_AUTH) — never as raw provider env vars. Read them throughctx.config/ctx.auth. - Each worker receives
HAY_API_URLand a scoped JWT inHAY_API_TOKEN(generated with the plugin's declared capabilities) for calling back into core. - Core waits for the worker's
GET /metadataendpoint before considering it started. - Workers are idle-killed after 5 minutes of inactivity (checked every minute,
server/services/plugin-instance-manager.service.ts). Never rely on the process staying alive — this is why crons are scheduled by core, not the worker.
Canonical directory layout#
plugins/core/<name>/
├── package.json # name = plugin ID; "type": "module"; hay-plugin block
├── tsconfig.json # ESM, strict, exclude ["node_modules", "dist", "mcp"]
├── thumbnail.svg # plugin icon, served at /plugins/thumbnails/:pluginId (svg > png > jpg)
├── src/index.ts # default export = defineHayPlugin(...) [REQUIRED]
├── mcp/ # local stdio MCP server (optional)
│ ├── index.js
│ └── package.json
├── components/ # Vue UI (capability "ui")
├── vite.config.ui.ts # builds components → dist/ui.js (UMD, vue externalized)
├── i18n/ # en.json (+ other locales)
└── dist/ # build output (gitignored)
Build from the repo root with npm --workspace=plugins/core/<name> run build; scripts/build-plugins.sh batch-builds all plugins.
The hay-plugin Package Block#
The fields core actually reads from package.json → "hay-plugin" (interface HayPluginBlock in server/services/plugin-manager.service.ts):
| Field | Type | Description |
|---|---|---|
entry |
string |
Path to the compiled entry module (e.g. "./dist/index.js") |
displayName |
string |
Human-readable name shown in the marketplace |
category |
string |
One of integration | channel | tool | analytics | products (SDK PluginCategory); document importers are marked via documentImporter, not a category |
capabilities |
string[] |
Declared capabilities: routes, mcp, auth, config, ui, messages, customers, sources, products, cron |
env |
string[] |
Allow-list of host env var names that config fields may fall back to via their env: property |
channel |
string |
Channel plugins only: the channel slug (e.g. "instagram"); matched by pluginManagerService.findPluginIdByChannel() for outbound delivery |
autoActivate |
boolean |
Legacy (document importers) |
trpcRouter |
string |
Legacy (document importers): path to a tRPC router loaded in-process |
documentImporter |
boolean |
Legacy: marks a document importer plugin |
config |
object |
Legacy: manifest-style config schema, copied into the registry manifest's configSchema (new plugins use register.config instead) |
auth |
object |
Legacy: manifest-style auth config, copied into the registry manifest (new plugins use register.auth.* instead) |
Capabilities are declarative: they drive marketplace classification, plugin type inference, and the scope of the worker's callback JWT — they are not hard-enforced against your implementation.
Example (channel plugin)#
{
"name": "hay-channel-instagram-meta",
"type": "module",
"hay-plugin": {
"entry": "./dist/index.js",
"displayName": "Instagram",
"category": "channel",
"channel": "instagram",
"capabilities": ["messages", "customers"],
"env": ["META_APP_ID", "META_APP_SECRET", "META_VERIFY_TOKEN"]
}
}
Example (integration plugin)#
{
"name": "hay-plugin-klaviyo",
"type": "module",
"hay-plugin": {
"entry": "./dist/index.js",
"displayName": "Klaviyo",
"category": "integration",
"capabilities": ["mcp", "auth"]
}
}
defineHayPlugin and Lifecycle Hooks#
The only factory is defineHayPlugin from @hay/plugin-sdk (not createPlugin or definePlugin). It takes a HayPluginFactory:
import { defineHayPlugin } from "@hay/plugin-sdk";
export default defineHayPlugin((globalCtx) => ({
name: "My Plugin", // the ONLY required field
onInitialize() {
globalCtx.register.config({
/* ... */
});
globalCtx.register.auth.apiKey({
/* ... */
});
},
async onStart(ctx) {
const apiKey = ctx.config.getOptional<string>("apiKey");
if (!apiKey) {
ctx.logger.warn("Not configured yet — skipping startup");
return;
}
await ctx.mcp.startLocalStdio({
/* ... */
});
},
}));
HayPluginDefinition (packages/plugin-sdk/types/plugin.ts) — name is required, every hook is optional:
| Hook | Signature | When it runs |
|---|---|---|
onInitialize |
(ctx: HayGlobalContext) => void | Promise<void> |
Once per worker process, before the HTTP server starts. Descriptor-only: make register.* calls; no network requests, no org data. |
onStart |
(ctx: HayStartContext) => void | Promise<void> |
Every org runtime start/restart (first config save, config/auth updates). Read config/auth, gate on missing credentials, start MCP servers. Must never crash the worker — log errors instead. |
onConnected |
(ctx: HayConnectedContext) => { routingKeys?: string[] } | Promise<{ routingKeys?: string[] }> |
Called by core (POST /on-connected) right after OAuth tokens are stored. Return opaque routing keys that core persists for shared-webhook fan-out. Throwing never fails the OAuth flow — core logs a warning and continues. |
onValidateAuth |
(ctx: HayAuthValidationContext) => boolean | Promise<boolean> |
When auth credentials are saved/updated (POST /validate-auth). Return true for valid, or throw an Error with a user-facing message. If not implemented, auth is assumed valid. |
onConfigUpdate |
(ctx: HayConfigUpdateContext) => void | Promise<void> |
After settings are saved (POST /config-update). Most plugins can omit this — the platform restarts registered MCP servers itself. |
onDisable |
(ctx: HayDisableContext) => void | Promise<void> |
On disable/uninstall (and SIGTERM). Tear down pollers, sockets, and external subscriptions. |
onEnable |
— | Typed but never called by the runner. Reserved for future use by core. Do not rely on it. |
Hook Contexts#
All context interfaces live in packages/plugin-sdk/types/contexts.ts.
| Context | Fields |
|---|---|
HayGlobalContext (onInitialize + factory arg) |
register: HayRegisterAPI, config: HayConfigDescriptorAPI (field references only — config.field(name)), logger: HayLogger |
HayStartContext (onStart) |
org: HayOrg, config: HayConfigRuntimeAPI, auth: HayAuthRuntimeAPI, mcp: HayMcpRuntimeAPI, productSource?: HayProductSourceRuntimeAPI (only with the products capability), logger |
HayConnectedContext (onConnected) |
org, config, auth (fresh tokens), logger |
HayAuthValidationContext (onValidateAuth) |
org, config, auth, logger |
HayConfigUpdateContext (onConfigUpdate) |
org, config, logger |
HayDisableContext (onDisable) |
org, logger |
HayCronContext (cron handlers) |
org, config, auth: HayCronAuthAPI (adds update()), logger — no mcp |
The key rule: onInitialize is declarative (register schemas and descriptors), org runtime hooks are operational (read resolved values, start things).
The register API#
Available as ctx.register inside onInitialize (types in packages/plugin-sdk/types/register.ts). Registered metadata is exposed via the worker's /metadata endpoint.
register.config#
register.config(schema: Record<string, ConfigFieldDescriptor>): void
Defines configuration fields; the dashboard generates the settings form from this schema. ConfigFieldDescriptor (packages/plugin-sdk/types/config.ts):
| Property | Type | Description |
|---|---|---|
type |
"string" | "number" | "boolean" | "json" |
Field type (required) |
label |
string |
UI label |
description |
string |
UI help text |
placeholder |
string |
Input placeholder |
required |
boolean |
Show a configuration error if missing (default false) |
encrypted |
boolean |
Encrypt at rest, mask in UI and logs. Mandatory for secrets. |
default |
T |
Default value |
env |
string |
Host env var fallback. Must be listed in hay-plugin.env — validated at load time. |
options |
Array<{ label: string; value: string | number }> |
Renders a dropdown instead of free text |
showWhen |
ShowWhen |
Declarative visibility: { field, equals? | in? | notEquals? } referencing another field |
register.config({
apiKey: {
type: "string",
label: "API Key",
description: "Your Klaviyo private API key",
required: true,
encrypted: true,
},
maxRetries: { type: "number", label: "Max Retries", default: 3 },
});
Resolution order at runtime for ctx.config.get(key): org-stored value → process.env[env] fallback (if allow-listed) → default.
register.auth#
The platform runs the entire OAuth dance — authorization, token exchange, storage, and automatic refresh. Plugins never touch OAuth endpoints themselves.
register.auth.apiKey#
register.auth.apiKey(options: ApiKeyAuthOptions): void
interface ApiKeyAuthOptions {
id: string; // auth method id (appears as AuthState.methodId)
label: string; // UI label
configField: string; // name of the register.config field holding the key
}
register.auth.oauth2#
register.auth.oauth2(options: OAuth2AuthOptions): void
OAuth2AuthOptions (packages/plugin-sdk/types/auth.ts):
| Property | Type | Description |
|---|---|---|
id |
string |
Auth method id |
label |
string |
UI label |
authorizationUrl |
string |
Provider authorize URL |
tokenUrl |
string |
Provider token URL |
scopes |
string[] |
Requested scopes (optional) |
clientId |
ConfigFieldReference |
Created with ctx.config.field("clientId") |
clientSecret |
ConfigFieldReference |
Created with ctx.config.field("clientSecret") |
authorizationParams |
Record<string, string> |
Extra static authorize-URL params. Reserved params (client_id, redirect_uri, response_type, state, scope, etc.) cannot be overridden. |
scopeSeparator |
string |
Scope join delimiter; defaults to a space. Instagram needs ",". |
tokenExchange |
OAuthTokenOp |
Declarative one-time token transform run by core right after the code exchange (e.g. Instagram ig_exchange_token short→long-lived swap) |
tokenRefresh |
OAuthTokenOp |
Declarative custom refresh strategy for providers without a standard refresh_token grant (e.g. Instagram ig_refresh_token) |
interface OAuthTokenOp {
url: string; // endpoint to GET
grantType: string; // value for the grant_type query param
tokenParam: string; // query-param name carrying the current access token
includeClientSecret?: boolean; // append client_secret (needed by token exchange)
}
Both token ops are executed server-side by core (server/services/oauth.service.ts) as a single GET request whose JSON response must include access_token.
register.auth.oauth2({
id: "oauth",
label: "Connect with Instagram",
authorizationUrl: "https://www.instagram.com/oauth/authorize",
tokenUrl: "https://api.instagram.com/oauth/access_token",
scopes: ["instagram_business_basic", "instagram_business_manage_messages"],
scopeSeparator: ",",
clientId: globalCtx.config.field("clientId"),
clientSecret: globalCtx.config.field("clientSecret"),
tokenExchange: {
url: "https://graph.instagram.com/access_token",
grantType: "ig_exchange_token",
tokenParam: "access_token",
includeClientSecret: true,
},
tokenRefresh: {
url: "https://graph.instagram.com/refresh_access_token",
grantType: "ig_refresh_token",
tokenParam: "access_token",
},
});
register.route#
register.route(method: HttpMethod, path: string, handler: RouteHandler): void
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type RouteHandler = (req, res) => void | Promise<void>; // Express-compatible
Routes are mounted on the plugin's worker HTTP server. Common uses: provider webhooks (POST /webhook), outbound delivery (POST /deliver), escalation (POST /escalate), callbacks. External traffic reaches them through the core proxy (ALL /v1/plugins/:pluginId/*), which strips credential-bearing headers before forwarding.
register.route("POST", "/webhook", async (req, res) => {
// verify signature, filter events, then call back into core
res.status(200).json({ received: true });
});
register.ui.page#
register.ui.page(page: PluginPage): void
PluginPage (packages/plugin-sdk/types/ui.ts):
| Property | Type | Description |
|---|---|---|
id |
string |
Unique within the plugin |
title |
string |
Display title |
component |
string |
Path relative to plugin root (e.g. "./components/settings/AfterSettings.vue"); the export name is derived from the filename |
slot |
"standalone" | "after-settings" | "before-settings" |
Where to render (default "standalone" — standalone routes are a future enhancement) |
icon |
string |
Optional icon identifier |
requiresSetup |
boolean |
Only show after configuration is complete |
UI components are built by Vite (vite.config.ui.ts) into a UMD bundle at dist/ui.js with Vue externalized; the dashboard resolves components by export name and serves assets from /plugins/ui/:pluginName/:assetPath.
register.cron#
register.cron(options: CronJobOptions): void
CronJobOptions (packages/plugin-sdk/types/cron.ts):
| Property | Type | Description |
|---|---|---|
name |
string |
Unique within the plugin; must match [a-z0-9_-]+ |
schedule |
string |
5-field cron expression |
handler |
(ctx: HayCronContext) => void | Promise<void> |
Invoked when the job fires |
retryPolicy |
{ maxRetries?: number; backoff?: "fixed" | "exponential" } |
Optional; backoff is advisory |
Crons are scheduled by core, not the worker (workers are idle-killed). server/services/plugin-cron.service.ts registers one platform-scheduler job per enabled org per declared cron (job id plugin-cron:<pluginId>:<orgId>:<name>), wakes the worker when it fires, and invokes POST /cron/:name. Never use setInterval or node-cron inside a plugin.
Cron handlers get an extended auth API to persist refreshed credentials:
register.cron({
name: "refresh_token",
schedule: "0 */20 * * *",
handler: async (ctx) => {
const fresh = await refreshSomehow(ctx.auth.get());
// Buffered, returned to core, persisted encrypted; worker restarted.
ctx.auth.update({ accessToken: fresh.accessToken, expiresAt: fresh.expiresAt });
},
retryPolicy: { maxRetries: 3, backoff: "exponential" },
});
register.webhookRouting#
register.webhookRouting(descriptor: WebhookRoutingDescriptor): void
For providers that deliver every org's events to a single shared webhook URL with no org identifier (e.g. Meta apps). The plugin declares — as plain data, no code execution — how core should verify, answer the handshake, and route. Core executes the strategy blindly (server/services/webhook-router.service.ts) and fans events out to the right per-org workers' POST /webhook.
WebhookRoutingDescriptor (packages/plugin-sdk/types/webhook-routing.ts):
| Field | Description |
|---|---|
signature |
{ header, format: "sha256-hmac", secretEnv } — core verifies HMAC-SHA256 over the exact raw request bytes with the secret from process.env[secretEnv] (must be in the hay-plugin.env allow-list); timing-safe compare, sha256= prefix stripped. Only sha256-hmac is supported. |
verificationChallenge |
Optional GET handshake: { modeParam, verifyTokenParam, challengeParam, verifyTokenEnv? | verifyTokenConfigField? } — core echoes the challenge when mode is subscribe and the token matches. |
routeKeyPath |
{ itemsPath, keyPath } — dot-paths (no eval, no DSL) to the events array and the per-event routing key. |
Routing keys are matched against the keys persisted from onConnected to resolve the target org.
register.webhookRouting({
signature: {
header: "x-hub-signature-256",
format: "sha256-hmac",
secretEnv: "META_APP_SECRET",
},
verificationChallenge: {
modeParam: "hub.mode",
verifyTokenParam: "hub.verify_token",
challengeParam: "hub.challenge",
verifyTokenEnv: "META_VERIFY_TOKEN",
},
routeKeyPath: { itemsPath: "entry", keyPath: "id" },
});
Runtime APIs#
Available on org runtime hook contexts (never in onInitialize).
ctx.config#
HayConfigRuntimeAPI (packages/plugin-sdk/types/config.ts):
config.get<T = any>(key: string): T // throws/errors if required + missing
config.getOptional<T = any>(key: string): T | undefined // undefined if not configured
config.keys(): string[] // all registered field names
config.toEnv(mapping: Record<string, string>): Record<string, string>
// toEnv maps config values to env-var names for child processes;
// undefined/null values are omitted.
ctx.auth#
HayAuthRuntimeAPI (packages/plugin-sdk/types/auth.ts):
auth.get(): AuthState | null
interface AuthState {
methodId: string; // the id used in register.auth.*
credentials: Record<string, unknown>;
// API key: { apiKey: string }
// OAuth2: { accessToken: string, refreshToken?: string, expiresAt?: number }
}
Cron handlers additionally get auth.update(credentials, methodId?) (see register.cron).
ctx.mcp#
HayMcpRuntimeAPI (packages/plugin-sdk/types/mcp.ts) — three ways to expose MCP tools. The platform restarts registered MCP servers on config change, stops them on disable, and caches tool lists via the worker's GET /mcp/list-tools.
// 1. Local stdio child process — the most common pattern.
// cwd is relative to the plugin directory; pass credentials via env.
await ctx.mcp.startLocalStdio({
id: "klaviyo-mcp",
command: "node",
args: ["index.js"],
cwd: "./mcp",
env: { KLAVIYO_API_KEY: ctx.config.get("apiKey") },
});
// 2. Provider-hosted (remote) MCP server.
await ctx.mcp.startExternal({
id: "stripe-mcp",
url: "https://mcp.stripe.com",
authHeaders: { Authorization: `Bearer ${apiKey}` },
});
// 3. In-process MCP server (advanced).
await ctx.mcp.startLocal("my-mcp", (mcpCtx) => new MyMcpServer(mcpCtx));
Known framework gap: nothing runs
npm installinsidemcp/at build time (andnode_modulesis gitignored repo-wide). Plugins with a local stdio MCP server must either ensuremcp/node_modulesexists on the host (runnpm installinsidemcp/manually, asplugins/core/klaviyorequires) or use only Node built-ins.
ctx.productSource#
HayProductSourceRuntimeAPI (packages/plugin-sdk/types/products.ts) — present on HayStartContext only when the plugin declares the products capability. Lets an e-commerce plugin sync its catalog into Hay's canonical product store (the SDK-level wrapper over the products.upsertMany / products.delete callback procedures):
productSource.upsert(products: CanonicalProduct[]): Promise<{ upserted: number; errors: number }>
productSource.delete(externalId: string): Promise<{ removed: boolean }>
CanonicalProduct (same file) is the canonical catalog shape: externalId, handle, title, variants: CanonicalVariant[] (required), plus optional descriptionHtml, vendor, productType, status: "active" | "draft" | "archived", tags, categories, options, images, currency, sourceUrl, attributes. Variants carry externalId, title, and optional sku, price, compareAtPrice, inventoryQuantity, availability: "in_stock" | "out_of_stock" | "backorder", etc. Upserts are idempotent on (source, externalId); core stamps source from the authenticated plugin id. See plugins/core/shopify for a full sync implementation (initial sync via cron + webhook-driven updates).
Worker HTTP Contract#
Endpoints the SDK runner mounts on every worker (packages/plugin-sdk/runner/http-server.ts) — core calls these; plugins do not implement them directly:
| Endpoint | Purpose |
|---|---|
GET /health |
Liveness check |
GET /metadata |
Registered config schema, auth methods, routes, UI pages, cron descriptors; core waits on this at startup |
POST /validate-auth |
Invokes onValidateAuth |
POST /on-connected |
Invokes onConnected after OAuth tokens are stored |
POST /config-update |
Invokes onConfigUpdate |
POST /disable |
Invokes onDisable |
POST /cron/:name |
Invokes the named cron handler |
POST /mcp/call-tool |
Executes an MCP tool |
GET /mcp/list-tools |
Lists MCP tools (cached by core) |
| (plugin routes) | Anything registered via register.route (e.g. /webhook, /deliver, /escalate) |
External traffic reaches worker routes only through the core proxy: ALL /v1/plugins/:pluginId/* (server/routes/v1/plugins/proxy.ts). The org is resolved from auth, subdomain, or query param — or, for a POST /webhook with no org identifier and a declared webhookRouting descriptor, the request is diverted to the shared webhook router.
Plugin → Core Callback API#
Workers call back into core using the injected HAY_API_URL + HAY_API_TOKEN (tRPC over HTTP). Procedures live in server/routes/v1/plugin-api/trpc.ts and are capability-gated by the worker's JWT (a plugin without the messages capability cannot call messages.receive).
| Procedure | Capability | Input (key fields) |
|---|---|---|
messages.receive |
messages |
{ from, content, channel, metadata?, senderType?: "customer" | "human_agent", externalConversationId? } — finds/creates the customer (keyed on external_id per channel) and conversation, adds the message. senderType: "human_agent" flips the conversation to human-took-over. |
messages.send |
messages |
{ to, content, channel, conversationId?, metadata? } |
messages.getByConversation |
messages |
{ conversationId: uuid } |
conversations.updateStatusByExternalId |
messages |
{ channel, externalConversationId, status: "open" | "pending-human" | "human-took-over" | "resolved" | "closed" } |
customers.get |
customers |
{ customerId: uuid } |
customers.findByExternalId |
customers |
{ externalId } |
customers.upsert |
customers |
{ externalId, channel, email?, phone?, name?, metadata? } |
sources.register |
sources |
{ id, name, category: "messaging" | "social" | "email" | "helpdesk", icon?, metadata? } — currently a stub (accepts input, logs, returns { success: true }; persistence is a TODO in the handler) |
mcp.registerLocal / mcp.registerRemote |
mcp |
Register MCP server + tool descriptors with core |
products.upsertMany / products.delete |
products |
Product catalog sync (e.g. Shopify); core stamps the product source from the authenticated plugin id |
Inbound message dedupe is core-side: if metadata.mid is set on messages.receive, core claims it atomically via Redis SETNX and silently drops duplicates. Channels that don't pass mid get no dedupe.
The router self-describes as a simplified initial implementation ("full conversation management coming in Phase 4"). Also note: the
PluginApiClienthelper is currently duplicated per channel plugin (e.g.plugins/core/instagram/src/plugin-api.ts,plugins/core/chatwoot/src/plugin-api.ts) rather than exported from the SDK — a known gap.
Legacy REST callback endpoints#
An older Express router still exists alongside the tRPC callback API, mounted at /v1/plugin-api (server/routes/v1/plugin-api/index.ts), authenticated with the same worker JWT: POST /send-email (requires an email capability — a legacy value outside the SDK's PluginCapability union, used by plugins/core/email), POST /mcp/register-local, POST /mcp/register-remote, and GET /health. New plugins should use the tRPC procedures; the REST surface is kept for the email plugin path.
Channel Delivery Contract#
For category: "channel" plugins (see Channel Registration and Channel Architecture for the full guide).
Inbound: provider webhook → core proxy POST /v1/plugins/:pluginId/webhook → worker's registered POST /webhook route → plugin verifies/filters → plugin calls messages.receive on the callback API.
Outbound: server/services/channel-delivery.service.ts subscribes to the Redis websocket:events channel. On message_received events for bot/human-agent messages with deliveryState === "sent" (web channel skipped), it resolves the plugin via hay-plugin.channel, wakes the org's worker, and posts:
POST /deliver
{ to, content, messageId, conversationId, conversationMetadata, messageMetadata }
The plugin's /deliver route responds with { success, providerMessageId?, error? }. Convention: non-retryable provider errors return HTTP 200 with success: false (e.g. a 24-hour-window expiry) to avoid retry storms; reserve non-200 responses for retryable failures.
Escalation: when a conversation transitions to pending-human, core posts to the worker's /escalate. This is best-effort — plugins that don't implement it return 404, which core silently tolerates.
Dashboard tRPC Endpoints#
The dashboard-facing router (server/routes/v1/plugins/index.ts, handlers in plugins.handler.ts). All procedures use authenticatedProcedure (JWT + x-organization-id). Call them from the dashboard via the Hay client:
import { Hay } from "@/utils/api";
Queries#
| Procedure | Input | Description |
|---|---|---|
Hay.plugins.getAll.query() |
— | All discovered plugins with org-scoped enabled/config state |
Hay.plugins.get.query({ pluginId }) |
{ pluginId: string } |
One plugin's details, including current configuration |
Hay.plugins.getInstances.query() |
— | Plugin instances for the org |
Hay.plugins.getUITemplate.query({ pluginId }) |
{ pluginId } |
Configuration UI template |
Hay.plugins.getMCPTools.query() |
— | Cached MCP tools across enabled plugins |
Hay.plugins.getMenuItems.query() |
— | Plugin-contributed menu items |
Hay.plugins.testConnection.query({ pluginId }) |
{ pluginId } |
Health check (PluginHealthCheckResult) |
Hay.plugins.getPluginTranslations.query({ locale }) |
{ locale: string } |
i18n bundles for the org's enabled plugins, falling back to en |
Hay.plugins.oauth.isAvailable.query({ pluginId }) |
{ pluginId } |
{ available, ... } — whether OAuth is registered and client credentials are resolvable |
Hay.plugins.oauth.status.query({ pluginId }) |
{ pluginId } |
Current OAuth connection status |
Mutations#
| Procedure | Input | Description |
|---|---|---|
Hay.plugins.enable.mutate({ pluginId, configuration? }) |
{ pluginId, configuration?: Record<string, any> } |
Enable for the org (custom plugins are org-access-checked) |
Hay.plugins.disable.mutate({ pluginId }) |
{ pluginId } |
Disable; calls the worker's /disable (5s timeout) before teardown |
Hay.plugins.restart.mutate({ pluginId }) |
{ pluginId } |
Restart the org's worker |
Hay.plugins.configure.mutate({ pluginId, configuration }) |
{ pluginId, configuration: Record<string, any> } |
Save configuration (encrypted fields encrypted at rest) |
Hay.plugins.refreshMCPTools.mutate({ pluginId }) |
{ pluginId } |
Re-fetch tool list from the worker |
Hay.plugins.validateAuth.mutate({ pluginId, authState }) |
{ pluginId, authState: { methodId, credentials } } |
Proxies to the worker's /validate-auth |
Hay.plugins.oauth.initiate.mutate({ pluginId }) |
{ pluginId } |
Returns { authorizationUrl, state } to start the flow |
Hay.plugins.oauth.revoke.mutate({ pluginId }) |
{ pluginId } |
Revoke the stored connection |
Non-tRPC HTTP endpoints#
ALL /v1/plugins/:pluginId/*— catch-all proxy to the org's worker (webhooks, plugin routes)GET /plugins/ui/:pluginName/:assetPath— serves built plugin UI assets
Internationalization (i18n)#
Plugins may ship an i18n/ directory with per-locale JSON files (en.json required as the fallback; e.g. pt.json for Portuguese). Each file supports:
{
"name": "My Plugin",
"description": "One-sentence summary",
"tools": { "tool_name": { "label": "…", "description": "…" } },
"config": { "fieldName": { "label": "…", "description": "…" } }
}
Keys under tools must match MCP tool names; keys under config must match register.config field names. The dashboard fetches bundles via Hay.plugins.getPluginTranslations.query({ locale }) — only for plugins the org has enabled — and falls back to en for missing locales.
Migrating from the Old Manifest System#
If you are updating an old plugin or reading stale docs, these are the changes:
| Old system | Current system |
|---|---|
manifest.json with id/type/capabilities/configSchema |
package.json → "hay-plugin" block; nothing reads a manifest file |
| Plugin ID declared in the manifest | Plugin ID = npm package name |
Types mcp-connector, retriever, playbook, workflow, utility |
Categories: integration | channel | tool | analytics | products (+ legacy documentImporter: true flag) |
Manifest configSchema |
register.config(...) in onInitialize |
Manifest auth / OAuth env-var config |
register.auth.apiKey(...) / register.auth.oauth2(...); platform owns the whole OAuth flow, including declarative tokenExchange / tokenRefresh |
Manifest capabilities.api route declarations |
register.route(...) |
settingsExtensions in the manifest |
register.ui.page(...) |
MCP startCommand / installCommand in the manifest |
ctx.mcp.startLocalStdio(...) / startExternal(...) in onStart |
| Plugins loaded in-process | Per-org HTTP workers spawned from the SDK runner, idle-killed after 5 minutes |
Reading credentials from process.env in the entry |
ctx.config / ctx.auth; the env: field is only an allow-listed host fallback |
Self-scheduled background jobs (setInterval, node-cron) |
register.cron(...) — core owns the schedule and wakes the worker |
SDK path file:../../plugin-sdk |
file:../../../packages/plugin-sdk |
Legacy paths that still exist: document importers (documentImporter: true + autoActivate: true + trpcRouter in the hay-plugin block, alongside a normal category like "integration") ride a special in-process path outside the worker model — core loads the plugin's tRPC router in-process at boot instead of (or in addition to) spawning a worker. See plugins/core/atlassian (importer + MCP tools in one plugin) and plugins/core/notion (pure importer). The documentImporter flag adds document_importer to the registry manifest's internal type array.
Additional Resources#
- Getting Started: Getting Started guide
- Channel plugins: Channel Registration and Channel Architecture
- SDK source:
packages/plugin-sdk/(types undertypes/, runner underrunner/) - Reference implementations:
plugins/core/klaviyo/plugins/core/woocommerce(local MCP),plugins/core/stripe/plugins/core/hubspot(remote MCP),plugins/core/chatwoot/plugins/core/instagram/plugins/core/whatsapp(channels),plugins/core/shopify(OAuth + cron + products sync),plugins/core/atlassian/plugins/core/notion(document importers) - Authoring guide:
.claude/skills/build-plugin/(archetypes, templates, anti-patterns) — the canonical how-to companion to this reference