Skip to main content

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 levelthe logic is novel runtime behaviour, not a wiring of existing kinds
you want it editable and analyzable without shipping a packageyou 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:

FieldRole
capabilitystill required — the lifecycle role the instance fulfils (Telo.Invocable, Telo.Provider, Telo.Runnable, Telo.Mount)
schemathe kind's config; self is typed from it
inputType / outputTypethe invocation contract (Invocable / Runnable)
resourcesinternal 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
inputstop-level sibling — the values passed to the dispatch target
resulttop-level sibling — post-call mapping applied to the target's output
extendsthe 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])"
  • resources declares the internal Sql.Query, under a literal name. A name composed from self (!cel "self.name + '-query'") is refused — TEMPLATE_ENTRY_NAME_DYNAMIC at telo check, ERR_TEMPLATE_ENTRY_NAME_DYNAMIC at 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 !ref is looked up verbatim, so a CEL-named entry is one nothing can name.
  • invoke names it with a !ref, the same spelling every other reference uses, and it is the only spelling. A !ref naming no entry is TEMPLATE_DISPATCH_UNKNOWN; anything that is not a !ref — a bare string, the removed { kind, name } object — is INVALID_REFERENCE_FORM, the same code that form gets at every other slot in Telo.
  • Reference slots inside an entry follow the same rule: !ref or an inline declaration, naming a sibling entry or a resource of the declaring module (TEMPLATE_REF_UNKNOWN otherwise).
  • 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's handler is 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 is REF_SLOT_COMPUTED — and ERR_REF_SLOT_COMPUTED at boot, refused before the body is expanded, which is what reaches you when the body belongs to a published dependency your own telo check says nothing about: to forward routes, declare them on the kind already shaped as the router expects rather than assembling them with map. 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 check validates what each consumer writes there exactly as it would a resource of the entry's kind: its schema (inheritance and x-telo-schema-from resolved), its reference slots and their inputs:, and the CEL inside it in the entry's contexts — request.* in a route's inputs:, result.* in its returns: — unless your kind marks the path x-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's schema: declares only what it adds — titles, a field it requires that the entry does not — and never restates the entry's constraints or x-telo-context blocks. 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 slot x-telo-ref. A declaration that disagrees with the entry's field is TEMPLATE_FORWARD_INCOMPATIBLE at your kind.
  • inputs is what gets passed to the Sql.Query. Inside it, CEL sees both self and inputs (the caller's arguments to Read). The map is checked against the dispatch target's declared inputType like any call site: a literal that does not satisfy it is TEMPLATE_TARGET_MISMATCH, and a !cel whose type does not fit the argument — a string into an integer, a self field declared nullable into an argument that takes no null — is CEL_TYPE_ERROR at that argument, where it would otherwise fail at dispatch with ERR_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"
  • provide names the dispatch target.
  • inputs feeds it (self only — no inputs variable for providers).
  • result rewrites the target's output. Inside result, CEL sees result, typed from the dispatch target's outputType; the produced value is validated against the outputType of the abstract this kind extends.

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 a resources: entry; anything else is TEMPLATE_TARGETS_INVALID, and a name matching no entry is TEMPLATE_TARGET_UNKNOWN.
  • Only on a Telo.Service or Telo.Runnable definition (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 is targets: [!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​

VariableTyped fromAvailable in
selfthis definition's schemaeverywhere — resources (at any depth), inputs, result, and CEL in schema
inputsthe enclosing inputType at top-level inputs:; a nested entry's OWN inputType inside ittop-level inputs — Invocable / Runnable only — and wherever the nested kind binds it
resultthe dispatch target's outputTypetop-level result
whatever the nested kind bindsthat kind's own annotationsinside 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.Definition field and x-telo-* annotation, including the no-controller execution rule.
  • Inheritance — Telo.Abstract, extends, and Self.
  • Authoring a Module — the controller-backed path, for behaviour a template can't express.
  • Sharing Across Libraries — resources: inputs and lifecycle: shared, for what a template's composition cannot reach.
  • modules/crud and modules/sql-repository — real templated modules.