Contribution Reference
Every contribution bucket a plugin manifest can declare, its capability, its module contract, and the host semantics around it.
A contribution is a data descriptor in contributes.<bucket>[]. Its executable half, when it has one, lives at one condition-independent package export that codegen verifies without running.
Two rules hold for every bucket without exception:
- The manifest must declare the bucket's capability and an explicit
flag. Both are rechecked, together with the operator grant and anyrequiredEnvVars, immediately before plugin code runs. - Contributed kinds are namespaced
plugin.<pluginId>.<localId>, so a plugin can never shadow or collide with a core kind or another plugin's kind.
Bucket summary
Wired end to end
A production host path resolves and runs these contributions today.
| Bucket | Capability | Runs in | Failure direction |
|---|---|---|---|
sendTransports | send:transport | Convex Node action | Typed failure code; host owns retries |
agentSteps | agent:step | Convex Node action | Fails the inbox lifecycle closed |
draftStrategies | draft:strategy | Convex Node action | Falls back to the built-in default strategy |
sendGates | send:gate | Convex Node action | Routes the reply to human review |
automationSteps | automation:step | Convex Node action | failed step outcome, host-owned retry |
crons | scheduler:cron | Convex Node action | Run is skipped |
navItems | ui:navigation | Nuxt (data only) | Entry is dropped |
settingsPanels | ui:settings | Nuxt (data only) | Entry is dropped |
Declared and catalogued — but not yet invoked
The manifest type, validator capability check, and codegen metadata exist for these four. No host dispatch or authorization entry is shipped, so declaring one has no runtime effect today. They are listed here rather than with reserved names because their manifest shapes are available, but executable host adapters must land with a concrete producer.
| Bucket | Capability | Would run in | What is missing today |
|---|---|---|---|
automationTriggers | automation:trigger | Convex mutation | No host firing seam exists |
automationConditions | automation:condition | Convex query | There is no plugin condition evaluator — conditions/index.ts throws for a plugin.* kind |
webhookEvents | webhooks:publish | Data only | The persisted event validator is a closed core-only union and no publish path authorizes a plugin event |
importProviders | imports:provide | Convex Node action | The import walker's provider registry is core-only, and integrationImports.provider cannot hold a plugin kind |
Four capabilities have no contribution bucket: the host mediates llm:invoke, plugin-storage:read and plugin-storage:write, while worker:enqueue is reserved for a Tier-3 adapter that is not shipped. The storage quotas and LLM budget live on the capability reference; the dormant worker protocol is documented under Sandboxed Jobs.
Send transports
sendTransports: [{
id, label, module: { exportPath }, retryDelays: [/* ≤ 3 bounded delays */],
requiredEnvVars: ['PLUGIN_ACME_TOKEN'], optionalEnvVars?, // PLUGIN_-prefixed, no "__"
credentialFields?, // the form, joined to those
supportsCustomReturnPath?, messageIdSource?, deduplicatesOnIdempotencyKey?,
webhook?, domainIdentity?
}]
The stored provider kind is plugin.<pluginId>.<localId>. Codegen emits an isolate-safe metadata catalog plus a separate 'use node' executable registry.
import type {
PluginSendAttempt,
PluginSendTransportConfig,
PluginSendTransportModule,
PluginSendTransportParams,
} from '@owlat/plugin-kit';
interface RelayExtras {
readonly endpoint: string;
}
/** One attempt only. Owlat owns retries, health, routing, and audit. */
export const transport: PluginSendTransportModule<RelayExtras> = {
parseExtras(input: unknown): RelayExtras {
if (typeof input !== 'object' || input === null) {
throw new TypeError('extras must be an object');
}
const endpoint = (input as { endpoint?: unknown }).endpoint;
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
throw new TypeError('extras.endpoint must be an https URL');
}
return { endpoint };
},
async send(
params: PluginSendTransportParams,
extras: RelayExtras,
// THIS INSTANCE's credentials, keyed by the name the manifest declared.
// Never `process.env`: that reads the deployment-default instance's token
// whichever transport id the send was addressed to.
config: PluginSendTransportConfig
): Promise<PluginSendAttempt> {
const response = await fetch(extras.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${config.env['PLUGIN_ACME_TOKEN'] ?? ''}`,
},
body: JSON.stringify({ to: params.to, from: params.from, subject: params.subject }),
});
if (response.status === 429) return { success: false, code: 'rate_limited' };
if (!response.ok) return { success: false, code: 'temporary_failure' };
return { success: true, id: response.headers.get('x-message-id') ?? '' };
},
};
parseExtras is the sole unknown-input boundary and must return the honest extras type or throw. send performs exactly one network attempt. Failure codes are the fixed vocabulary rate_limited, temporary_failure, ambiguous_timeout, invalid_recipient, invalid_sender, authentication_failed, content_rejected, unknown.
Immediately before every attempt the host rechecks the singleton organization, registration, flag, declaration, exact grant, and required env presence in a mutation. Denial never invokes plugin code and is audited as access_denied. Terminal audit rows contain only system attribution, outcome and attempt count — never addresses, content, provider ids, or raw errors.
Configuration, and named instances
Declare the deployment variables your transport reads in requiredEnvVars / optionalEnvVars. The host resolves them and hands send a third argument — { instanceKey, env } — carrying only those variables, keyed by the name you declared. Read your credentials from there, not from process.env: the environment names the deployment-default instance whichever transport id the send was addressed to.
Names must be PLUGIN_-prefixed and contain no __. The prefix is the namespace that keeps a manifest from being handed a credential that is not the plugin's; __ is the instance separator, so a base name containing it would alias another instance's variable.
Declaring configuration is also what earns your kind named instances: plugin.<pluginId>.<localId>#eu listed in SEND_TRANSPORT_INSTANCES reads PLUGIN_ACME_TOKEN__EU and arrives at send as PLUGIN_ACME_TOKEN with instanceKey: 'eu'. A required variable that is missing fails the attempt before your module runs. A transport that declares no configuration keeps reading the plugin's deployment-wide variables and is refused named instances, because a suffix reaches none of them.
Your transport counts as configured when the plugin's own flag.requiredEnvVars and your requiredEnvVars are all present — the union, not one or the other. A transport whose token is set inside a plugin nobody enabled is not sendable, and reporting it as ready would put it on a route the dispatch path then refuses. Only your own variables take the __<INSTANCEKEY> suffix; a flag variable is a deployment-wide switch and is read unsuffixed for every instance.
The two lists may not overlap. A variable named in both flag.requiredEnvVars and a transport's requiredEnvVars/optionalEnvVars fails manifest validation, because only one of the two scopes takes the suffix: a named instance would be graded configured on PLUGIN_ACME_TOKEN__EU alone while the deployment-wide variable that gates the whole plugin went unchecked, and every send to that instance would then be refused by the authorization path forever. Name the two separately — PLUGIN_ACME_ENABLED for the pack, PLUGIN_ACME_TOKEN for the transport.
optionalEnvVars may only accompany at least one requiredEnvVars entry. A transport whose whole configuration is optional has no credential a deployment must set, so nothing could decide whether one of its named instances is configured — the manifest is refused rather than silently given instances_unsupported.
The credentials form
credentialFields describes how to ASK an operator for the variables above — the same typed descriptors a core provider's catalog entry carries, in the settingsSchema vocabulary you already know:
credentialFields: [
{ kind: 'secret', key: 'token', label: 'Server token', required: true, envVar: 'PLUGIN_ACME_TOKEN' },
{ kind: 'string', key: 'stream', label: 'Message stream', envVar: 'PLUGIN_ACME_STREAM' },
]
Kinds are string, secret, number, boolean and select — the catalog's two composites (region-select, host-port) are ours, and a transport expresses the same configuration as their parts. Every field's envVar must be one this transport declared, matched to the field's own required: required: true names a member of requiredEnvVars, anything else names a member of optionalEnvVars. That join is what keeps a rendered form from asking for a variable no send reads, or omitting the one that gates the transport.
Descriptors are DESCRIPTIVE ONLY — nothing here decides what a send reads, and no surface renders a plugin's form yet.
Capabilities
| Field | Values | Absent means |
|---|---|---|
supportsCustomReturnPath | no | no. The only value this tier has, and the field is here so you can spell it. The core catalog's yes and probe both claim that Owlat's own bounce processor can attribute your bounces, which needs an envelope sender whose local part is a VERP token Owlat signs with a deployment secret — a key no bundled module is handed. Declaring one would grade your arm's bounce data as comparable with our own while the bounces land at your provider, so the manifest is refused instead. Your feedback path is the webhook below. |
messageIdSource | provider, composed | provider. composed says you echo back the Message-ID Owlat minted. |
deduplicatesOnIdempotencyKey | true, false | false. true also requires a buildSystemMailExtras export that carries the key into your request; the host refuses to register the transport otherwise, because a claim without the wiring turns a double delivery into a "safe" retry. |
Two catalog values are DERIVED from what the contribution carries rather than declared beside it, because a boolean next to the thing it describes could only ever disagree with it. hasProviderFeedback is true exactly when the contribution carries a webhook; domainVerification is api exactly when it carries a domainIdentity, and none otherwise. The core catalog's remaining values — acceptanceSemantics: 'accepted' and messageIdSource: 'idempotency-key' — are not available to this tier; each turns on host machinery that is not yet general, so they are refused at authoring time rather than mislabelling your sends.
Both extras builders are optional, pure and synchronous — no I/O, no clock, no environment. buildDispatchExtras receives one governed send's routing facts (idempotency key, message type, delivery domain, IP pool, warm-up overflow, engagement score) and buildSystemMailExtras receives the caller's idempotency key, when there was one. Whatever you return goes back through your own parseExtras before send sees it. The return-path host Owlat resolves for its own relay arm is deliberately not among them: it is authorised against that relay's published SPF, so stamping it from another transport would fail SPF on the bounce domain of every message you stamped.
Feedback webhook
A transport that receives bounces, complaints, deliveries and deferrals from its provider declares a second module export on the SAME contribution — its feedback half:
webhook: {
module: { exportPath },
signature: <one of the two host-verified schemes below>,
storeRawPayload?: boolean // default false
}
// The default. A contract that spells no `scheme` is this one.
signature: {
scheme?: 'hmac-timestamp-body',
header, algorithm, encoding, secretEnvVar, // PLUGIN_-prefixed
replay: { timestampHeader, toleranceSeconds } // ≤ 900
}
// Svix — what Resend and many other ESP consoles sign with.
signature: {
scheme: 'svix',
secretEnvVar, // PLUGIN_-prefixed
toleranceSeconds // ≤ 900
}
Pick the scheme your provider's console actually signs with. The Svix arm carries only those two fields because everything else — the svix-id / svix-timestamp / svix-signature headers, HMAC-SHA256, base64, the signed string `${id}.${timestamp}.${body}`, the whsec_ secret form — belongs to the scheme and is implemented once in the host; a manifest that could spell them could only disagree with the scheme it named. The two remaining host schemes, aws-sns and mandrill-form, are not available to this tier: the first is host infrastructure (a certificate Owlat fetches and caches, bound to a topic the deployment owns), the second is a legacy vendor shape signed over the deployment's own public URL.
All of them arrive on one route, POST /webhooks/plugin/<pluginId>. Because the route is keyed by plugin id, at most one transport per plugin may declare a webhook; a second one fails manifest validation, as does a webhook with no signature, a signature naming a scheme Owlat does not verify with, or a default-arm signature with no replay provisions.
List secretEnvVar in flag.requiredEnvVars — the manifest validator requires it. Without the secret the host cannot verify anything and answers every delivery 503, and a run of non-2xx is what makes a provider deactivate your endpoint; naming the variable in requiredEnvVars turns that invisible failure into a plugin an operator simply cannot enable until the secret is set.
The host verifies; the plugin parses. Owlat reads the secret from secretEnvVar, verifies the bytes under the scheme you named — the same code path that verifies Owlat's own core providers, so choosing a word never means supplying a verifier — refuses a timestamp further from now than toleranceSeconds, applies a delivery it has already accepted exactly zero further times (a repeat is answered 200 { success: true, duplicate: true }, because the usual cause is a lost acknowledgement rather than an attacker, and a 4xx would count against your endpoint), and rechecks flag, grant and env before the events land. A plugin never sees the secret and never decides whether a request is authentic — the endpoint is unauthenticated and internet-facing, so its strength cannot be a property of third-party code.
import type {
PluginSendTransportWebhookModule,
PluginWebhookFeedbackEvent,
} from '@owlat/plugin-kit';
/** Parse ONLY: these bytes are already verified, fresh, and not a replay. */
export const webhook: PluginSendTransportWebhookModule = {
parseEvents(rawBody: string): readonly PluginWebhookFeedbackEvent[] {
const batch = JSON.parse(rawBody) as { readonly records?: readonly unknown[] };
return (batch.records ?? []).flatMap((record) => {
const { type, id, at } = record as { type?: string; id?: string; at?: number };
if (type !== 'HardBounce' || typeof id !== 'string' || typeof at !== 'number') return [];
return [{ kind: 'bounced', providerMessageId: id, at, bounceType: 'hard' } as const];
});
},
};
The vocabulary is delivered, bounced, complained and deferred — the facts the send lifecycle and the measurement plane consume. Return [] for a batch carrying nothing Owlat acts on (a provider's verification ping, event kinds we ignore); throwing is answered 400 and the provider may redeliver. Every field you return is re-validated by the host, and providerType is stamped from the registry, so a batch cannot attribute itself to another transport. Provider message ids in the namespaces Owlat reserves for messages it minted itself — pb- (Postbox personal mail) and rp-probe. (return-path capability probes) — are refused for the same reason: the id chooses which lane the event is dispatched into.
Size your batches to these two limits. Both are answered 413 with nothing applied, and a provider retrying an over-limit delivery gets 413 again until it gives up — so the feedback in it is lost rather than delayed. Configure the provider to chunk; Owlat will not split what it refused.
| Limit | Ceiling |
|---|---|
Request body (PLUGIN_WEBHOOK_MAX_BODY_BYTES) | 1 048 576 bytes of UTF-8 |
Events returned per delivery (PLUGIN_WEBHOOK_MAX_BATCH_EVENTS) | 5 000 |
This module runs in the Convex isolate (it is imported by the HTTP router), so it must not import Node builtins. Raw request bodies are retained only when you set storeRawPayload: true — and when you do, a verified body is kept before your parseEvents runs, so the deliveries you most need the bytes of (the ones your parse half rejected) are the ones you get.
Sending-domain identity
A transport whose provider must be told about a customer's sending domain — and asked whether it may sign for it — declares a third module export on the same contribution:
domainIdentity: { module: { exportPath } }
One field, because everything else about a domain identity is the host's. Declaring it IS domainVerification: 'api' for this kind, and it registers your transport into the host's relay-identity registry at composition time: from then on the routing gate will ask you whether a From domain may be relayed, the identity backfill will provision domains an operator connected earlier, and the alignment pre-flight will ask you to describe your DKIM/SPF arm.
Two calls, and the split is the one every identity API draws. registerDomain is the WRITE — create or confirm the identity at the provider, idempotently, because the host re-registers on an operator's explicit repair. checkDomain is the READ the host repeats on its own schedule (daily once verified, hourly while DNS is outstanding) to keep the proof fresh. Both are handed the transport's resolved configuration and must read credentials from it rather than from process.env, and neither may be slow: the host calls them from a scheduled action, and a hung call is an unrefreshed proof that ages out.
Three answers, because the host writes each one differently. ok carries observations and is the only outcome that is EVIDENCE — the only one that refreshes the proof's age. auth_failed says the provider rejected this deployment's credential: terminal until an operator fixes it, recorded as such, and it does not overwrite the SPF/DKIM verdicts already stored, because a bad API key is not evidence that the operator's DNS stopped being valid. unavailable says the provider did not answer: evidence of nothing, so only the retry moves. A module that throws is read as unavailable — the host cannot tell a bug in your code from an outage, and the conservative reading is the one that neither condemns a credential nor refreshes a proof.
import type {
PluginDomainIdentityResult,
PluginSendTransportDomainIdentityModule,
} from '@owlat/plugin-kit';
/** Observations ONLY: the host derives the status and owns the freshness bound. */
export const domainIdentity: PluginSendTransportDomainIdentityModule = {
// The credential comes from the resolved configuration, never from
// `process.env`: an environment read resolves the deployment-default instance
// whichever instance the host meant.
registerDomain: (domain, config) => askProvider('POST', domain, config.env['PLUGIN_ACME_TOKEN']),
checkDomain: (domain, config) => askProvider('GET', domain, config.env['PLUGIN_ACME_TOKEN']),
};
async function askProvider(
method: 'GET' | 'POST',
domain: string,
token: string | undefined
): Promise<PluginDomainIdentityResult> {
const response = await fetch(`https://api.example.net/domains/${domain}`, {
method,
headers: { Authorization: `Bearer ${token ?? ''}` },
});
// Three distinguishable answers, because the host writes each one differently:
// only `ok` refreshes the proof's age, and only `auth_failed` condemns a key.
if (response.status === 401 || response.status === 403) {
return { outcome: 'auth_failed', error: 'the provider rejected the token' };
}
if (!response.ok) return { outcome: 'unavailable', error: `HTTP ${response.status}` };
const body = (await response.json()) as {
readonly owned?: boolean;
readonly spf?: boolean;
readonly dkim?: boolean;
readonly selector?: string;
};
return {
outcome: 'ok',
state: {
isOwnershipVerified: body.owned === true,
spf: { isValid: body.spf === true },
dkim: { isValid: body.dkim === true },
dkimSelectors: body.selector ? [body.selector] : [],
spfMechanisms: ['include:spf.example.net'],
},
};
}
You report observations; the host decides. There is no status field you can return. Owlat derives it from your three observations — ownership confirmed, SPF valid, DKIM valid, and at least one selector to resolve — so "verified" means the same thing at every relay tier, and a module cannot report a domain verified while telling us its DKIM record is invalid. The freshness bound is a host constant (PLUGIN_RELAY_PROOF_MAX_AGE_MS, seven days) and not a manifest field: it is the only thing that retires a proof for an identity revoked at your end while our row survives, so a declarable window would be a declarable weakening of it. Where the identity row lives, what a failed call may overwrite, and when to ask again are the host's too.
dkimSelectors and spfMechanisms are carried on the STATE rather than declared in the manifest because both provider shapes are real — one shared account-wide selector, or per-domain tokens that only exist after registration. They are what the dual-transport alignment pre-flight resolves live. The two empty lists do NOT mean the same thing, so it is worth being exact: no dkimSelectors means "we cannot describe this domain's signing identity", which is a HOLD on the ramp and never an opened gate (the domain does not reach verified and no reference arm is described for it). No spfMechanisms means "this relay needs no SPF authorization on the customer's From domain" — the pre-flight merges your mechanisms with the own MTA's into one required set, so contributing none simply drops your requirement and the SPF check can pass on a record that does not name you. Return them whenever you know them; a shared include you cannot read out of your API is better hard-coded than omitted. At most 8 of each (PLUGIN_DOMAIN_IDENTITY_MAX_DNS_FACTS), each at most 255 characters (PLUGIN_DOMAIN_IDENTITY_MAX_DNS_FACT_LENGTH), and anything over is dropped rather than refused. The error on a failed outcome or an invalid record is provider free text kept for an operator log line only, truncated at 500 characters (PLUGIN_DOMAIN_IDENTITY_MAX_ERROR_LENGTH) and never rendered as guidance.
Like the webhook half, this module is imported by code on the enqueue path and must not import Node builtins; its calls are HTTP and fetch is available. Every call is re-authorized first — flag on, send:transport still granted, configuration present — and audited as transport.domain_identity, because it spends this deployment's credential at your provider under a customer's domain name. Turning the plugin off stops it, visibly.
Agent steps
agentSteps: [{ id, after, module: { exportPath }, lifecycleEdges: [] }]
after is a core step (security_scan, context_retrieval, classify, clarify, draft) or another plugin step. Codegen rejects unknown or terminal anchors, duplicate kinds, insertion cycles, and edges outside the host's restrict-only policy.
import type {
PluginAgentStepInput,
PluginAgentStepModule,
PluginAgentStepResult,
} from '@owlat/plugin-kit';
export const agentStep: PluginAgentStepModule = {
async execute(input: PluginAgentStepInput): Promise<PluginAgentStepResult> {
if (input.subject.toLowerCase().startsWith('[auto-reply]')) {
// Restrict-only: a step may request a DECLARED caution edge, but never
// choose the next step, approve, or send.
return { kind: 'caution', to: 'archived', reason: 'vendor auto-reply' };
}
return { kind: 'continue' };
},
};
Five of the six built-in steps map to three host-owned placements; the sixth, the terminal route step, has no placement and cannot be used as an anchor:
| Placement | Anchors | Edges a descendant may request |
|---|---|---|
classification | security scan, context retrieval, classify | archived, failed from classifying |
before_draft | clarify | archived, failed from drafting (no draft is guaranteed to exist yet) |
after_draft | draft | archived, failed, and the drafting → draft_ready review edge |
A plugin chained after another plugin inherits its placement. No plugin may request approved or sent, choose the next step, run before the security scan, or edit the core legality graph. The walker always resumes the original core continuation; invalid output or an exception fails the lifecycle closed.
Draft strategies
draftStrategies: [{ id, label, module: { exportPath }, timeoutMs /* ≤ 30 000 */ }]
A strategy replaces only primary generation. Selection order is contact, then mailbox, then classification, then the built-in default. The module receives a frozen bounded projection plus the attributed, budgeted LLM service (which separately requires llm:invoke) and returns { draftBody }.
Owlat keeps assembled-context injection scanning, the quality self-check, review options, persistence, routing, autonomy, and sending outside the strategy. Denial, timeout, failure, stale selection, or malformed/oversized/injection-like output all fall back once to default. See ADR-0050.
Autonomy gates (sendGates)
sendGates: [{ id, label, module: { exportPath }, timeoutMs /* ≤ 30 000 */ }]
Plugin gates run after every immutable core route-time gate, in generated catalog order, once at the route-time approval boundary. The module receives a frozen, bounded mail projection and an AbortSignal — no host service, credential, or Convex context.
The result type is structurally incapable of approval: { outcome: 'no-objection' } or { outcome: 'objection', reason }. Disabled, revoked, stale, missing, timed-out, failed, or malformed gates all conservatively route the reply to human review. Audit records fixed operation/outcome/reason codes only. See ADR-0051.
Automations
automationTriggers: [{ id, label, description, icon, module: { exportPath } }]
automationSteps: [{ id, label, description, icon, module: { exportPath } }]
automationConditions: [{ id, label, description, icon, module: { exportPath } }]
Each registry has its own capability so a grant can enable one without the others. Editor metadata is copied verbatim into the generated catalog so the automation builder can render a contribution without importing plugin code; the frontend still treats it as untrusted text.
import type {
PluginAutomationStepInput,
PluginAutomationStepModule,
PluginAutomationStepResult,
} from '@owlat/plugin-kit';
interface NotifyConfig {
readonly channel: string;
}
export const automationStep: PluginAutomationStepModule<NotifyConfig> = {
parseConfig(raw: unknown): NotifyConfig {
const channel = (raw as { channel?: unknown } | null)?.channel;
if (typeof channel !== 'string' || channel.length === 0) {
throw new TypeError('config.channel is required');
}
return { channel };
},
async execute(
input: PluginAutomationStepInput,
config: NotifyConfig
): Promise<PluginAutomationStepResult> {
if (!input.contactEmail.includes('@')) {
return { kind: 'failed', reason: 'contact has no address' };
}
await Promise.resolve(config.channel);
return { kind: 'completed' };
},
};
The step walker owns retries, the idempotent claim, cancellation, and the circuit breaker; the hosted runner owns exactly one authorized attempt with a host-owned 30-second deadline. A plugin step may complete or fail — it can never force a run to advance. Failure reasons are clamped and control-stripped before they reach errorMessage.
Triggers only decide whether a firing starts an automation; the host fans out. Plugin trigger config rides a { pluginConfig } arm and is unwrapped before parseConfig, and buildTriggerData output is clamped to bounded primitive keys before it reaches the run row. Conditions are contracted to evaluate synchronously inside a query.
Of the three automation registries, only automationSteps is dispatched. There is no plugin-trigger firing seam and no plugin condition evaluator: conditions/index.ts throws for a plugin.* kind rather than returning "does not match", and no condition-kind validator lets one be persisted in a segment filter. The catalog and capability ceilings are reserved; executable host paths are not shipped ahead of a producer.
Webhook events
webhookEvents: [{ id, description, subscribable }]
Data only — the plugin ships no executable code for the event. Core events keep flat literals (email.sent, contact.created); plugin events are plugin.<pluginId>.<localId>. subscribable: false means customer endpoints cannot subscribe and the event is only ever delivered to a single explicit target. Payload data handed to the host at emit time is untrusted and is clamped and scrubbed before delivery.
The composed event catalog exists, but no publish or authorization seam is shipped: persisted webhook-event validators are hand-enumerated closed unions pinned to the core registry, so a plugin.* event kind cannot be stored on an endpoint subscription and is never delivered. Declaring the bucket is inert today.
Import providers
importProviders: [{ id, label, module: { exportPath }, signature, attestSource? }]
signature is required: { header, algorithm: 'hmac-sha256' | 'hmac-sha1', encoding: 'hex' | 'base64', secretEnvVar }. The host reads the secret from the env var, recomputes the HMAC over the raw body, and compares in constant time. Verification fails closed when the secret is unset or the header is missing, malformed, or mismatched — a plugin cannot opt out.
The signature contract signs the raw body alone — no timestamp, tolerance, or nonce — so passing it proves origin, not freshness. It gates no HTTP endpoint today. The piece that wires an inbound HTTP surface must layer replay defense on top before any endpoint accepts plugin-sourced traffic. Contrast the Tier-2 signed hooks, which do sign a timestamp and a nonce.
import type {
JsonObject,
PluginImportPageResult,
PluginImportProviderInput,
PluginImportProviderModule,
} from '@owlat/plugin-kit';
export const importProvider: PluginImportProviderModule = {
validateConfig(config: JsonObject) {
return typeof config['listId'] === 'string'
? ({ ok: true } as const)
: ({ ok: false, reason: 'listId is required' } as const);
},
async fetchPage(input: PluginImportProviderInput): Promise<PluginImportPageResult> {
// `cursor` is `''` on the first page; return `null` to end the walk.
const page = input.cursor === '' ? 1 : Number(input.cursor);
return {
rows: [{ email: `contact-${page}@example.com`, fields: { source: 'vendor' } }],
nextCursor: page >= 2 ? null : String(page + 1),
};
},
};
The generated import-provider module registry exists, but no start-authorization seam is shipped: the import walker dispatches through a core-only INTEGRATION_IMPORT_PROVIDERS map, and integrationImports.provider is a two-literal union that cannot hold plugin.<pluginId>.<localId>. An import run can therefore never reach a contributed provider today.
Crons
crons: [{ id, label, module: { exportPath }, schedule: { intervalMinutes }, timeoutMs }]
Scheduling limits, enforced at manifest validation, codegen and registration:
| Limit | Value |
|---|---|
schedule.intervalMinutes | 15 … 40 320 (four weeks) |
timeoutMs | 1 000 … 300 000 (five minutes) |
A plugin can add background work but never a hot loop or an effectively-never cron. The registered Convex cron name is the namespaced kind, so registrations are unique. Each execution receives { signal, logger, llm } — no Convex context, tenant id, or credential — and cancellation is cooperative through signal.
Navigation and settings entries
navItems: [{ id, section, name, href, icon, order? }]
settingsPanels: [{ id, name, href, icon, order? }]
Both are data only: a labelled link to an internal dashboard path. The label is clamped to 64 UTF-16 code units (an astral character counts as two, so the manifest validator and the render-side clamp agree on the budget) with control and bidi-format characters stripped when the entry is derived — that is spoofing defense, so a plugin cannot draw a label that visually impersonates a core one; HTML escaping in Vue is the XSS defense. The entry is gated behind the plugin flag and ordered deterministically after every core entry.
Registry dedup is by destination href, first-registered-wins, and core is always registered first — that is what prevents a plugin from shadowing a core destination. A nav item targeting an unknown or feature-off section is dropped; a plugin cannot create a new top-level section.
A plugin cannot ship a page. No arbitrary browser code is loaded at runtime, and codegen emits no Nuxt routes, so href must resolve to a route the dashboard build already has — otherwise the link renders and then 404s. The destination every plugin gets for free is its own schema-rendered settings page at /dashboard/admin/instance/plugins/<pluginId>, which is what both reference manifests link to. Anything else has to be a core route, or a route the operator's own build provides.
Settings schema
settingsSchema is not a contribution bucket — it is a top-level declarative form the host renders, validates, persists, and redacts, so a plugin needs no custom client code.
| Field kind | Extra fields |
|---|---|
string | default?, maxLength? |
secret | envVar (required, PLUGIN_-prefixed) — declaration only, see below |
number | default?, min?, max? |
boolean | default? |
select | options: [{ value, label }], default? |
All kinds carry key, label, description?, required?. Ceilings: 64 fields, 64 options per select, 8 192 characters per text value. __proto__, constructor, and prototype are rejected as keys.
A secret field stores nothing. It names a PLUGIN_-prefixed deployment environment variable, and the host reports only whether that variable is present; a write for a secret key is rejected outright. Owlat therefore holds no plugin credential plaintext at all — there is no row to leak, no envelope to rotate, and no key to compromise. Nothing else would be safe to build: no host path ever hands a plugin its settings (plugins receive host-mediated services only), so a persisted credential would be write-only storage. Use flag.requiredEnvVars when the plugin must not run at all without the variable.
Reserved names
PLUGIN_CONTRIBUTION_KINDS also contains lifecycleEffects, assistantTools, inboundAdapters, emailBlocks, commands, panels, widgets, and taskCards. Those buckets are reserved in the manifest type and accepted by the validator as opaque arrays, but no codegen or host seam consumes them yet — the corresponding core registries exist and are open to core modules only. Declaring one has no runtime effect today. Treat them as reserved names, not as extension points.
A reservation is only free while it names something real, so it can be withdrawn. channelAdapters was: the bidirectional channel-adapter interface it pointed at is gone (its two faking implementations deleted, its three working ones moved next to the single action that dispatched them), and the seams that replaced it — sendTransports for outbound and inboundAdapters for inbound — already have buckets of their own. A manifest that declares channelAdapters today is rejected with unknown_field, the same as a typo. Nothing stops the name coming back the day a channel seam does.