Authoring in TSX
Author one default-exported SML template per TSX file with typed variables, predicate builders, and clear send-time semantics.
SML (the primitives + class-subset language documented on the rest of this site) is deliberately a small, flat, closed grammar, no components, no props, no composition. That's a good property for a compiled document format: it keeps per-keystroke compile fast, keeps the editor's canvas round-trip lossless, and keeps the built-in agent's grammar small enough to hold in a system prompt. It's a worse property for hand-authoring anything with real structure, reusable sections, typed inputs, composition.
TSX is the answer to that: a typed authoring layer that compiles down to
SML, not a second runtime format. You write idiomatic TypeScript/JSX
functions; a build step evaluates them once, on your machine, and emits plain
.samva source. The platform never sees your TSX, never runs your build tool,
and never executes your code. It only ever receives and recompiles SML, the
same closed grammar this whole reference documents.
One default export per file
Each .tsx file owns at most one template, and that template is its default
export:
export default function WelcomeEmail(v: Variables<{ firstName: string }>) {
return (
<Email class="bg-stone-50">
<Preview>Welcome aboard, {v.firstName}</Preview>
<Container class="bg-white px-8 py-6">
<Heading level={1} class="text-2xl font-bold">
Welcome, {v.firstName}
</Heading>
</Container>
</Email>
);
}The file is the template identity. Named exports are ignored by template
discovery, so you can use them for helpers, constants, and ordinary composition
without accidentally creating additional templates. A TSX file without a
default export is excluded with a warning; a default export that does not return
<Email>, <Sms>, or <WhatsApp> is a build diagnostic.
The components (<Email>, <Container>, <Heading>, ...) mirror the SML
primitives one for one, same tags, same attributes, same content model. There's
no new vocabulary to learn beyond what's already on the
primitives reference; TSX just gives that vocabulary
TypeScript's type-checking, editor autocomplete, and composition via ordinary
functions and imports.
Evaluation, not parsing
This is the core mechanical difference from SML: TSX is evaluated, never
statically analyzed. The build step runs your function with a real
JavaScript engine, any TypeScript that's valid to run at build time is
accepted (loops, conditionals over build-time data, helper functions, imports
from your own codebase, npm packages). The output of running that function
(the JSX tree it returns) is what gets validated against SML's closed grammar
The same schemas and checks validate hand-written .samva source.
Because it's evaluation and not parsing, there's nothing to sandbox: your code runs on your machine, in your build, the same trust boundary as the rest of your toolchain. Nothing executes on Samva's infrastructure, the platform's only input is the emitted SML.
Variables<T> typed send-time holes
export default function WelcomeEmail(v: Variables<{firstName: string}>) declares the
template's variable schema as an ordinary TypeScript type, and the emitter
invokes your function with a recording proxy in place of real data. Reading
v.firstName doesn't return a string, it returns a reference that, when it
ends up somewhere in the JSX tree, prints as {{firstName}} in the emitted
SML. The declared type doubles as the variable schema the platform validates
sends against, so there's exactly one place variables are declared, not a
TypeScript type and a separate JSON schema that can drift apart.
The build-time-vs-send-time rule
Variables are send-time holes, real values don't exist until a specific
email is being sent to a specific recipient, which is long after your build
ran. v.firstName is a placeholder, not a string, at the moment your function
executes.
This means ordinary JavaScript coercion of a variable reference is a
build-time error, not a build-time computation: comparing it (v.plan === "pro"), concatenating it (`Hi ${v.firstName}!` outside JSX text
position), or doing arithmetic on it throws immediately when the emitter runs
your function. There is no value yet to compare, concatenate, or compute
with, only a reference to where a value will eventually go. Letting plain JS
operators silently coerce that reference to "[object Object]" or NaN
would silently emit a broken template instead of failing loudly at the one
point (your local build) where you can actually see the mistake.
The fix is the same variable reference's typed predicate builders, which construct a predicate reference instead of coercing to a real value:
// Throws at build time, plain coercion, not a builder call:
if (v.plan === "pro") { /* ... */ }
// Correct, builder methods construct a PredicateRef, never coerce:
<If test={v.plan.eq("pro").and(v.seats.gt(5))}>
<Text>Your team plan covers {v.seats} seats.</Text>
<Else>
<Text>Thanks for being a pro customer, {v.firstName}.</Text>
</Else>
</If>VariableRef grows typed comparison methods per field (eq/ne/gt/gte/
lt/lte, typed by the field's declared type), presence checks
(exists/notExists), and boolean chaining (and/or/not) that compose
PredicateRefs. The emitter prints the resulting PredicateRef as an SML
test="..." string using the exact same predicate grammar
documented for hand-written SML, comparisons over flat names and literals,
and/or/not, no arithmetic, no paths. The builders exist specifically so
that grammar is reachable through typed, autocompletable TypeScript instead of
string-templating a predicate by hand.
This is the same shape as {{}}/${} interpolation always had, variables
were never usable in real if statements or template-string logic, because
"real" values don't exist at build time. <If>/predicate builders are the
one expressiveness upgrade over plain interpolation: a typed, send-time-safe
way to branch, without ever pretending a variable is a build-time value.
The pipeline
template.tsx (default export)
│ (Vite JSX transform, evaluated at YOUR build time)
▼
SmlDocument AST (in memory)
│ print
▼
canonical .samva source
│ samva push → sends SOURCE ONLY, never compiled HTML
▼
platform: parses + compiles with the org's theme layers, server-side
▼
artifact HTML (+ plain-text alt), stored content-addressedTwo things worth being precise about:
- Local preview runs the full chain, including the SML → HTML compiler. the same pure library the platform Worker, the editor's browser canvas, and the test suite all call. A local dev server can show you real compiled output, with hot reload, entirely offline.
- Push sends SML, never HTML. The platform always recompiles server-side, against the organization's current brand-kit theme and target-client profile. This is what keeps a template re-themeable after the fact and keeps "what compiled this" a server-side, auditable fact, a local toolchain that's a version behind can't ship a stale-looking artifact, because it never ships an artifact at all.
Composition lives here, not in SML
SML's primitive set is permanently flat and closed, no <component>, no
<slot>, no user-defined macros, ever. Composition (a reusable "email
footer" block, a shared header with props, a library of on-brand sections) is
a TSX-authoring-layer concern: it's ordinary function composition, the same
way you'd share a React component. The emitter inlines the result, a
composed TSX template still emits one flat SML document, because that's the
only shape the platform (and the built-in agent, and the canvas) ever has to
understand.
Determinism
The emitter runs your template function twice and diffs the two emitted
SML strings. Anything that makes those two runs differ, Date.now(),
Math.random(), reading mutable external state, is flagged as a build-time
warning, because a non-deterministic template makes diffing pushed versions
meaningless and breaks the emission-determinism guarantee the whole pipeline
relies on.
Managed-from-code templates in the dashboard
A template pushed from TSX carries origin: tsx on its stored version. The
dashboard canvas stays fully editable, dashboard edits aren't blocked, but
the editor shows a "managed from code" banner, and samva push warns (or, in
stricter setups, refuses) if the repo's version has diverged from what's
currently published, the same posture git push --force-with-lease takes
toward a moved remote. Whichever side is the source of truth for a given
template, the repo for a code-managed team, the dashboard for a
template authored natively there, the underlying version history (every save
is a commit, with rollback) makes the divergence visible either way.
Who writes what
- The built-in template agent always writes SML directly, the closed grammar and span-precise diagnostics are exactly what makes its write → validate → retry loop converge quickly. It has no reason to go through a TSX layer.
- A customer's own agent, working in their repo, writes TSX idiomatically
, its iteration loop is
tsc, the emitter's determinism/coercion checks, and ultimately the same SML diagnostics, all through standard tooling a code-focused agent already knows how to drive.
Related
- Conditionals, the predicate grammar the typed builders emit into, and the full send-time-vs-build-time explanation from the SML side.
- Variables,
{{name}}/${name}interpolation semantics, unchanged underneath the typedVariables<T>layer. - Primitives, the tag vocabulary TSX components mirror one for one.