Skip to main content

!ref and !cel

Two YAML tags carry everything dynamic in a Telo manifest. !ref points at another resource; !cel computes a value. Most first-week diagnostics are about one of them, so they are worth ten minutes up front.

!ref — point at a resource​

handler: !ref greet              # a resource in this module
mount: !ref api # same
output: !ref Console.writeLine # an instance exported by the import aliased `Console`

The rules are short:

  • !ref <name> resolves a resource declared in the same module scope.
  • !ref <Alias>.<name> resolves an instance an imported library exports. The part before the first dot is the import alias.
  • A resource name must be a plain identifier — ^[A-Za-z_][A-Za-z0-9_]*$, and not a CEL keyword (INVALID_NAME). A dot is what the split above reserves; a hyphen is read by CEL as subtraction. The alias is PascalCase and the resource camelCase, which is what distinguishes !ref Console.writeLine from kind: Console.WriteLine — see the style guide.
  • The tag is mandatory. A bare string (handler: Greet) or the old object form (handler: { kind: Run.Value, name: Greet }) is rejected with INVALID_REFERENCE_FORM.

The slot decides what is acceptable​

Every reference slot declares which kind it takes. Point it at the wrong sort of resource and you get REFERENCE_KIND_MISMATCH from telo check, naming what the slot expects — not a runtime failure. A kind that inherits from the expected one is accepted too, so a specialized Sql.Connection fits any slot that takes the abstract.

Per-call data lives beside the ref, never inside it​

A reference names a resource and nothing else. Anything about this particular call is a sibling key:

- name: Greet
invoke: !ref Greet # which resource
inputs: # what to pass it — a sibling, not part of the ref
name: !cel "request.query.name"

Inline instead of referenced​

Where a slot takes a reference, it usually also accepts an inline declaration — an object with a kind: and no name::

handler:
kind: Run.Value
bindings:
who: !cel "variables.audience"
value:
message: !cel "'Hello, ' + who + '!'"
greeting: !cel "who"

Use a !ref when the resource is shared or worth naming; inline when it exists only for this one slot.

bindings: is how a temporary value gets a name. who is computed once and read twice, so the expression it stands for is written once — that is the declarative answer to reaching for a script the moment a calculation needs a scratch variable. The next section covers it properly.

!cel — compute a value​

port:    !cel "ports.http"
enabled: !cel "variables.mode == 'production'"
message: !interpolate "Hello, ${{ inputs.name }}!"

A computed value of any type is written !cel. Text with values embedded in it is written !interpolate: literal text with ${{ … }} holes, each hole a CEL expression. It always produces a string, and each hole is converted exactly as CEL's string() converts it — a timestamp as RFC 3339, a duration as 5400s, bytes as UTF-8. telo check reports a hole it cannot convert (a list, a map) as INTERPOLATION_HOLE_NOT_CONVERTIBLE and one that may be null as CEL_NULLABLE_ACCESS; a hole that turns out null at run time fails with ERR_INTERPOLATION_HOLE_NOT_CONVERTIBLE. Concatenation inside !cel (!cel "'Hello, ' + inputs.name") is still valid.

A plain string holding ${{ … }} is never evaluated. Older manifests wrote it that way: it still loads — a lone "${{ x }}" is read as !cel "x" and any other as !interpolate — with a DEPRECATED_UNTAGGED_INTERPOLATION warning, and telo migrate rewrites the file. Text that is literally ${{ is written !literal.

What is in scope, and where​

CEL sees different names depending on where the expression is written. This is the single most useful table on the page:

NameAvailable inComes from
variables.<n>, secrets.<n>anywhere in the modulethe module's declared inputs
ports.<n>anywhere in the root applicationits ports: block
module.<field>anywhere in the declaring modulethat module doc's own metadata
resources.<n>.<field>anywhere, after that resource publisheswhat its author configured
resources.<n>.status.<field>only in fields that resolve after it has runwhat it observed at runtime
inputs.<n>inside a callable's bodythe values passed at the call site
steps.<n>.resultinside a Run.Sequencean earlier step's result
request.*inside an HTTP handler's inputs: / returns:query, body, params, headers, path, method
result.*inside a returns: blockwhat the handler returned
error.*inside catch: / finally: / catches:the caught failure: code, message, step, data
a bare nameinside a kind that declares bindings:a value you named yourself — see below

Everything here is typed. steps.Greet.result.mesage is a CEL_UNKNOWN_FIELD error at check time, not undefined at 3am. That holds through a named shape too: an inputType or outputType property written as !ref Money types inputs.price.amont and steps.quote.result.price.amont against Money's fields.

module.<field> is the one that saves you from restating a value you already wrote down:

serverInfo:
name: my-mcp
version: !cel "module.version" # not a second literal to keep in sync

It is closed over the fields the module doc actually declares, so module.verison is an error rather than an empty string at runtime, and it is per module: a resource inside an imported library reads that library's metadata, not the application's, because a library's version is its own.

Naming an intermediate value​

One expression that computes four things at once is unreadable, and splitting it across four resources costs four dispatches. Run.Value and Run.Choice take a bindings: map instead: each entry names a value, readable by bare name in the kind's expressions and in the other bindings.

kind: Run.Value
metadata: { name: PriceLine }
inputType:
kind: Telo.JsonSchema
schema:
type: object
required: [qty, unitPrice, discountRate, taxRate]
properties:
qty: { type: number }
unitPrice: { type: number }
discountRate: { type: number }
taxRate: { type: number }
bindings:
net: !cel "gross - discount"
gross: !cel "inputs.qty * inputs.unitPrice"
discount: !cel "gross * inputs.discountRate"
value:
net: !cel "net"
tax: !cel "net * inputs.taxRate"

net is written first and reads two bindings declared after it. That is fine: order comes from the references, not the manifest — which is why bindings: is a map (order carries no meaning) while steps: is a list (order is the point). A binding that reaches itself is a BINDING_CYCLE error.

Each binding is computed on first read, at most once per call — and never at all if nothing reads it. So a binding means exactly what pasting its expression into each use site would mean. In a decision table that is the difference between computing a shared value once and computing it per row:

kind: Run.Choice
metadata: { name: ShippingTier }
bindings:
weight: !cel "sum(inputs.items.map(i, i.grams)) / 1000.0"
choices:
- when: !cel "weight > 30.0"
value: { tier: freight }
- when: !cel "weight > 2.0"
value: { tier: parcel }
default:
value: { tier: letter }

Bindings are evaluated before any step runs, so they never see steps.*. For a value derived from a step's result, use a step that carries value: instead of invoke: — it publishes steps.<name>.result like any other step, but dispatches nothing:

steps:
- name: Cart
invoke: !ref LoadCart
- name: Total
value: !cel "sum(steps.Cart.result.lines.map(l, l.price * double(l.qty)))"
- name: Charge
invoke: !ref PaymentGateway
inputs:
amount: !cel "steps.Total.result"

A binding may not shadow a name already in scope — inputs, steps, error, variables, secrets, resources, ports. telo check says so (BINDING_NAME_RESERVED) rather than leaving you with a name that silently never resolves.

Not every field evaluates CEL​

A kind's schema decides which of its fields are evaluated. Writing an expression in a field that is not one of them gives CEL_IN_NON_EVAL_FIELD — the value would have been read as a literal string, which is almost never what you meant. When you hit it, check the kind's schema on the hub for which fields accept expressions.

The distinction also has a timing side. Some fields are evaluated once at load (a server's port:), others on every call (a route's inputs:). That is why observed state cannot be read in a startup field: at load time nothing has run yet, and telo check says so (OBSERVED_STATE_IN_STARTUP_FIELD).

Null-safety is enforced​

If a value's schema admits null, dereferencing it without a guard is a static error (CEL_NULLABLE_ACCESS). Guards are recognised in both forms:

code: !cel "error != null && error.code"
code: !cel "error == null ? 'ok' : error.code"

The common case is error inside a finally: block, which is null when the try: succeeded.

Putting both together​

- name: Charge
invoke: !ref PaymentGateway # !ref: which resource
inputs:
amount: !cel "steps.Cart.result.total" # !cel: computed from an earlier step
currency: !cel "variables.currency" # !cel: from module config
retry:
attempts: 3 # a plain literal needs no tag

Rule of thumb: !ref for identity, !cel for value, no tag for a literal.

Two more tags embed a file that ships beside the manifest — !include-text and !include-bytes. See Embedding files.

When something goes wrong​

telo check finds all of this before you run anything — see Catching errors before they run, and the diagnostics reference for any specific code. The three you will meet first:

  • CEL_UNKNOWN_FIELD — the name is not in scope at that point; check the table above.
  • CEL_IN_NON_EVAL_FIELD — the field is not evaluated; the expression would be literal text.
  • INVALID_REFERENCE_FORM — write !ref Name, not a bare string.