Template inputs

Declare a template's input schema and fixtures, bind fields in the body, format values with fmt, and choose the locale and time zone per send.

The schema is the template's contract with every caller. It is checked in three places: at compile time against every binding in the body, at publish time against every fixture, and at send time against the real payload.

Schema

Write the schema as a literal jsonSchema<Input>({ ... }) from @samva/markup/input-schema. The type argument gives the body and your editor the input type; the object is the portable JSON Schema that validates every send.

templates/appointment.tsx
import { defineTemplate, type TemplateInput } from "@samva/markup";
import { Email, Section } from "@samva/markup/email";
import { fmt } from "@samva/markup/fmt";
import { jsonSchema } from "@samva/markup/input-schema";

const template = defineTemplate({
  id: "appointment",
  locale: "en-US",
  schema: jsonSchema<{
    name: string;
    startsAt: string;
    guests: string[];
    balance: number;
    currency: string;
  }>({
    type: "object",
    properties: {
      name: { type: "string" },
      startsAt: { type: "string", description: "ISO 8601 start time" },
      guests: { type: "array", items: { type: "string" } },
      balance: { type: "number" },
      currency: { type: "string" },
    },
    required: ["name", "startsAt", "guests", "balance", "currency"],
    additionalProperties: false,
  }),
  fixtures: {
    default: {
      name: "Ada",
      startsAt: "2026-10-01T15:30:00Z",
      guests: ["Grace", "Alan"],
      balance: 1250.5,
      currency: "EUR",
    },
  },
  email: {
    subject: (input) => `Your appointment on ${fmt.date(input.startsAt, "medium")}`,
    body: (input) => (
      <Email>
        <Section>
          <p>
            Hi {input.name}, see you at {fmt.time(input.startsAt, "short")} with{" "}
            {fmt.list(input.guests)}.
          </p>
          <p>Balance due: {fmt.money(input.balance, input.currency)}</p>
        </Section>
      </Email>
    ),
  },
});

export type AppointmentInput = TemplateInput<typeof template>;
export default template;
  • The schema uses JSON Schema 2020-12 with local references. Remote references, format, and unknown keywords fail with invalid-schema.
  • The payload is validated as sent. Defaults are not inserted, values are not coerced, and extra properties are not removed. A schema default documents intent only.
  • A payload that fails validation is rejected before render, so a send never renders a blank.
  • Strings are escaped as text, never interpreted as markup.

Bindings are checked against the schema

Bindings are checked against the schema, not by running TypeScript, so the hosted editor, the CLI, and the local editor report the same findings. {input.ordr} fails with unknown-field and suggests order. Reading an optional field outside a guard fails with unguarded-optional; see the static profile.

TemplateInput<typeof template> gives your own code the input type. samva templates types writes typed inputs for every published template, so the application that sends a message is checked against the same schema.

Fixtures

Fixtures are named payloads that must validate against the schema. Each one is a preview in the editor, a render target for agents and samva templates render, and a publish check. Cover every branch of the body: a default, one without each optional field, and one with many list items.

A fixture is data. Write it as literals, or as constants and imported assets assembled from literals. A fixture that stops matching the schema fails with fixture-invalid and names the fixture and the field.

Formatters

fmt from @samva/markup/fmt is the only set of functions a body may call. Each formatter is deterministic: the locale and time zone come from the send, and times come from the input, so a render reads no clock.

FormatterExampleOutput
fmt.money(amount, currency, locale?)fmt.money(84, "EUR", "de")84,00 €
fmt.number(n, options?, locale?)fmt.number(1234.5, { maximumFractionDigits: 0 })1,235
fmt.date(iso, style?, locale?)fmt.date(input.shippedAt, "medium")Sep 29, 2026
fmt.time(iso, style?, locale?)fmt.time(input.slot, "short")3:30 PM
fmt.plural(n, forms, locale?)fmt.plural(n, { one: "item", other: "items" })items
fmt.list(values, locale?)fmt.list(["Ada", "Grace"])Ada and Grace

date and time take short, medium, long, or full. Dates and times are ISO 8601 strings. A call with an unknown formatter fails with unknown-formatter.

Locale and time zone

Locale and time zone are options of the send, not fields of the schema, and fmt reads them implicitly:

  • locale is a BCP 47 language tag such as de-DE. It defaults to the template's locale, and then to en-US.
  • timeZone is an IANA name such as Europe/Berlin. It defaults to UTC.
  • An explicit locale argument, as in fmt.money(84, "EUR", "de"), overrides the send's locale.

Pass them on the send: see Format dates and money for the recipient, or --locale and --time-zone in the CLI. Scheduled messages and campaigns take the same two fields, and samva templates render accepts both flags for a preview.

Related documentation

On this page