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:
| File | Answers | Language |
|---|---|---|
config/config.json | What types must exist, and which rule produces what? | JSON |
__init__.py | How does a model entity become the data a template needs? | Python |
templates/*.jinja2 | What 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:
scope | Runs | The builder receives |
|---|---|---|
entity (default) | once per matching entity | that entity's id |
viewpoint | once per viewpoint | the viewpoint's id |
model | once for the whole model | the 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.
- A definition needs
context_builder. Without it,TemplateConfigrefuses 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_buildername — 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__.pymust 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:
| Helper | Returns |
|---|---|
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.
create_packis mandatory. Without it,load_packraisesPackProtocolError. An empty__init__.pyis 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.
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.