CEL functions
An expression that recurs across a module — a price with VAT, a signature check, a staleness test — is a function. Telo lets a module declare it once, as a resource, and call it from any CEL expression through a module name:
price: !cel "Self.withVat(item.net)"
signed: !cel "Crypto.hmacSha256(secrets.key, request.body)"
A function is a resource whose capability resolves to Telo.Callable. It has a
signature — params and returns — that telo check types every call against,
and a lifetime like any resource: it is created before anything that calls it,
rebuilt when it changes, and torn down with the module.
Writing a function in CEL
Telo.Function is the built-in callable whose result is a CEL expression over its
parameters:
kind: Telo.Function
metadata:
name: withVat
description: Adds VAT to a net price at the given rate.
params:
- name: net
schema: { type: number }
- name: rate
schema: { type: number, default: 0.24 }
optional: true
returns:
schema: { type: number }
body: !cel "net * (1.0 + rate)"
paramsis ordered, because a call is positional (Self.withVat(10.0)), and named, because a native function receives one object keyed by those names. Optional parameters come last. An omitted optional parameter takes its schema'sdefault, and isnullwhen it declares none — so inside the body it is nullable.nullable: trueadmitsnullas an argument, which is different from omitting one.schemais JSON Schema, and names a shape with!ref(schema: !ref Money) — never a bare string. Each parameter's schema, and the result's, is a schema of its own: a shared shape it points at withtelo://manifest#/$defs/<Name>resolves inside it, whatever else the signature declares.- A
Textparameter (schema: { $ref: "telo://manifest#/$defs/Text" }) takes a text written at the call — a string or{ 'text': …, 'context': … }— never an expression, and is checked in the calling module'slang:. A parameter whose schema carriesx-telo-text-values-for: /<text parameter>holds that text's values, checked against every placeholder it reads. A native function receives the text as its record; see Reading aText. bodysees its parameters, the CEL catalog and the functions it can call, and nothing else: novariables,resources,stepsorrequest. A function computes from its arguments.- No
deterministic:key. ATelo.Function's determinism is derived from what its body calls (below); declaring it is a schema violation.
Calling a function
A call's receiver is a module name, and it names a resource of that module:
| Written | Names |
|---|---|
Self.fn(…) or <ModuleName>.fn(…) | a function the module itself declares |
<Alias>.fn(…) | a function the imported library lists in exports.resources |
A library exports a function exactly as it exports any instance:
kind: Telo.Library
metadata:
name: Billing
exports:
resources: [withVat]
telo check refuses a call that reaches no resource (FUNCTION_UNRESOLVED), one
the library does not export (FUNCTION_NOT_EXPORTED), a resource that is not a
function (FUNCTION_NOT_CALLABLE), the wrong number of arguments
(FUNCTION_ARITY_MISMATCH) and an argument its parameter does not admit
(FUNCTION_ARGUMENT_MISMATCH). A call's result is typed as the function's
returns, so Self.quote(x).total is checked against the declared shape.
Three things a call cannot do:
- Recurse. A call is a dependency of the resource making it, so a body calling
itself — directly or through another function — is a dependency cycle
(
DEPENDENCY_CYCLE). - Run where nothing binds functions — an Application's
logging:block, read while the application loads, or aTelo.JsonSchemarulecondition, evaluated against a value alone (FUNCTION_CALL_UNBOUND). - Be read as a value. A function publishes no reading, so it is not in the
resourcesscope.
telo cel functions <manifest> lists the functions a manifest can call, with
their signatures and derived determinism.
How a call resolves
This is the rule every CEL engine hosting Telo implements, so an expression means
the same thing to telo check, the editor and every kernel.
- The name set. For each declaring module, the names a call's receiver may
be are
Self, the module's ownmetadata.name, eachimports:alias andTelo. A partial file uses the names of the module that includes it. A name in this set is never a root identifier of the expression, and is reserved: a binding, comprehension variable orcel.bindname cannot take it (BINDING_NAME_RESERVED). - Resolution at compile. A receiver-style call whose receiver is a plain identifier in the name set is a module call. When the expression is compiled the call is rewritten, on the parsed tree, into a late-bound call dispatched through a context key no manifest can spell — so a call cannot reach past the export gate or the dependency edge. The compiled value carries the list of qualified calls it makes. Spans and trace text stay as written.
- Binding per scope, at creation. When a resource is created — before any of
its expressions evaluate, including a call in a branch that never runs — every
call it makes is bound, once per module scope per qualified name. A template
body binds in the module that defined the template. Two isolated imports of a
library bind twice; a shared one binds once. A binding that cannot be made
fails the creation (
ERR_FUNCTION_UNRESOLVED,ERR_FUNCTION_NOT_EXPORTED,ERR_FUNCTION_NOT_CALLABLE); a callee not yet created defers the caller as a pending reference does. - Evaluation. Positional arguments map onto the parameter names, defaults
fill omitted optional parameters, and declared scalars are normalized. Whatever
the function throws fails the expression as
ERR_FUNCTION_FAILED, carrying the function's name and the thrown code — an ambient errortry:andcatches:catch, and a retry policy does not re-attempt.
Determinism
Whether a function returns the same result for the same arguments matters in four
places: inside a durable region declared idempotent (DURABLE_NONDETERMINISM),
in a field evaluated once at startup (CEL_NONDETERMINISTIC_IN_COMPILE_FIELD), in
a resource or referrer rule condition (RESOURCE_RULE_INVALID), and at a slot
whose callable abstract declares deterministic: true.
A Telo.Function is deterministic exactly when everything it calls is —
catalog functions and other module functions alike — and host-free on the
same terms. Every message about it names the chain to the call that decided it:
Billing.isStale → now()
An edit to a body a rule calls changes the verdict with no edit to the rule.
Native functions
When a function needs code — a cryptographic primitive, a parser, a native library — write a callable kind with a controller, and declare instances of it:
kind: Telo.Definition
metadata:
name: Hmac
description: Signs a message with HMAC under a key, using the configured digest algorithm.
capability: Telo.Callable
deterministic: true
schema:
type: object
properties:
algorithm: { type: string, enum: [sha256, sha512] }
required: [algorithm]
params:
- name: key
schema: { type: string }
- name: message
schema: { type: string }
returns:
schema: { type: string }
controllers:
- pkg:telo/local/js?path=./nodejs/crypto.mjs&local_path=./nodejs/src/index.ts#Hmac
---
kind: Self.Hmac
metadata:
name: hmacSha256
algorithm: sha256
deterministic: true is a promise, and nothing checks it. The runtime cannot
inspect a controller's code, so a native function is deterministic only where the
kind supplying its controller says so — and absent means false. Make the promise
only when the result depends on nothing but the arguments and the instance's
configuration: no clock, no randomness, no I/O. A native function is always
host-backed, so a rule condition never calls one.
Node
import { createHmac } from "node:crypto";
import type { FunctionController, ResourceManifest } from "@telorun/sdk";
interface HmacResource extends ResourceManifest {
algorithm: "sha256" | "sha512";
}
export const Hmac: FunctionController<HmacResource, { key: string; message: string }, string> = {
async create(resource) {
return {
call: ({ key, message }) => createHmac(resource.algorithm, key).update(message).digest("hex"),
};
},
};
create(resource, ctx)may be asynchronous.ctxoffers onlyresolveControllerFile,resolveNativeFile,logandeffect; register whatcreateallocates withctx.effect(…).perform()so teardown and reload release it.call(args)is synchronous — it runs inside one CEL expression. Acalltyped to return a promise fails the TypeScript build; one declaredasyncis refused when the instance is created (ERR_CONTROLLER_INVALID), and a promise returned at runtime isERR_FUNCTION_ASYNC.argsis one object keyed by parameter name, with defaults filled and declared scalars normalized; values are CEL values — abigintfor an integer, aDatefor a timestamp, aDuration, aUint8Arrayfor bytes, and aTextRecordfor aTextparameter. Arguments that do not satisfyparamsareERR_INPUT_INVALID, a result that does not satisfyreturnsisERR_OUTPUT_INVALID.
Rust
use serde::Deserialize;
use telorun_sdk::{function, Function, FunctionContext, Result, Timestamp, Value};
pub struct IsBefore;
#[derive(Deserialize)]
pub struct Instants { a: Timestamp, b: Timestamp }
#[function(entry = "is_before")]
impl Function for IsBefore {
type Config = Value;
type Args = Instants;
type Output = bool;
fn create(_config: Value, _ctx: &dyn FunctionContext) -> Result<Self> { Ok(IsBefore) }
fn call(&self, args: Instants) -> Result<bool> { Ok(args.a < args.b) }
}
controllers:
- pkg:cargo/<crate>?local_path=./rust#is_before
Arguments and results cross as typed frames, so Timestamp, Duration, Bytes
and Uint64 keep their CEL type. Drop releases what create allocated. A panic
fails the expression as ERR_FUNCTION_FAILED carrying ERR_CONTROLLER_PANIC.
Dependencies, as data
A native function's call receives no context, so a resource it holds as an
instance is of no use to it. What it can use is a resource's configuration —
a translator reading its catalogs, a formatter reading a table of units. So a
native callable kind's use: dependency slot may name any kind but a need type,
and at create() the controller receives, at that slot, the referent's
configuration as its own controller received it: compile-time expressions
evaluated, accessor fields as their bindings, each Text as its record, and
every reference inside replaced the same way, recursively — without kind and
metadata, deep-cloned and frozen, one copy per referenced resource.
kind: Telo.Definition
metadata:
name: Lookup
capability: Telo.Callable
deterministic: true
schema:
type: object
properties:
table:
x-telo-ref: { kind: Self.Table, use: dependency }
params:
- name: key
schema: { type: string }
returns:
schema: { type: string }
controllers:
- pkg:telo/local/js?path=./nodejs/lookup.mjs&local_path=./nodejs/src/index.ts#Lookup
import type { FunctionController, ResourceManifest } from "@telorun/sdk";
interface LookupResource extends ResourceManifest {
table: { readonly rows: Readonly<Record<string, string>> };
}
export const Lookup: FunctionController<LookupResource, { key: string }, string> = {
create(resource) {
const rows = resource.table.rows; // the table's configuration, not an instance
return { call: ({ key }) => rows[key] ?? "" };
},
};
The reference stays a dependency: the function is created after the table,
rebuilt when it changes, and its callers rebound with it. A slot is a data slot
only when every use it can take — every case of a use case map — is
dependency; a slot one of whose uses is call is held as before, and may name
only another function or a shape. A native function that calls a function it
holds declares that slot use: call.
A referent is data only when nothing in its configuration is still to be
produced, so CALLABLE_REFERENCE_NOT_DATA — and ERR_CALLABLE_REFERENCE_NOT_DATA
from the kernel — refuses, at the function's slot, a referent that holds an
expression evaluated after creation (a runtime-eval field, a CEL region, a step
or function body), a scope, a need (read its fields in a compile-eval field
instead), or a reference cycle. A library's resource input is judged at each
import that supplies it. A function kind whose result comes from a body never
receives anything at creation, so one inheriting a data slot naming any other
kind is refused at that slot (CALLABLE_DEFINITION_INVALID), and a deterministic
claim is not judged against what a data slot holds, since it is never called.
A native function written as an entry of a template body receives what the
template's body reaches. A sibling entry is delivered as the template created it
— an expression the template evaluates before creating the entry is data, one its
kind evaluates per call is not — and !ref <Alias>.<name> reaches an instance
the defining module imports. A resource of the defining module itself, named
bare or as Self.<name>, is not reached by an entry of the body, so it is
refused (CALLABLE_REFERENCE_NOT_DATA at the definition, the kernel's twin as
the template creates the function): declare it in the body's resources:, or
hand it in through a bare self.<field>. What a consumer hands in that way is
its own, so it is judged where the consumer writes it — at the function's slot,
or in a sibling the function holds.
Functions in slots
A kind can hold a function the way it holds any resource — through a reference slot constrained to a callable abstract that states the signature:
kind: Telo.Abstract
metadata:
name: Signer
capability: Telo.Callable
params:
- name: key
schema: { type: string }
- name: message
schema: { type: string }
returns:
schema: { type: string }
A function satisfies the slot by extends: Self.Signer, or structurally: its
result must be readable as the abstract's (covariant), the abstract's arguments
must be acceptable to it (contravariant), by position and by name, and — where
the abstract declares deterministic: true — its derived determinism must hold.
A bare Telo.Callable constraint says nothing about the signature and is refused
(X_TELO_REF_CALLABLE_UNTYPED).
The holding controller calls call(args) with one object keyed by parameter name.
Every function's call — written in CEL or natively — is bound to its own
signature when it is created: defaults are filled, arguments that do not satisfy
params are ERR_INPUT_INVALID, a result that does not satisfy returns is
ERR_OUTPUT_INVALID, and anything else the function throws is
ERR_FUNCTION_FAILED naming it as <kind>/<name>.
Why a function is shaped this way
- A function is a resource. It is a named value with an owner, configuration, a lifetime and a reload story, and resources already give it one namespace, one export gate, one reference grammar, init order and a box in the editor. A compile-eval field computed with a function is rebuilt when that function changes, because the call is a dependency edge; an edit inside an imported library still restarts the application, as every library edit does.
- Not an invocable.
invokeis asynchronous, traced and zone-tracked per dispatch, so it cannot run inside a synchronous CEL expression. - One way to write each kind of function.
Telo.Functionholds a CEL body and nothing else; native code is a callable kind plus its instances, so no document is half kind, half instance. - Resolved on the parsed tree, typed per site, bound per scope. A signature belongs to a module and an implementation to a scope: two isolated imports share one compiled expression and hold two instances. Rewriting the source text would move editor positions off what the author wrote.
- Held as a
dependency, never acall.call(args)receives no context, so no zone, cancellation or trace reaches it, and claiming it runs inside the holder's invocation would assert a containment that cannot exist. The one holder that declarescallis another native function, whose own call is the whole of its invocation — itsuse: dependencyslot hands over configuration. - A body sees only its parameters. That is what makes it typable from its
signature, rebuilt exactly when something it calls changes, and evaluable by
telo checkinside a rule condition. - Determinism is derived where it can be and claimed where it cannot. A flag written on something the analyzer can derive is a second source of truth, and a default of "pure" on code nobody can inspect would make every check reading it unsound with nothing reported. It is also a substitution dimension, so a deterministic abstract never accepts an implementation that reads the clock.
- Types are JSON Schema. Every contract walk, field check and structural
comparison already reads it, and a record is an ordinary
Telo.JsonSchemamatched structurally. A parameter's schema is not a place forx-telo-sensitive: a CEL value is never redacted, so secrets stay on contracts (SENSITIVE_ANNOTATION_MISPLACED). - One signature per name. Omission is
optional, trailing, taking the schema'sdefaultornull;nullableis for an argument that genuinely isnull. createreturns an instance with a synchronouscall. A bare exported function has no place for asynchronous setup, and process-level state would be shared across in-process kernels and two versions of one module, with nothing to release it on reload.- Native time and byte types in every language. A Rust function receives a
Timestamp, aDurationand bytes rather than text, so no function parses a value itself; every non-live value type has one plain encoding, which is what lets byte parameters cross into native code. - A call is not journaled. Every decision point of a durable run already records its value, and a call inside one expression has no step path of its own.
Declaring a runtime floor
A module whose own telo.yaml declares a Telo.Function or a
capability: Telo.Callable kind uses syntax older runtimes reject; declare the
release that carries it under requires: telo: — see
Declaring runtime requirements.