Norq

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 --example

This 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.

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 norq

Codex:

codex plugin install norq

The plugin bundles two things:

  1. 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.
  2. 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-server standalone — wire into Claude Desktop, Cursor, or any MCP client. See AI Integration for config snippets.
  • norq brand sync-agents — generate AGENTS_BRAND.md from your brand.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 sync

For 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
  • :::table iterates over an array and renders a table with computed subtotals
  • Variable pipe args{{item.price | multiply item.qty}} computes price × quantity
  • Pipescurrency "USD", capitalize, date "%B %d, %Y", pluralize
  • :::if guards 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 lint

The linter checks all templates against the schema:

  • Undefined variables{{bogus}} not in schema
  • Nullable access — using optional fields without :::if or | default
  • Inline directives::: mid-line (renders as literal text)
  • Directives in tables:::each inside markdown tables (use :::table instead)

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 email

The 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 test

The 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/norq

This 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 customer as custmer? Compile error.
  • Forget items? Compile error.
  • Pass total as 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/norq

Then 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-processsend() compiles templates in-process for fast, low-latency delivery. No norq binary or daemon is invoked at runtime.

Sending push? Push delivery is per-token (one SendResult per device, with token_index and token_status fields 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