Templated definitions
A Telo.Definition does not always need a controller. When your new kind is a composition of kinds that already exist, you can build it entirely in YAML: declare the internal resources it wires up, and delegate its capability method to one of them. There is no code, the kind stays fully visual in the editor, and it remains statically analyzable.
This is the templated form of a definition. It is the counterpart to the controller-backed form in Authoring a Module: same Telo.Definition document, but instead of controllers: pointing at a package, a dispatch body describes how to assemble and run the instance.
Templated vs. controller-backed
| Reach for a template when… | Reach for a controller when… |
|---|---|
| the behaviour is a composition of existing kinds (a SQL query, an HTTP call, a cache lookup) | you need a Node/Rust API the kernel doesn't expose yet |
| it's reusable and you want it type-safe at the manifest level | the logic is novel runtime behaviour, not a wiring of existing kinds |
| you want it editable and analyzable without shipping a package | you need imperative control the template engine can't express |
Templates are the preferred extension path whenever they fit — see the JS.Script-is-a-last-resort guidance in CLAUDE.md: before writing code, check whether the work is a composition that belongs in a generic templated kind.
Anatomy
A templated definition omits controllers: and instead supplies:
| Field | Role |
|---|---|
capability | still required — the lifecycle role the instance fulfils (Telo.Invocable, Telo.Provider, Telo.Runnable, Telo.Mount) |
schema | the kind's config; self is typed from it |
inputType / outputType | the invocation contract (Invocable / Runnable) |
resources | internal sub-resources instantiated per outer instance — the kinds the template composes |
invoke: / provide: / run: / mount: | the dispatch target — a !ref to the internal resource that fulfils the capability |
targets: | Service / Runnable only, instead of run: — the internal resources the instance starts, in order, when it runs |
inputs | top-level sibling — the values passed to the dispatch target |
result | top-level sibling — post-call mapping applied to the target's output |
extends | the Telo.Abstract this kind implements (optional) |
The dispatch field, inputs, and result are top-level siblings on the definition — the same { name, inputs, invoke } factoring used by Run.Sequence steps.
Invocable template
Compose a Sql.Query into a reusable Read kind. Each Read instance owns its own query resource (named from self.name so instances don't collide), delegates invoke() to it, and feeds it SQL built from the instance's config and the caller's inputs:
kind: Telo.Definition
metadata:
name: Read
capability: Telo.Invocable
schema:
type: object
required: [connection, table]
properties:
connection:
x-telo-ref: Sql.Connection
table:
type: string
inputType:
kind: Type.JsonSchema
schema:
type: object
properties:
filters: { type: object, additionalProperties: true }
required: [filters]
resources:
- kind: Sql.Query
metadata: { name: query }
connection: !cel "self.connection"
invoke: !ref query
inputs:
sql: !cel "'SELECT * FROM ' + self.table + (keys(inputs.filters).size() > 0 ? ' WHERE ' + join(keys(inputs.filters).map(k, k + ' = ?'), ' AND ') : '')"
bindings: !cel "keys(inputs.filters).map(k, inputs.filters[k])"
resourcesdeclares the internalSql.Query, under a literal name. A name composed fromself(!cel "self.name + '-query'") is refused —TEMPLATE_ENTRY_NAME_DYNAMICattelo check,ERR_TEMPLATE_ENTRY_NAME_DYNAMICat the kernel. Every instance of a template owns its children in a child context of its own, so two instances never collide on a literal name and a per-instance suffix buys nothing; what it costs is that a!refis looked up verbatim, so a CEL-named entry is one nothing can name.invokenames it with a!ref, the same spelling every other reference uses, and it is the only spelling. A!refnaming no entry isTEMPLATE_DISPATCH_UNKNOWN; anything that is not a!ref— a bare string, the removed{ kind, name }object — isINVALID_REFERENCE_FORM, the same code that form gets at every other slot in Telo.- Reference slots inside an entry follow the same rule:
!refor an inline declaration, naming a sibling entry or a resource of the declaring module (TEMPLATE_REF_UNKNOWNotherwise). - A reference the instance holds reaches an entry only through a bare
!cel "self.<path>", which forwards the value untouched — a single slot (connection: !cel "self.connection") or a whole value holding several (routes: !cel "self.routes", where each route'shandleris a reference the instance declared). Any other expression is evaluated by CEL and yields data, so a computed value at or above a reference slot isREF_SLOT_COMPUTED— andERR_REF_SLOT_COMPUTEDat boot, refused before the body is expanded, which is what reaches you when the body belongs to a published dependency your owntelo checksays nothing about: to forward routes, declare them on the kind already shaped as the router expects rather than assembling them withmap. The same rule holds outside a body, at a resource field the kind evaluates at creation. - A forwarded value is checked as the entry kind's own field. At boot the entry receives it verbatim, so
telo checkvalidates what each consumer writes there exactly as it would a resource of the entry's kind: its schema (inheritance andx-telo-schema-fromresolved), its reference slots and theirinputs:, and the CEL inside it in the entry's contexts —request.*in a route'sinputs:,result.*in itsreturns:— unless your kind marks the pathx-telo-eval: compile, which evaluates it before it is forwarded. Each finding is reported on the consumer's own line (workflows[0].returns[0].status), under its usual code. So your kind'sschema:declares only what it adds — titles, a field it requires that the entry does not — and never restates the entry's constraints orx-telo-contextblocks. The one thing it must keep is every reference slot under the forwarded value: a reference is resolved where the consumer declared it only when your kind's schema marks the slotx-telo-ref. A declaration that disagrees with the entry's field isTEMPLATE_FORWARD_INCOMPATIBLEat your kind. inputsis what gets passed to theSql.Query. Inside it, CEL sees bothselfandinputs(the caller's arguments toRead). The map is checked against the dispatch target's declaredinputTypelike any call site: a literal that does not satisfy it isTEMPLATE_TARGET_MISMATCH, and a!celwhose type does not fit the argument — a string into aninteger, aselffield declared nullable into an argument that takes no null — isCEL_TYPE_ERRORat that argument, where it would otherwise fail at dispatch withERR_INPUT_INVALID.
Provider template
A provider composes a source kind and shapes its output. provide() takes no caller arguments, so the body sees only self. The top-level result maps the target's output into the shape required by the extends-declared abstract:
kind: Telo.Definition
metadata:
name: TokenProvider
capability: Telo.Provider
extends: Auth.Credential
schema:
type: object
required: [secret]
properties:
secret: { type: string }
resources:
- kind: JavaScript.Script
metadata: { name: source }
inputType: { type: object, additionalProperties: true }
outputType: { type: object, additionalProperties: true }
code: |
function main(input) { return { raw: input.value } }
provide: !ref source
inputs:
value: !cel "self.secret"
result:
token: !cel "'bearer ' + result.raw"
providenames the dispatch target.inputsfeeds it (selfonly — noinputsvariable for providers).resultrewrites the target's output. Insideresult, CEL seesresult, typed from the dispatch target'soutputType; the produced value is validated against theoutputTypeof the abstract this kindextends.
Mount template
A Telo.Mount template declares an internal mountable surface (typically an Http.Api) plus any handlers it needs, then names the surface with mount:. modules/crud is the canonical example — Crud.Resource builds four SQL handlers and one Http.Api, all from self. Its list route answers with an envelope — one page of rows, the count of every match and the cursor of the next page — rather than a bare array:
kind: Telo.Definition
metadata:
name: Resource
capability: Telo.Mount
schema:
type: object
required: [connection, table]
properties:
connection: { x-telo-ref: Sql.Connection }
table: { type: string }
resources:
- kind: Self.Reader # an internal handler kind of the same library
metadata: { name: reader }
connection: !cel "self.connection"
table: !cel "self.table"
- kind: Http.Api
metadata: { name: api }
routes:
- request: { method: GET, path: / }
handler: !ref reader
returns:
- status: 200
content:
application/json:
body: !cel "result" # { rows, total, next }
mount: !ref api
See modules/crud/telo.yaml for the full CRUD surface.
Starting several entries — targets:
run: forwards the instance's one run() to one entry. A kind that has more
than one thing to start — a server and the poller beside it, a schema migration
before the server that reads the table — lists them in targets: instead, the
template's counterpart to an Application's boot sequence:
kind: Telo.Definition
metadata:
name: App
capability: Telo.Service
schema: { … }
resources:
- kind: DurableLocal.Resumer
metadata: { name: resumer }
workflow: !ref process
- kind: Http.Server
metadata: { name: server }
listen:
port: !cel "self.port"
mounts: [ … ]
targets:
- !ref resumer
- !ref server
When an instance runs, each target is started in order through the kernel's
run path — traced, marked started, and holding what its own run() allocated
(a listening socket, a kernel hold) for as long as the instance lives. A target
that fails stops the ones after it.
- Each item is
!ref <entry>naming aresources:entry; anything else isTEMPLATE_TARGETS_INVALID, and a name matching no entry isTEMPLATE_TARGET_UNKNOWN. - Only on a
Telo.ServiceorTelo.Runnabledefinition (TEMPLATE_TARGETS_CAPABILITY) — an invocable, provider or mount is never run, so nothing would start its targets. - Not beside
run:(TEMPLATE_TARGETS_WITH_RUN); a single entry istargets: [!ref <entry>].
Each refusal is reported by telo check at the definition and, for a
dependency's definition the consumer's check does not read, by the kernel when
the definition is registered (ERR_TEMPLATE_TARGETS_WITH_RUN, …). A module
writing targets: on a definition declares requires: telo: ">=0.96.0".
A resources: entry is a declaration of its own kind
Write each entry exactly as you would write the same resource at the top level. Its CEL is resolved through that kind's annotations — its x-telo-context regions, its step body, its error branches — so every name the kind binds is in scope where it binds it:
resources:
- kind: Run.Sequence
metadata: { name: body }
inputType:
kind: Telo.JsonSchema
schema:
type: object
properties: { q: { type: string } }
steps:
- name: rows
invoke: !ref query
inputs:
bindings:
- !cel "inputs.q" # the SEQUENCE's own arguments
- kind: Run.Projection
metadata: { name: labels }
collection: !cel "inputs.rows"
steps:
- name: label
value: !cel "item.body" # the ITERATION's element
An entry is checked as that resource would be: against its kind's schema, with every !ref — including a step's invoke: — naming a sibling entry or a resource of the declaring module, and no two entries under one name. A resource used once inside an entry can be declared inline where it is used (a route's handler: { kind: Run.Sequence, … }); it is created with the rest of the body, and its CEL is typed by its own kind.
A body is not limited to one dispatch. inputs inside a nested body types from that entry's own inputType:, never the enclosing definition's — the template reaches its body through a mapping free to rename or narrow, so typing against the outer contract would accept names the body never receives.
Which nodes survive the template's init() is decided by the nested kind too: a field it marks x-telo-eval: runtime, or one under a CEL-bearing region, is left compiled for its own controller to evaluate. Everything else is resolved once against self.
CEL scopes inside a template
| Variable | Typed from | Available in |
|---|---|---|
self | this definition's schema | everywhere — resources (at any depth), inputs, result, and CEL in schema |
inputs | the enclosing inputType at top-level inputs:; a nested entry's OWN inputType inside it | top-level inputs — Invocable / Runnable only — and wherever the nested kind binds it |
result | the dispatch target's outputType | top-level result |
| whatever the nested kind binds | that kind's own annotations | inside the entry that declares it — item / index in an iteration, request in a route, error in a catch: |
self may be mixed with a call-time name in one expression. A field the instance leaves out reads as its schema default: through self.
An expression reading nothing but self is resolved once, when the instance is created — unless it calls a function whose result differs per call (uuidv4(), nowMillis()), which is always evaluated where the nested kind evaluates the field.
The analyzer validates every expression against the scope in force at its position, so a typo like self.tabel, inputs.fitlers or item.bodyy is a telo check error rather than a runtime surprise. Where a scope is open — a name the declaring kind leaves untyped — a typo below it is not decidable and is not reported.
extends and Self
extends declares the Telo.Abstract this kind implements (e.g. Auth.Credential above). The prefix is an import alias, or Self — auto-registered to point at the declaring library's own module — when the abstract or composed kind lives in the same library. Self.<Kind> lets a template compose or implement a sibling kind without an import (a self-import would loop the loader), and resolves ungated, independent of exports.kinds. The CRUD example uses Self.Reader to compose its own internal handler kind.
See also
- Resource Definition — every
Telo.Definitionfield andx-telo-*annotation, including the no-controller execution rule. - Inheritance —
Telo.Abstract,extends, andSelf. - Authoring a Module — the controller-backed path, for behaviour a template can't express.
- Sharing Across Libraries —
resources:inputs andlifecycle: shared, for what a template's composition cannot reach. modules/crudandmodules/sql-repository— real templated modules.