Customization guide
Where to change things, in the order buyers usually want to.
Branding
| What | Where |
|---|---|
| Product name & logo |
src/components/logo.tsx (one component, used
everywhere)
|
| App metadata / titles |
src/app/layout.tsx (metadata),
per-page metadata exports
|
| Design tokens (colors, radius) |
src/app/globals.css, :root and
.dark blocks; every component reads these
variables
|
| Fonts |
src/app/layout.tsx (next/font
definitions)
|
| Status color vocabulary |
src/components/status.tsx, the single source
for status/severity rendering
|
| "Powered by Vigil" footer |
src/app/status/[slug]/page.tsx, one line on the
public status page; delete it to fully white-label
|
| Landing page copy |
src/app/page.tsx (in-app) and
landing/index.html (sales page)
|
The theme system is class-based (next-themes): both
light and dark palettes live in globals.css. Change a
token once and every screen (including the public status page)
follows.
Roles & permissions
One file: src/lib/permissions.ts. Add a
resource/action to the statement, grant it to roles,
and it is enforced everywhere, server actions
(requirePermission), Better Auth endpoints and
conditional UI (hasPermission) all read this matrix.
Roles are plain strings stored on the membership row; adding a
fifth role is additive.
Domain changes
The layering rule is routes → services → tables:
-
Schema: add columns/tables in
src/db/schema/*, thennpm run db:generate && npm run db:migrate. -
Service: extend the module in
src/modules/<context>/service.ts(functions take(db, actor, input); keep org-scoping in queries). -
Action. Thin wrapper in
src/app/(app)/<area>/actions.ts: guard → zod parse → service →revalidatePath. - UI: server component fetches via the service; client components call the action and toast on error.
Worked examples to copy from: monitors (full CRUD), incidents (state machine + timeline), status pages (public read model).
Common extensions
-
Real email delivery: implement
EmailTransportinsrc/modules/notifications/index.tsand callsetEmailTransportat startup (worker + app). Everything already sends through it. -
A new check type: since 1.10.0 types live in a registry at
src/modules/monitors/types/, and adding one touches five files and no existing code path:-
a descriptor in
types/catalog.ts, label, target field, the facts the type can emit, which form sections it uses. This module is imported by the browser, so it must stay free of zod and of everynode:import; -
a spec in
types/specs/<id>.ts, a zod schema for its stored config, a target schema, its assertions,fromRowanddescribeTarget; -
a probe in
types/probes/<id>.ts. Measures, and returns facts. It must never returnokordegraded: types measure, the runner judges, and every downstream behavior depends on the verdict being recomputable from stored facts; -
an entry in
types/specs/index.ts. This is the one that is easy to miss, and missing it produces a type that appears in the form's selector and then fails validation with "Unknown check type", because the action layer resolves specs from that map and never touches the registry; -
one line in
types/registry.tsjoining the spec to the probe.
The conformance suite (
tests/unit/check-registry.test.ts) then applies every rule to your type automatically, that its assertions only read facts it declares, thatfromRowsurvives a junk config blob, that a required port is actually asked for in the form. There is no dispatch to edit, noswitchto extend, and no migration: the type's settings go in theconfigjsonb column.If it must not appear in Vigil Core, mark each of its own files with
// @edition:eeon the first line, and give the four lines it adds to the shared files (the import and the map entry, inregistry.tsandspecs/index.ts) a trailing// @edition:ee; the catalog entry goes in an// @edition:ee-start/-endblock. The strip deletes marked files outright and marked lines in place, so the source never reaches the public tree; a feature flag would leave it there. The two scripted-synthetic types are the worked example. -
a descriptor in
-
Another notification provider: 25 native provider types already ship (PagerDuty, Jira Service Management, Slack, Discord, Teams, Telegram, the push services, Twilio, SMTP, Resend, signed webhooks, Amazon SNS and the rest), plus a bridge to your own Apprise server, unlimited channels, and on-call schedules and escalation ladders on top. Adding one is a file in
src/modules/notifications/providers/and a line in theCHANNEL_PROVIDERSarray in that directory'sindex.ts: the channel editor, the docs generator and the public provider count all read that array, so a provider that is not in it does not exist anywhere and no surface can claim one that is not shipped. Setcapabilities.native: falseif what you are adding is a bridge rather than an integration, so it is not counted as one. -
Who gets told, rather than how: that is not a code change. Alert routing policies decide it from the product, and maintenance windows decide when nobody is told at all.
-
Recovery receivers: the product side is done (signed trigger, verify-before/after, bounds, immutable record); your side is the endpoint. Start from
examples/recovery-receiver.mjsand the per-platform commands + systemd/Kubernetes manifests inexamples/recovery-templates.md; anything that verifiesX-Vigil-Signatureand restarts a service qualifies. A new kind of automation is a runbook step type, not a second recovery path: add a descriptor to the registry insrc/modules/runbooks/registry.tswith its implementation undersrc/modules/runbooks/actions/, and it inherits the durable run, the approvals, the resource leases and the append-only attempt record. The per-monitor recovery action stays what it is: one endpoint, one fixed shape. -
More AI actions. Follow
src/modules/ai/incident-ai.ts: build a prompt from owned data, add a rate-limited action, keep output in an editable form.
Removing features
Each module is a folder with its routes: deleting AI is removing
src/modules/ai + the two actions + two buttons;
deleting status pages is removing the module, its two route
folders and the sidebar link. Nothing else reaches into them.