How Telo works
Telo is a runtime, not a framework you import. You hand it a YAML manifest describing what should exist; it works out the order things must be built in, builds them, and runs what you told it to run.
This page is the mental model. Nothing here is required to write your first manifest — but everything else in the docs assumes it.
Manifests, resources, kinds
A manifest is a YAML file of one or more documents separated by ---. The
first document is always Telo.Application (a runnable entry point) or
Telo.Library (an importable unit). Every document after it declares one
resource:
kind: Http.Server # what sort of thing this is
metadata:
name: Server # what to call it
listen: # its configuration, at the top level
port: 8080
There is no spec: wrapper — a resource's fields sit directly on the document.
kind: names a resource kind, and the prefix before the dot is an import
alias you chose. Write Http: oci://ghcr.io/telorun/http-server@… in your
imports: map and the kind is Http.Server; alias it Web and the same kind
is Web.Server. A module never dictates its own prefix.
Kinds are declared, not built in
The kernel knows nothing about HTTP, SQL, or AI. Each kind is declared by a
Telo.Definition inside some module's manifest, which carries:
- a JSON Schema for the resource's fields — this is what
telo checkvalidates your YAML against, and what the editor completes from; x-telo-*annotations on that schema, saying what each field means — which kinds a reference accepts, where an expression is evaluated and what it can read — so the checker handles the kind without knowing it;- a capability, naming its lifecycle role;
- an implementation: a controller written in code, or a template that composes kinds that already exist, written in the manifest itself — see Templated definitions.
That is the whole extension mechanism. A kind you write yourself is
indistinguishable from Http.Server — see Authoring a module.
The declaration states the kind's whole contract, so the language its controller is written in is invisible to a manifest that uses the kind. The manifest says what exists and how it is wired; a controller says how one piece works inside.
So a manifest holds two levels in one language: the kinds, and the resources that are instances of them. Both are documents in the same files, checked by the same checker and shipped by the same modules. What a module can add is vocabulary — new kinds. The grammar itself is fixed: the capabilities, the annotations, the tags and the step forms are the same for every module.
Capabilities
A kind's capability determines how it participates in the lifecycle, and which slots will accept it:
| Capability | The controller implements | Typical kinds |
|---|---|---|
Telo.Service | init(), run() | Servers, connection pools — long-lived |
Telo.Runnable | run() | One-shot tasks, sequences, pipelines |
Telo.Invocable | invoke(inputs) | Handlers, scripts — things you call |
Telo.Provider | provide() | Configuration and value sources |
Telo.Mount | mounted into a service | HTTP routers, middleware |
Telo.Sink | write(record), flush() | Log sinks — record-stream destinations |
Telo.Type | nothing — schema only | Shared type declarations |
A kind may instead be declared as a Telo.Abstract: a contract with no
implementation, which exists to be extended. Sql.Connection is one — you never
instantiate it, you instantiate SQLite.Connection, which extends it. Any slot
that accepts the abstract accepts every kind that extends it, which is how one
manifest swaps SQLite for Postgres by changing an import.
Capability governs wiring, not dispatch. A step's invoke: slot accepts an
Invocable or a Runnable; an application's targets: accepts a Runnable or a
Service. A resource that must be both started and called has to be two kinds,
because no single capability satisfies both slots.
What happens when you run a manifest
telo ./manifest.yaml
- Load. The manifest and every import are parsed,
!celexpressions are compiled, andvariables:/secrets:/ports:are bound from the host environment. A missing required variable fails here — before any code runs. - Analyze. The same static checks
telo checkperforms run against the loaded graph: unknown kinds, broken references, CEL type errors. - Init. Each resource is constructed in dependency order — derived from
its references and from the imports and definitions its kind comes from, so
you never declare an ordering. A dependency only visible at runtime defers
the resource to a later pass.
init()builds the instance and performs no observable side effects. - Run targets. Everything in
targets:is dispatched. - Wait. The process stays alive while any hold is outstanding.
A long-lived resource takes a hold — Http.Server acquires one when it starts
listening and releases it on teardown. That is why an app whose target is a
server stays up, and an app whose targets all complete exits on its own. You
configure neither; both follow from what you declared.
Where side effects live
init() builds; run() acts. Http.Server is the reference implementation:
init() registers routes and plugins, run() calls listen() and takes the
hold. This split is what makes the init loop safe to retry, and what makes an
init failure diagnosable.
There is no teardown(). Both methods return the effects they performed —
each allocation paired with the inverse that undoes it — and the runtime unwinds
them last-in-first-out when the resource is torn down, or as soon as a later step
fails. So the server that could not bind its port releases the hold it took a
line earlier without any recovery code of its own.
Two ways a value flows
Configured state is pulled. After a resource initializes, the kernel reads
what its author configured and publishes it at resources.<name>.<field>, so
another resource's CEL can read it.
Observed state is pushed. What a resource learns while running — the port
a socket actually got, an endpoint a negotiation produced — is reported by the
controller when it knows it, and published under resources.<name>.status.<field>.
Because it only exists once something has run, reading it in a field that
resolves at startup is a static error. See Observed state.
Composition instead of glue
Wiring resources together is itself declarative:
!refpoints one resource at another. The slot declares which kinds it accepts, so a wrong reference is caught before anything runs.!celcomputes a value from what is in scope at that point.Run.Sequenceorders steps, with conditionals, loops, and error handling — see Composing behaviour.
Both tags are covered in !ref and !cel.
Why it is static
Because a manifest is data rather than code, the whole graph can be examined
without running it. That is what telo check and the VS Code extension do:
resolve every import, type-check every expression against the schemas the kinds
declare, and verify that each reference points at a kind the slot accepts. Most
mistakes are caught before the process starts, which is a property Telo protects
deliberately — anything that would make a manifest un-analyzable is out of
bounds.
Next
- Your first HTTP API — build one.
!refand!cel— the syntax this all rests on.- Glossary — every term on this page, defined.