Server side
// plugins/dev/server/index.ts
import { definePluginServer } from '@desk/server/plugin-sdk';
import manifest from '../manifest';
import { DevModule } from './dev.module';
export default definePluginServer({ manifest, module: DevModule });
The module is imported into the desk like its own modules. Inject what you need from the SDK (PrismaService,
AccessScopeService, CasesService, PluginHost; for the audit log use the context's audit, below).
Routes
[!IMPORTANT] Controllers must be under
/api/v1/x/<id>/(checked at start). While the plugin is off, every route under that path answers 404 before any of your code runs.
@TokenScope('dev:read') // API tokens need this scope; sessions just need to be signed in
@Controller('api/v1/x/dev')
export class DevController {
constructor(private readonly access: DevAccess, private readonly tasks: TasksService) {}
@ApiDoc({ summary: 'A task with its linked cases, comments and history' })
@Get('tasks/:id')
async get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tasks.detail(await this.access.forUser(user), id);
}
@TokenScope('dev:write')
@Post('tasks')
async create(@CurrentUser() user: AuthUser, @Body(new Zod(taskBody)) body: TaskBody) { … }
}
@AdminOnly(), @TokenScope(…), @ApiDoc(…) and Zod work as in the desk: routes with a scope are in the OpenAPI
description at /api/docs while the plugin is on.
Access
[!CAUTION] Follow the desk's rule: every case query goes through the access scope. Tests should show that an agent can't see outside their units.
- Get the scope with
AccessScopeService.forUser(user). - Filter cases with
caseWhere(scope)(or load one withCasesService.find(scope, ref), which answers 404 outside it), and units withunitWhere(scope). - Give your own records a unit and filter them the same way, as the dev plugin does with tasks.
Extension points
Ask PluginHost for your plugin's context once, in onModuleInit.
[!NOTE] Everything registered through the context only acts while the plugin is on.
The context at a glance (each is described below, with an example):
| Method | What it does |
|---|---|
onCase | Runs on case events: created, customer_replied, changed, agent_replied, status_changed, assigned, rated. |
mcpTools | Adds MCP tools for AI assistants. |
onUnitDelete | Keeps units with your data from being deleted (also while the plugin is off). |
onStaff | Changes in who works where, after they are saved. |
addTimelineEntry | A timeline entry on a case. |
notify | An email to staff about a case. |
notifyStaff | An email to staff about something of the plugin's own. |
caseRules, caseSla, beforeResolve, caseListFilter | How the desk handles cases: rules, service levels, resolving, list filters. |
formLookups | Lookup fields on forms. |
ruleSubject, runRules | Rules on a plugin's records. |
housekeeping, evidence, controlSubject, controlBlock, recordControls | Housekeeping, evidence and controls. |
emit, onEvent, audit, access.* | Events, audit log and access. |
every, mailPublic, portalPages | Scheduled work, emails to requesters, portal pages. |
offer, use, onOffer | Plugins that build on each other. |
isOn | Whether the plugin is on now. |
Registering at start
@Module({ controllers: [DevController], providers: [DevAccess, TasksService] })
export class DevModule implements OnModuleInit {
constructor(private readonly host: PluginHost, private readonly prisma: PrismaService, …) {}
onModuleInit() {
const ctx = this.host.context('dev');
// Case events: created, customer_replied, changed, agent_replied, status_changed, assigned, rated.
ctx.onCase('status_changed', async ({ caseId, from, to }) => { … });
// MCP tools for AI assistants (see below).
ctx.mcpTools(devMcpTools(…));
// Keep units with your data from being deleted (also while the plugin is off), like the incidents plugin does.
ctx.onUnitDelete(async (unitId) => ((await this.prisma.pluginIncidentsUnit.count({ where: { unitId } })) ? 'Take it off the incidents first' : null));
// Changes in who works where, after they are saved: a person's units, disabled or enabled, a unit deleted.
ctx.onStaff('units_changed', async ({ userId, before, after, actorId }) => { … });
}
}
Timeline entries and emails
Elsewhere, through the same context:
// A timeline entry on a case: a system message the case view renders with your web renderer for `task_done`,
// or as `text` while the plugin is off. `data` is for the renderer.
await ctx.addTimelineEntry(case.id, { kind: 'task_done', text: 'DEV-3 “Export fails” is done', authorId: user.id, data: { taskId, key: 'DEV-3' } });
// An email to staff about a case, like the desk's own notifications (only people who may handle the case).
await ctx.notify(case.id, [case.assigneeId], 'DEV-3 is done', 'The dev task DEV-3 …', user.id /* not to them */, 'case' /* a kind */);
// An email to staff about something of the plugin's own, not one case (like a declared incident). The button
// links to a page of the plugin (`/app/x/<id>/…`). Who gets it is the plugin's decision: only people who may see
// what it is about. Disabled users are left out; returns how many got it.
const n = await ctx.notifyStaff(agentIds, {
subject: '[INC-4] SEV1 incident declared: Checkout down', heading: 'SEV1 incident declared', title: 'INC-4 Checkout down',
meta: 'Web shop · Commander: Sam', excerpt: 'Card payments fail at checkout', path: `/app/x/incidents/${incident.id}`, action: 'Open incident',
kind: 'declared', // one of the manifest's `notifications`: people who turned it off don't get it
}, user.id);
The timeline entry is stored as { event: 'plugin', plugin: 'dev', kind: 'task_done', text, …data } and appears in
the MCP tool get_case with the rest of the conversation.
[!IMPORTANT] Name a
kindon every email: it must be one of the manifest'snotifications(the host refuses others), and people who turned that kind off in their settings are left out.
A kind marked optIn: true is off until a person turns it on (for emails only some want, like a service owner's).
How the desk handles cases
How the desk handles cases, from a plugin (Service Configuration does all three):
// Conditions and actions in automation rules, declared in the manifest's `rules` (keys `<id>.<name>`; a `lookup`
// value searches `GET <url>?q=` and names saved values with `GET <url>?ids=a,b`, both `{ items: [{ id, label }] }`).
ctx.caseRules({
facts: async (caseIds) => new Map(/* case id → what the conditions look at, one query */),
matches: (condition, facts) => …,
validate: async ({ condition } | { action }, { orgId, unitPath }) => null /* or what's wrong */,
apply: async (caseId, action, via /* the rule's name */) => 'What happened' /* or null */,
});
// A case's service-level policy or calendar instead of its unit's; emit the case event `changed` when it may change.
ctx.caseSla(async (caseId) => ({ policyId, businessHoursId }) /* or null for the unit's */);
// Before an agent resolves or closes a case: what is still needed, or null.
ctx.beforeResolve(async (caseId) => null);
// A filter on the case lists (the inbox, its counts and chips, saved views, exports) from the plugin's data, and what
// the inbox's search finds through it. The desk keeps its access scope around it. The parameter is one the case list
// API knows (`serviceId`, Service Configuration's).
ctx.caseListFilter({
param: 'serviceId',
where: async (value, scope) => ({ id: { in: [/* the cases with it */] } }),
search: async (q, scope) => null /* or a condition on cases, for a cheap match like a service's code */,
});
While the plugin is off:
- a saved list filter of it matches no cases, so a saved view never shows more than it did;
- its saved conditions don't match, and its actions are skipped.
Lookup fields on forms
A plugin can offer a source for the field builder's lookup fields (on forms, wherever
they are used), named <plugin>.<id>. Service Configuration provides itsm.service, the Service field.
ctx.formLookups({
id: 'service', // the source is `itsm.service`
label: 'Service', // in Add field
ownAccountHint: 'Only the services their account uses; they must be signed in on the portal.',
// scope: { accountId } for a field limited to the contact's account (the default), or 'any'.
search: async (orgId, scope, q, limit) => [/* { id, label, hint? }, best first */],
check: async (orgId, scope, id) => 'Customer VPN' /* the name, or null when it may not be picked */,
chosen: async (caseId, id) => { /* a case came in with it picked, before automations run */ },
});
The field definition is { type: 'lookup', source: 'itsm.service', ownAccount: true, … }. A field limited to the
contact's account is left out where nobody is signed in, and a field whose plugin is off is always left out.
GET /api/v1/field-lookups (for the builder) lists the sources of the plugins that are on, { sources: [{ source, label, ownAccountHint }] }; the public side is in form submissions.
Rules on a plugin's records
Automations can run on a plugin's own records, as Change Management does on changes
and Problem Management on problems. Declare each kind in the manifest's rules.subjects, by name (change makes
the subject changes.change); its conditions and actions are its own, without the plugin's prefix:
rules: {
subjects: {
change: {
label: 'Changes', // the list of rules, and Runs on in New rule
noun: 'change', // in sentences ("For open changes in …")
triggers: { // any of created, changed, scheduled, in its own words
created: { title: 'A change is recorded', short: 'When a change is recorded', text: 'Right after someone records a change.' },
changed: { title: 'A change moves on', short: 'When a change moves to another status', text: 'Each time its status changes.' },
scheduled: { title: 'On a schedule', short: 'Every few minutes', text: 'Checks open changes every 5 minutes.' },
},
conditions: {
risk: { label: 'Risk', ops: [{ id: 'at_least', label: 'is at least', value: 'choice' }], choices: [{ id: 'low', label: 'Low' }, { id: 'high', label: 'High' }] },
starts: { label: 'Planned start', group: 'Time', scheduledOnly: true, suffix: 'hours', ops: [{ id: 'within', label: 'is within', value: 'number' }] },
},
actions: { note: { label: 'Add a note', value: 'text' } },
},
},
},
Condition and action definitions may use the value kind choice, with choices: [{ id, label }]; at_least
compares by their order. On the server, give the hooks and run the rules on your events:
ctx.ruleSubject('change', {
// Each record as rules see it: { id, orgId, unitPaths, key: 'CHG-12', title, href, facts }.
load: async (ids) => new Map(/* id → RuleRecord */),
open: async (orgId) => [/* ids of open ones, for scheduled rules */],
apply: async (id, action, via /* the rule's name */) => 'What happened' /* or null */,
validate: async ({ condition } | { action }, { orgId, unitPath }) => null /* optional: or what's wrong */,
});
// After it is created, or moved to another status; `anchor` names the event, so a retry doesn't act twice.
await ctx.runRules('change', 'changed', change.id, `approved:${change.version}`);
The desk matches conditions on facts by condition name, generically: is, is_not, any, none, at_least,
contains_any, gt (a number, or for scheduledOnly conditions more than so many hours since a time) and within
(a time within so many hours ahead). A rule applies to a record when the rule's unit holds one of its unitPaths
(a change is in the units of its services).
In the rules API:
| What | How |
|---|---|
A rule's subject | case, the default, or changes.change, problems.problem. |
The trigger changed | For records only. |
GET /api/v1/rules/<id> | Adds recordRuns, each with the record's key, title and href. |
POST /api/v1/rules/test with a subject | Answers records, the open ones that match now. |
Housekeeping, evidence and controls
A plugin whose records pile up, or that an auditor asks about, plugs into the desk's housekeeping, evidence and process controls, as Problem, Change and Incident Management do:
// A task on Settings → Housekeeping, once per organization (kind `<plugin>.<id>`, here `problems.closed`). It is
// created switched off, listed and run by the nightly housekeeping only while the plugin is on. Leave records under
// legal hold alone: the desk doesn't know which of yours are.
ctx.housekeeping({
id: 'closed', title: 'Closed problems', description: 'Deletes problems closed for a long time …',
setting: { key: 'days', label: 'Closed for more than', unit: 'days', min: 90, max: 3650, default: 1095 },
destructive: true, // asks before switching it on or running it
count: (orgId, cutoff) => /* how many `remove` would remove now */,
remove: async (orgId, cutoff) => /* remove them; how many went */,
});
// Its records as audit evidence (`EvidenceSource`): Settings → Evidence offers them as a population.
ctx.evidence({
id: 'problems', label: 'Problems', isOn: () => ctx.isOn(),
filters: [{ id: 'priority', label: 'Priority' }], // the population form's filters, given back in `q.filters`
// Every record created in the period that the scope can see, oldest first; `ref` is what auditors paste.
population: async (scope, { orgId, from, to, filters }) => ({ columns: ['Problem', 'Title'], rows: [{ id, ref: 'PRB-12', cells: ['PRB-12', 'Disk fills up'] }] }),
// One record's whole history as PDF blocks (`PdfBlock`), as the scope sees it: each sampled record's PDF.
record: async (scope, user, id) => ({ ref: 'PRB-12', title: 'Disk fills up', blocks: [{ h1: 'Summary' }, { p: '…' }] }),
});
// Process controls on its records (`ControlSubjectHooks`), here incidents: what a control's scope looks at, and
// whether a requirement is met now (null for a requirement the kind doesn't have).
ctx.controlSubject('incident', {
isOn: () => ctx.isOn(),
facts: async (id) => ({ orgId, ref: 'INC-4', unitPaths, priority: 'p1', categoryId, props }),
evaluate: async (id, requirement, config, resolverId) => requirement === 'review' ? { met: true, detail: 'Review written' } : null,
});
// Before resolving or closing one: the enforced control not met (refuse with it as the reason), or null.
const blocked = await ctx.controlBlock('incident', incident.id, user.id);
// After resolving or closing: record every control that applies, met or not, with its proof.
await ctx.recordControls('incident', incident.id, user.id);
The SDK has the desk's export helpers, so a plugin's lists export like the desk's own, with the same metadata, checksums and export log (exports):
| Export | What |
|---|---|
ExportsService | tabular(user, { kind, title, orgId, filters, columns, rows }, format) makes a CSV, Excel or PDF file with its metadata and records it; document(user, { kind, title, ref, blocks }) a record's PDF; record(user, …) keeps a file made elsewhere (a ZIP) in the export log; send(res, file) sends it with X-Export-Id, X-Checksum-SHA256 and X-Rows-SHA256. |
exportQuery, EXPORT_FORMATS | The query fields of an export route: format (csv, xlsx, pdf) and columns. |
pickColumns(all, ids), ExportColumn | The columns named in columns= (in that order), or all of them. |
filtersShown(query) | The list's filters as the file shows them: everything but paging, format and columns. |
renderPdf({ title, footer, blocks }), PdfBlock, markdownBlocks(md) | A PDF made by the desk itself (headings, paragraphs, lists, pairs, tables); Markdown as blocks. Latin text only: other characters print as “?”. |
renderXlsx(sheets, { title, author }), Cell | An Excel workbook from sheets of rows. |
zipFiles(files), toCsv(rows) | A ZIP of files; rows as CSV (cells that start like a formula are escaped). |
Events, audit log and access
// One of the manifest's events, to the webhooks of the organization that subscribed (and other plugins' onEvent).
// Skipped while the plugin is off; an event not in the manifest throws. Put in what a receiver needs without calling
// back (ids, keys, titles, a link), never message content of cases.
await ctx.emit('dev.task.done', task.orgId, { task: { id, key: 'DEV-3', title, column: 'Done', url }, by: { id: user.id, name: user.name } });
// Another plugin's events (or your own).
ctx.onEvent('incidents.resolved', async (e) => { … e.orgId, e.data … });
// The desk's audit log, as `dev.column.added`, with a sentence for the log (shown when the web side has no label).
await ctx.audit(user.id, 'column.added', { target: column.id, summary: 'Added the column “Blocked” to the dev board', data: { orgId } });
Webhooks get a plugin's event as { id, event_id, event: 'dev.task.done', created_at, plugin: 'dev', org_id, data },
signed like the desk's own (webhooks). The webhook editor lists the events of the plugins that are on,
under each plugin's name; a webhook keeps a plugin's events while it is off, and nothing is sent for them then.
[!TIP] Write to the audit log what an auditor would ask about: access and role changes, settings, status changes, deletions. Not every edit: the plugin's own history is for that.
Access hooks
A plugin that guards something with its own members (a project) reports who has access and why, so the access pages and the Access Management plugin can show and keep it:
ctx.access.provide({
kind: 'project', label: 'Projects',
who: async (projectId) => ({ objectName: 'Website', orgId, entries: [{ userId, name, email, role: 'owner', sources: [{ type: 'unit', unitId, unitName: 'Customer Service' }, { type: 'manual' }] }] }),
whatFor: async (userId) => [ /* the projects that person can use, with role and sources */ ],
});
// After saving a grant, a revocation or a role change:
await ctx.access.changed({ action: 'granted', kind: 'project', objectId, objectName, orgId, userId, source: { type: 'manual' }, role: 'member', actorId: user.id });
// For a plugin that keeps history (Access Management):
ctx.access.onChange(async (change) => { … change.plugin, change.action … });
Without a listener nothing is kept beyond the plugin's current state.
Scheduled work, emails to requesters, portal pages
// A job on a schedule (cron), on one instance at a time, while the plugin is on. Tests run it with
// JobsService.runNow('plugin.incidents.tick').
ctx.every('tick', '*/5 * * * *', () => this.incidents.tick());
// An email to someone outside the staff who asked for it (a status-page subscriber), in a unit's brand: logo,
// colours, sender, footer. Give them a way out.
await ctx.mailPublic({ to, unitId: portal.unitId, locale, subject, heading: 'Service update', title, body: markdown,
action: { label: 'View the status page', url }, links: [{ label: 'Unsubscribe', url: unsubscribeUrl }] });
// A page on support portals (the portal's navigation links to it): which portals have it (the Status Page plugin).
ctx.portalPages(async (portal) => ((await this.status.has(portal)) ? [{ path: 'status', label: 'Service status' }] : []));
The web side renders a portal page with portalPages: [{ path: 'status', element: StatusPage }]; it gets the portal's
slug, is public (no sign-in), and uses PublicShell, PortalNav and portalPath from the SDK. Its data comes from a
@Public() route of the plugin, which is 404 while the plugin is off.
The SDK also has the desk's business-hours
helpers (addBusinessMinutes, businessMinutesBetween, toCalendar) for targets, and RateLimitService and
clientIp for public routes.
Plugins that build on each other
A plugin can offer a service to other plugins, and use another's while that one is on. Major Incident Management uses Incident Management's incidents (with their access rules) and Software Development's tasks this way, and the Status Page plugin hooks into Incident Management's public incidents:
// plugins/incidents/server/incidents.module.ts: offer a typed object by name.
ctx.offer('incidents', incidentsApi);
// plugins/major/server: use it, null while Incident Management is off (or not in this desk). Import its type only.
import type { IncidentsApi } from '../../incidents/server/incidents-api';
const incidents = ctx.use<IncidentsApi>('incidents', 'incidents');
const dev = ctx.use<DevApi>('dev', 'tasks'); // optional: Software Development may be off
// Register hooks with it once it is offered (at start, on or off); hooks return nothing while your plugin is off.
ctx.onOffer<IncidentsApi>('incidents', 'incidents', (api) => api.tags(async (ids) => ctx.isOn() ? badges(ids) : new Map()));
A plugin that offers a service decides what others may hook into, and keeps working without them: Incident
Management asks its statusPages hook whether an organization has a status page and tells it about public incidents;
with no hook registered (the Status Page plugin isn't in this desk, or is off) it answers "no" and sends updates to the
linked cases only.
A plugin that can't work without another names it in requires.plugins; one it only uses when it's there (Software
Development, for Major Incident Management) it doesn't.
[!IMPORTANT] Services do their own access checks, as the user who asks.
What the built-in plugins offer:
| Plugin | Name | What |
|---|---|---|
incidents | incidents | IncidentsApi (plugins/incidents/server/incidents-api.ts), below. |
dev | tasks | DevApi (plugins/dev/server/dev-api.ts): the projects a user can add tasks to, creating a task as the user, and tasks' state by id. |
IncidentsApi has:
| Part | What |
|---|---|
detail, where | An incident through the access rules. |
addEntry | Timeline entries of your own, kind <plugin>.<what> with its sentence. |
canSee, candidates | Who can see it. |
startedOn(scope, orgId, serviceIds, from, to) | Incidents on services that started between two times, for Change Management's incidents during a window. |
caseIds(incidentIds) | The cases linked to incidents. Filter them through the case access scope before use. |
It also gives the last update.
And hooks for:
| Hook | What |
|---|---|
tags | Badges. |
featured | Incidents to show first where incidents are picked: their ids (Major Incident Management's majors). |
reviewTemplate | The review template. |
statusPages | Status pages: has(orgId), reach(incidentId), announce(incidentId, 'opened' | 'update' | 'resolved' | 'reopened', updateId?). |
On the web side a plugin imports another's components directly (it is compiled with it), and adds parts to its pages through slots.
Permissions
The scopes in the manifest are offered while the plugin is on: in Settings → API tokens, on the approval page for AI
assistants (OAuth), in the OAuth metadata (scopes_supported) and in the OpenAPI description. The desk's own scopes
don't change. A token keeps a plugin scope while the plugin is off, but every route behind it answers 404.
MCP tools
Tools are built with the same helper as the desk's own tools in McpToolsService. They are:
- offered while the plugin is on, and only to tokens with the tool's scope;
- run with the token owner's access scope;
- recorded, for writes, in the audit log (
mcp.tool) and as "via" on case timelines.
export function devMcpTools(access: DevAccess, tasks: TasksService): McpToolsRegistrar {
return (tool, user) => {
tool('create_dev_task', 'dev:write', {
title: 'Create a dev task',
description: 'Add a task to a project, in its first column unless one is named …',
write: true,
input: { unit_id: z.string(), title: z.string().min(1).max(300), case_ref: z.string().optional() },
}, async (a, scope) => tasks.create(scope, user, { unitId: a.unit_id, title: a.title, caseRef: a.case_ref, … }));
};
}
[!TIP] Throw the desk's errors (
badRequest,notFound): the assistant gets their message.
Everything through the API and MCP
[!IMPORTANT] Whatever a plugin's pages do must be possible through its API (every route with an
@ApiDocsummary and a scope, so it is in the OpenAPI description) and, where an assistant would use it, an MCP tool.
The test apps/server/test/plugin-parity.test.ts lists every plugin route and fails until each is either named with the MCP
tool that does the same, or marked API only with the reason (sidebar counts, pickers, deleting). Add your routes to
its COVERAGE list.
Tables
Prisma has one schema, kept as a folder (apps/server/prisma/schema/).
- Put a plugin's models in a file of its own there,
plugin-<id>.prisma, with tables namedplugin_<id>_…(@@map). - Make a migration as for the desk's own changes:
prisma migrate diff --from-config-datasource --to-schema prisma/schema --scriptintoprisma/migrations/<timestamp>_plugin_<id>/.
[!WARNING] Don't add relations to the desk's models (that would change their files); refer to units, users and cases by id, and look cases up through the access scope, which only finds cases that exist.
Tests
Server tests next to the code (plugins/<id>/server/*.test.ts) run with the desk's (cd apps/server && npx vitest run), against the test database, using the desk's test helpers (apps/server/test/app.ts, helpers.ts) and its
vitest (no need to add it to the plugin).
[!NOTE] Turn the plugin on first:
POST /api/v1/admin/plugins/<id>/onas an admin.