Schema contract
Why GenieOS templates have types — and how the contract keeps your CI, your editor, and your inbox in sync.
The schema contract is the single feature that makes GenieOS different from every other email API on the market. Every template has a typed declaration of the variables it consumes — and the API enforces it.
The result: templates can\u2019t silently grow new variables, sends can\u2019t
ship with Hi {{first_name}} in the inbox, and your editor knows exactly
what to autocomplete.
What a contract looks like
// templates/welcome/contract.json
{
"name": "welcome",
"version": 4,
"variables": {
"first_name": { "type": "string", "required": true },
"trial_days": { "type": "integer", "default": 14, "required": false },
"plan": { "type": "string", "enum": ["hobby", "pro"], "required": true },
"show_offer": { "type": "boolean", "default": false, "required": false }
}
}The four supported types — string, integer, boolean, string[] — are
enough for ~99% of real templates and they map cleanly to TypeScript and
Pydantic. We deliberately don\u2019t support free-form objects because they
defeat the whole point: if your template needs a deeply nested struct, that
struct is something to flatten before sending, not something to embed.
Where the contract comes from
A contract is derived from the template, not authored separately. When
you save a template in the dashboard, the editor walks the MJML / HTML
looking for {{variable}} references and infers a draft contract. You then
confirm types and required-ness; the contract version increments.
Programmatic flow (CI, agents, the CLI) is the same:
genie templates push ./templates/welcome.html \
--contract ./templates/welcome.contract.jsonPushing without a contract walks the template, infers the variables,
and asks you to confirm with --accept.
Enforcement
When you call POST /v1/transactional/send, the API:
- Loads the latest published version of the template.
- Validates the
variablespayload against the contract. - Rejects the send with
422 unprocessable_entityif anything is missing, wrong-typed, or out-of-enum.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": {
"type": "validation_error",
"code": "schema_contract_violation",
"message": "Variable `plan` must be one of [\"hobby\", \"pro\"], got \"founders\".",
"fields": [
{ "path": "variables.plan", "code": "enum_violation" },
{ "path": "variables.first_name", "code": "missing" }
],
"request_id": "req_01J..."
}
}Because the failure is structured, your SDK can rethrow it as a typed exception and your forms can highlight the right field. See Errors.
Why version the contract
When you change a template — add a new variable, make an existing one required, narrow an enum — the version increments. Pending sends keep the old contract until they\u2019re flushed; new sends get the new one.
The webhook event schema_contract.changed fires on every published
version bump:
{
"id": "evt_01J...",
"type": "schema_contract.changed",
"data": {
"template": "welcome",
"from_version": 3,
"to_version": 4,
"added": ["plan"],
"removed": [],
"tightened": ["first_name"],
"loosened": [],
"diff_url": "https://app.genieos.pro/.../diff"
}
}This is the hook your CI listens on to fail builds when a template upstream of your service changed. See Webhooks for delivery + signing.
Contracts in the SDKs
Both SDKs ship a generator that turns published contracts into typed function signatures. Run it in CI; commit the output.
npx @genie-os/sdk codegen --out src/genieos.types.tsimport { GenieOS } from 'genieos';
import type { Templates } from './genieos.types';
const gos = new GenieOS();
await gos.templates.send<Templates['welcome']>({
to: 'ada@example.com',
template: 'welcome',
variables: {
first_name: 'Ada',
plan: 'pro', // \u2705 autocomplete: 'hobby' | 'pro'
// \u26a0\ufe0f tsc fails here if `plan` is missing or `trial_days` is a string.
},
});genieos codegen --out app/genieos_types.pyfrom genieos import GenieOS
from app.genieos_types import WelcomeVariables
gos = GenieOS()
gos.templates.send(
to="ada@example.com",
template="welcome",
variables=WelcomeVariables(
first_name="Ada",
plan="pro", # \u2705 mypy / pyright: Literal["hobby", "pro"]
),
)Codegen is optional
The SDKs work fine without typed variables — variables is just a
Record<string, unknown>. Codegen turns silent runtime 422s into
red squiggles in your editor; pick it up when you\u2019re ready.
Contracts at the MCP boundary
When an AI editor uses the MCP to send mail on your behalf, the same
contract enforcement applies. The MCP\u2019s send_transactional tool
receives the contract as part of its tool schema, so the agent literally
cannot propose a malformed call — Cursor / Claude / Continue all surface
the missing field before the user hits send.
This is the killer feature of mcp.genieos.pro and the reason we built
the contract in the first place: schema is the only way to make AI agents
safe email participants.
Authoring tips
- Default copy reads on its own. If a variable has a sensible fallback
("there" for a missing first name), encode it as
default. The contract validator preserves required-ness for human-authored APIs but the rendered email always has a value. - Use
enumfor plans, statuses, and language. It\u2019s the cheapest way to catch typos on the day they happen, not on the day you ship. - Avoid
booleantoggles in templates. Prefer flipping templates: awelcome.trialandwelcome.paidare easier to read in the dashboard than a singlewelcomewith three booleans.