Skip to main content

Writing a Template Pack

Pack Configuration reads two finished packs to explain the levers. This page is the other direction: the anatomy of a pack you write yourself — the three files, what each is responsible for, and the traps that fail quietly rather than loudly. The walkthrough then builds one end to end.

A template pack turns a model into files. It is not a program you run; it is a package the generation engine loads and calls. Nothing in it is specific to one model — the same pack runs over any model that has the types it asks for.

The three files​

A minimal pack is a folder with exactly three things in it:

library_cards/
config/config.json the vocabulary and the rules
__init__.py the context builders (Python)
templates/book_card.md.jinja2 the template

Each answers a different question, and they hand off in one direction:

The three files of a pack and what each answersThe configuration decides which model elements a rule applies to and names the builder and template. The builder reads the model and returns context. The template turns that context into the text of one file. Each hands off to the next in one direction.Which elements, which rule?config/config.jsonthe types it needs,the definitions, andthe names of the twofiles beside itWhat is true of this element?__init__.pycreate_pack plus onecontext builder perdefinition — the onlyplace the model is readWhat does the file look like?templates/*.jinja2formats the contextand nothing else — itnever walks the modelitselfnames the builderreturns context
FileAnswersLanguage
config/config.jsonWhat types must exist, and which rule produces what?JSON
__init__.pyHow does a model entity become the data a template needs?Python
templates/*.jinja2What does the produced file look like?Jinja (ITB delimiters)

The engine reads the config, finds every entity a definition matches, calls that definition's context builder to turn the entity into a plain data object, and renders the template with it. Model → context → file.

1 · The configuration​

config/config.json has two jobs: declare the vocabulary the pack needs in your model, and declare the definitions — the rules that turn model into files.

{
"version": 1,
"metadata": { "name": "library_cards", "version": "1.0.0", "display_name": "Library Cards" },
"requirements": {
"entity_types": [ { "name": "Book", "key": "lib_book", "match_by": "key" } ],
"attribute_types": [ { "name": "Title", "key": "lib_title", "match_by": "key" },
{ "name": "Detail", "key": "lib_detail", "match_by": "key" } ],
"entity_custom_properties": [ { "name": "Shelf", "key": "lib_shelf", "data_type": "string" } ]
},
"definitions": [
{
"name": "book_card",
"type_key": "lib_book",
"template": "book_card.md.jinja2",
"context_builder": "build_book_card_context",
"name_pattern": "{name}.card.md"
}
]
}

Requirements are a contract with the model. When the pack is installed, ITB reconciles these against your model's types — a Book type keyed lib_book, two attribute types, one custom property. match_by: "key" means the definition matches the key, not the display name, so renaming "Book" to "Title" in the UI does not break anything.

A definition is one rule. type_key is what it matches; template and context_builder are the two halves that produce the file; name_pattern names it, with {name} filled from the entity.

Three fields on a definition decide how often and over what it runs:

scopeRunsThe builder receives
entity (default)once per matching entitythat entity's id
viewpointonce per viewpointthe viewpoint's id
modelonce for the whole modelthe sentinel id __model__

A book card is entity-scoped: one card per book. A catalogue over all books would be model-scoped: one file, the builder walking the whole model.

Quiet failures in the config
  • A definition needs context_builder. Without it, TemplateConfig refuses the whole pack with missing required field(s) — so this one fails loudly. The rest do not:
  • A capability pack needs distribution; a template pack does not. (Template packs ship as plain files; only capability packs are encrypted.)
  • A dangling context_builder name — one the Python does not export — produces no file and no error you will notice. The name in the config and the function in __init__.py must match exactly.

2 · The context builder​

__init__.py is Python, and it is required — there is no "config-only" pack. It exports one factory, create_pack, and one context builder per definition.

import os
from pydantic import BaseModel
from template_engine_api import TemplatePack, TransformHelpers

CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config", "config.json")
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")


class BookCardContext(BaseModel):
title: str
details: list[dict]
shelf: str


class LibraryCardBuilders(TransformHelpers):
def build_book_card_context(self, entity_id: str) -> BookCardContext:
entity = self._find(entity_id)
title = ""
details = []
for attr in self._attrs(entity_id):
role = self.imh.type_key(attr.id) if attr.type else None
if role == "lib_title":
title = attr.name
elif role == "lib_detail":
details.append({"name": attr.name, "value_type": attr.datatype or "string"})
return BookCardContext(
title=title or entity.name,
details=details,
shelf=self._cp(entity_id, "lib_shelf", ""),
)


def create_pack(data_model, config=None) -> TemplatePack:
builders = LibraryCardBuilders(data_model, config=config)
return TemplatePack(
context_builders={"build_book_card_context": builders.build_book_card_context},
template_config=CONFIG_PATH,
template_loader_path=TEMPLATES_DIR,
)

A builder's whole job is to read the model and return a plain data object. It subclasses TransformHelpers, which gives it the model-reading helpers:

HelperReturns
self._find(entity_id)the entity
self._attrs(entity_id)its attributes
self._cp(entity_id, key, default)a custom-property value
self.imh.type_key(obj_id)the key of an entity, attribute or relation's type

type_key is the one to notice: it is how the builder asks "what role does this attribute play" and gets back lib_title or lib_detail — the key, not the display name, so it is stable under renames. An attribute with no type returns None and is skipped, not guessed at: in a generated contract, a field whose obligation nobody decided is worse than a missing one.

Two traps in the Python
  • create_pack is mandatory. Without it, load_pack raises PackProtocolError. An empty __init__.py is not enough, however plausible it looks.
  • Do not write from __future__ import annotations. It turns every annotation into a string, and Pydantic then cannot resolve the context models when the pack is imported dynamically — "…Context is not fully defined". This has cost real time; leave it out.

3 · The template​

The builder's return value arrives in the template as ctx. The template turns it into the file.

# {@ ctx.title @}

{= if ctx.details =}
| Detail | Type |
| --- | --- |
{= for d in ctx.details =}
| {@ d.name @} | {@ d.value_type @} |
{= endfor =}
{= else =}
_No details recorded yet._
{= endif =}

{= if ctx.shelf =}
Shelf: **{@ ctx.shelf @}**
{= endif =}

The delimiters are not the usual ones. An expression is {@ … @} and a block is {= … =}, not {{ … }} and {% … %}. This is deliberate: it leaves {{ … }} untouched, so a template can write dbt or Jinja code into the generated file without the engine eating it — the Data Vault packs rely on exactly that.

The trap that looks like a context bug

Standard {{ … }} delimiters do not raise. They render as literal text and the expression comes out empty, so a wrong delimiter looks like the builder failed to supply the value. If a value is mysteriously blank, check the delimiter before the builder.

Getting it into a running instance​

A template pack you author is not installed through the Marketplace dialog — that lists published packs. You upload your own with the API, once per user:

cd library_cards && zip -r ../library_cards.zip .
curl -X POST "$ITB_API/api/v1/template/packs" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@../library_cards.zip"

After that, Generate All runs it alongside every other installed pack, and Show Templates reports which of its definitions match a given entity. The walkthrough does this end to end, starting from an empty model.

Testing a pack before you ship it​

A pack is code, and the cheapest way to know it works is to render it against a stubbed model — call the builder, feed its result through a Jinja environment configured with the same delimiters the engine uses, and assert on the output. That is how the demo packs are tested, and it catches both classes of quiet failure above: a wrong delimiter leaves the value blank, and a builder that skips an untyped attribute proves it by that attribute's absence from the rendered file.