Tutorial
This is the end-to-end tour. You’ll scaffold a project, define a data schema, write templates for multiple channels, lint and test, generate type-safe SDK bindings, and ship the first real send() call.
If you just want a feel for the syntax, try it in the browser — no install needed.
Create a project
norq init --exampleThis creates a project with three example notifications — one per type:
norq.config.yaml
notifications/
system/
security-alert/ ← security alert with conditional actions
transactional/
order-confirmation/ ← order with line items, computed totals
promotional/
weekly-digest/ ← content digest with conditional pro features
Each notification contains templates for multiple channels, a data schema, sample data, and tests.
Pair with an AI assistant (optional, recommended)
Norq is designed to work alongside AI coding assistants. Before writing templates, install the plugin so your agent gets deterministic project context — schema-first workflow, template syntax, brand tokens, channel-specific compilation rules — instead of guessing.
Claude Code:
/plugin marketplace add https://github.com/suprsend/norq
/plugin install norqCodex:
codex plugin install norqThe plugin bundles two things:
- An MCP server (
norq mcp-server) that exposes 12 tools your agent can call —norq_resolve,norq_compile,norq_lint,norq_test,norq_brand_inspect,norq_render_preview, and more — so the agent can iterate on templates against the real compiler instead of speculating about output. - An agentskills.io skill that teaches the agent the schema-first workflow, all directives and pipes, and per-channel rendering rules.
Other ways to give an AI access to Norq context:
norq mcp-serverstandalone — wire into Claude Desktop, Cursor, or any MCP client. See AI Integration for config snippets.norq brand sync-agents— generateAGENTS_BRAND.mdfrom yourbrand.yaml, so agents always reference the correct tokens.https://norq.sh/llms-full.txt— paste into any chat to give the model a full Norq syntax reference.
You can skip this step and come back to it later — but most of the rest of this tutorial goes faster with an AI in the loop.
Customize your brand
norq init --example already wrote a brand.yaml at your project root with sensible defaults. This is where every visual choice lives — colors, typography, named styles, dark-mode overrides — so templates reference tokens by name and you can reskin the entire project from one file.
# brand.yaml
meta:
name: Acme
tokens:
colors:
brand: "#6d28d9"
brand-text: "#ffffff"
brand-hover: "{colors.brand | darken 10%}"
body: "#374151"
background: "#ffffff"
card: "#f3f4f6"
typography:
body: { fontFamily: Inter, fontSize: 16, lineHeight: 1.6 }
heading-1: { basedOn: body, fontSize: 36, fontWeight: 700 }
styles:
button-primary:
bg: "{colors.brand}"
color: "{colors.brand-text}"
modes:
colorMode: auto # light | dark | auto
dark:
colors: { brand: "#a78bfa", background: "#0f172a" }Templates then refer to tokens by name:
# Order confirmed{.heading-1}
{color="brand"}
[Track order](https://example.com){.button-primary}
::: callout {bg="card" color="body"}
We'll email you when it ships.
:::Useful commands:
norq brand show # print the resolved brand (aliases + pipes flattened)
norq brand import DESIGN.md # convert a DESIGN.md, DTCG, Figma, or Style Dictionary file
norq brand export --format dtcg # export to DTCG / tailwind / css
norq brand sync-agents # regenerate AGENTS_BRAND.md so AI agents stay in syncFor the full token system (spacing, radii, fonts, voice, per-template overrides, runtime tenant whitelabeling), see the Brand reference. For now, leave the defaults in place — the rest of the tutorial works regardless of brand choices.
The schema-first workflow
Norq follows a strict order: schema → samples → templates → lint. Skipping steps leads to bugs.
1. Define your data shape
Every variable used in templates must be declared here. Fields not in required are nullable — the linter enforces null guards.
# data.schema.yaml
type: object
required: [customer, items, order]
properties:
customer:
type: object
required: [name, email]
properties:
name: { type: string }
email: { type: string, format: email }
items:
type: array
items:
type: object
required: [name, qty, price]
properties:
name: { type: string }
qty: { type: integer }
price: { type: number }
order:
type: object
required: [id, total, payment_method]
properties:
id: { type: string }
total: { type: number }
payment_method: { type: string }
estimated_delivery: { type: string, format: date }
tracking_url: { type: string, format: uri }estimated_delivery and tracking_url are not required — they’re nullable. Templates must guard them with :::if or | default.
2. Create sample data
Create at least two samples: a “full” sample with all fields, and a “minimal” sample with only required fields. The minimal sample catches nullable access bugs.
# data.samples.yaml
samples:
- name: "Full order"
data:
customer: { name: "Alice", email: "alice@example.com" }
items:
- { name: "Wireless Headphones", qty: 1, price: 79.99 }
- { name: "USB-C Cable", qty: 3, price: 12.99 }
- { name: "Phone Case", qty: 1, price: 24.99 }
order:
id: "ORD-2024-1847"
total: 143.95
payment_method: "credit card"
estimated_delivery: "2024-12-01"
tracking_url: "https://track.example.com/ORD-2024-1847"
- name: "Minimal order"
data:
customer: { name: "Bob", email: "bob@example.com" }
items:
- { name: "Widget", qty: 1, price: 9.99 }
order:
id: "ORD-2024-1848"
total: 9.99
payment_method: "paypal"The “Minimal order” sample has no estimated_delivery or tracking_url. If your template crashes with this sample, you have a nullable access bug.
3. Write templates
Each channel gets its own file. Start with email (most complex), then adapt for others.
email.md — uses directives for structure, :::table for line items, pipes for formatting:
---
subject: "Order #{{order.id}} confirmed — {{items | count}} {{items | count | pluralize 'item' 'items'}}"
preheader: "Thanks for your order"
---
::: header
# Order Confirmed
:::
Hi {{customer.name}},
Thank you for your order! Here's a summary of **{{items | count}} {{items | count | pluralize "item" "items"}}**:
::: table items as item
| Product | Qty | Price | Subtotal |
|---------|-----|-------|----------|
| {{item.name}} | {{item.qty}} | {{item.price | currency "USD"}} | {{item.price | multiply item.qty | currency "USD"}} |
:::
::: fields
Order ID: #{{order.id}}
Total: {{order.total | currency "USD"}}
Payment: {{order.payment_method | capitalize}}
::: if order.estimated_delivery
Estimated Delivery: {{order.estimated_delivery | date "%B %d, %Y"}}
:::
:::
::: if order.tracking_url
::: action
[Track Your Order]({{order.tracking_url}}){primary}
:::
:::
::: footer
Questions? [Contact support](https://support.example.com) · Order #{{order.id}}
:::Key concepts in this template:
- Frontmatter (
---) sets email subject and preheader - Directives (
:::header,:::fields,:::action,:::footer) compile to channel-native elements :::tableiterates over an array and renders a table with computed subtotals- Variable pipe args —
{{item.price | multiply item.qty}}computes price × quantity - Pipes —
currency "USD",capitalize,date "%B %d, %Y",pluralize :::ifguards nullable fields (tracking_url,estimated_delivery)
sms.md — short, no directives, every character counts:
Order #{{order.id}} confirmed! Total: {{order.total | currency "USD"}}.
::: if order.tracking_url
Track it: {{order.tracking_url}}
:::slack.md — uses Slack-native Block Kit elements:
::: header
Order #{{order.id}} Confirmed
:::
Hi {{customer.name}}, your order is confirmed.
::: fields
Order ID: #{{order.id}}
Total: {{order.total | currency "USD"}}
Payment: {{order.payment_method | capitalize}}
Items: {{items | count}}
:::
::: if order.tracking_url
::: action
[Track Order]({{order.tracking_url}}){primary}
:::
:::Same data, different structure per channel. That’s the core idea.
4. Lint
norq lintThe linter checks all templates against the schema:
- Undefined variables —
{{bogus}}not in schema - Nullable access — using optional fields without
:::ifor| default - Inline directives —
:::mid-line (renders as literal text) - Directives in tables —
:::eachinside markdown tables (use:::tableinstead)
Fix all warnings. They indicate real problems.
5. Compile and preview
# Compile to JSON
norq compile transactional/order-confirmation --channel email --sample "Full order" --json
# Start a live preview server
norq dev
# Preview a specific notification
norq preview transactional/order-confirmation --channel emailThe compiled output for email includes subject, html, and text. For SMS it includes body and segments. For Slack it includes blocks (Block Kit JSON).
6. Check deliverability
norq lint also checks email-specific quality signals: image-to-text ratio, link density, spam trigger words, missing alt text, contrast, and oversized HTML. Fix issues before sending to improve inbox placement.
7. Test
Add assertions in tests.yaml:
tests:
- name: "Email subject has order ID"
channel: email
sample: "Full order"
assert:
subject: { contains: "ORD-2024-1847" }
- name: "Email subject has item count"
channel: email
sample: "Full order"
assert:
subject: { contains: "3 items" }
- name: "SMS under 3 segments"
channel: sms
sample: "Full order"
assert:
sms_segments: { lte: 3 }
- name: "Minimal order renders clean"
all_channels: true
sample: "Minimal order"
assert:
diagnostics: { errors: 0 }
- name: "All channels render without errors"
all_channels: true
all_samples: true
assert:
diagnostics: { errors: 0 }Run:
norq testThe last test is the most important — it verifies every channel renders with every sample, catching nullable access bugs.
8. Generate type-safe SDK bindings
This is where Norq’s schema-first approach pays off. Your data.schema.yaml defines the data shape — now generate typed bindings so every send() call in your app code is validated at compile time.
norq codegen --lang typescript --out ./src/generated/norqThis reads every notification’s schema and generates one file per notification:
src/generated/norq/
index.ts # NorqNotifications map + re-exports
transactional/
order_confirmation.ts # OrderConfirmationData, OrderConfirmationChannel
The generated types enforce your schema:
// Auto-generated — do not edit
export interface OrderConfirmationData {
customer: { name: string; email: string };
items: Array<{ name: string; qty: number; price: number }>;
order: {
id: string;
total: number;
payment_method: string;
estimated_delivery?: string; // optional — not in required
tracking_url?: string; // optional — not in required
};
}
export type OrderConfirmationChannel = "email" | "sms" | "slack" | "push" | "whatsapp" | "msteams";Now your IDE catches errors before you run the code:
- Misspell
customerascustmer? Compile error. - Forget
items? Compile error. - Pass
totalas a string instead of number? Compile error. - Pass a channel that doesn’t exist for this notification? Compile error.
For production, add codegen to norq.config.yaml so it runs automatically:
codegen:
- lang: typescript
out: ./src/generated/norqThen norq codegen reads the config. Use norq codegen --check in CI to verify generated files are up to date.
See Code Generation for per-language output examples (TypeScript, Python, Go, Java, Ruby).
9. Send
# Via console provider (prints to stdout — great for development)
norq send transactional/order-confirmation \
--to '{"email":"alice@example.com"}' \
--sample "Full order"10. Send from code (type-safe)
With codegen, your send() calls are fully typed:
import { Norq } from "norq.sh";
import type { OrderConfirmationData } from "./generated/norq/transactional/order_confirmation";
const norq = new Norq({ projectDir: "./my-project" });
// TypeScript validates this shape against your schema —
// misspelled fields, wrong types, and missing required fields
// are caught at compile time, not in production.
const data: OrderConfirmationData = {
customer: { name: "Alice", email: "alice@example.com" },
items: [
{ name: "Wireless Headphones", qty: 1, price: 79.99 },
{ name: "USB-C Cable", qty: 3, price: 12.99 },
],
order: {
id: "ORD-2024-1847",
total: 105.96,
payment_method: "credit card",
tracking_url: "https://track.example.com/ORD-2024-1847",
},
};
const result = await norq.send("transactional/order-confirmation", {
to: { email: "alice@example.com" },
data,
});Without codegen, send() still works — you just pass an untyped data object. But with codegen, you get IDE autocomplete for every field name and compile-time errors for shape mismatches.
Additional SDK features:
batch()— prepare a template once, then send to many recipients with per-recipient data. Auto-flushes at the provider’s max batch size.- In-process —
send()compiles templates in-process for fast, low-latency delivery. Nonorqbinary or daemon is invoked at runtime.
Sending push? Push delivery is per-token (one
SendResultper device, withtoken_indexandtoken_statusfields for reacting to dead vs transient tokens). See the push channel guide for the recipient shape and the SDK reaction patterns.
SDKs available for Node.js, Python, Go, Java, and Ruby.
What next
- Expressions and pipes — variables, 40 built-in pipes, ternary expressions
- Directives reference — all 18 directives and how they compile per channel
- Template partials — reusable fragments with parameterized includes
- Brand —
brand.yamltoken system, dark mode, per-template overrides - Whitelabeling — runtime tenant brand overrides for multi-tenant apps
- Channel guides — per-channel best practices and email styling
- Project configuration — providers, routing, codegen, strict mode
- CLI reference — all commands and flags