Data Contracts
Every notification can define a data contract using JSON Schema. The schema powers LSP completions, nullable-access warnings, codegen, and sample data validation.
Here is a minimal example:
# notifications/welcome/data.schema.yaml
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user]
properties:
user:
type: object
required: [first_name]
properties:
first_name: { type: string }File
data.schema.yaml – placed inside the notification directory. One schema per notification, shared across all channels.
Format
Standard JSON Schema (draft 2020-12) written in YAML for readability:
$schema: "https://json-schema.org/draft/2020-12/schema"
type: object
required: [user, action_url]
properties:
user:
type: object
required: [first_name, email]
properties:
first_name: { type: string }
last_name: { type: string }
email: { type: string, format: email }
tier: { type: string, enum: [free, pro, enterprise] }
action_url: { type: string, format: uri }
unsubscribe_url: { type: string, format: uri }Required vs. optional fields
Fields listed in required are guaranteed non-null. Fields not in required are nullable – they can be absent at runtime. The linter warns if you use them without a guard (:::if or | default).
The linter uses this distinction for null safety:
# 'last_name' is not in required -- using it without a guard is a warning
warning: 'last_name' may be null — wrap in :::if guard or use | default "..."
--> email.md:8:5
|
8 | Hello {{user.last_name}}
| ^^^^^^^^^^^^^^^^^^
= rule: template/nullable-access
Fix with an :::if guard:
::: if user.last_name
Hello {{user.last_name}}
:::else
Hello {{user.first_name}}
:::Or with a | default pipe:
Hello {{user.last_name | default user.first_name}}Nullable fields
Express nullable fields using oneOf:
tracking_id:
oneOf: [{ type: string }, { type: "null" }]This tells the linter that tracking_id can be null, enabling nullable-access warnings.
Array types
items:
type: array
items:
type: object
required: [name, qty, price]
properties:
name: { type: string }
qty: { type: integer }
price: { type: number }Arrays are used with :::each loops:
::: each order.items as item
- {{item.name}} x {{item.qty}}
:::Description fields
Add description to schema fields – the LSP surfaces these as hover documentation in the editor:
properties:
tier:
type: string
enum: [free, pro, enterprise]
description: "User's subscription tier, determines feature access"What the schema enables
| Feature | Without schema | With schema |
|---|---|---|
| LSP completions | No variable suggestions | Full autocompletion with types |
| Null safety | No warnings | Warns on nullable access outside :::if |
| Codegen | Generates Record<string, any> |
Generates typed interfaces/structs |
| Sample validation | No validation | Samples validated against schema |
| Preview | Works (no validation) | Works + validation errors shown |
Why JSON Schema?
- Developers already know it (OpenAPI, VS Code settings, Adaptive Cards all use it)
- Every language has validators:
ajv(JS),jsonschema(Python),gojsonschema(Go) - Can be generated from Prisma schemas, TypeScript types, OpenAPI specs
- VS Code validates it natively with the JSON Schema meta-schema
Sample data
Sample data lives in a separate file (data.samples.yaml) and must validate against the schema:
samples:
- name: "New user"
data:
user:
first_name: "Gaurav"
email: "gaurav@example.com"
tier: "free"
action_url: "https://app.example.com/start"
unsubscribe_url: "https://app.example.com/unsub"
- name: "Pro user"
data:
user:
first_name: "Priya"
last_name: "Singh"
email: "priya@example.com"
tier: "pro"
action_url: "https://app.example.com/start"
unsubscribe_url: "https://app.example.com/unsub"Each sample has a name (used in CLI --sample flag, preview dropdown, and test references) and a data object.
See Project Configuration for provider setup, routing, codegen, and the brand: config key, and Brand for the visual identity token system.