Examples
Runnable Telo manifests showing common patterns. Each example is a complete
application. To run an example, install @telorun/cli,
and run telo <file-url> to execute it. Most examples carry their own tests,
listed beneath the example with the same one-line command.
Start here
HelloConsole
Smallest possible runnable Telo application: imports the console module and
prints a single line via a flat targets invoke step. Useful as a "does my setup work?"
smoke test and as a starting skeleton for larger script-style manifests.
Reach for Run.Sequence when a boot entry needs control flow, scopes, or
a return value.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/hello-world/telo.yaml
Open in Telo Studio → · View hello-world/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
HelloWorldExampleTest — Runs examples/hello-world to completion and checks what it printed. The smallest example there is, and the one most likely to be read first — so what it prints is part of its contract.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/hello-world/tests/prints-a-greeting.yaml
Show prints-a-greeting.yaml
kind: Telo.Application
metadata:
name: HelloWorldExampleTest
version: 1.0.0
description: >-
Runs `examples/hello-world` to completion and checks what it printed. The
smallest example there is, and the one most likely to be read first — so what
it prints is part of its contract.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
stdout: "Hello from Telo!\n"
TodoApp
A complete app served as one unit, with no hand-written frontend: the REST
todo API (one Crud.Resource over the todos table) and an admin UI declared
in the same manifest (UiReact.App) — a filterable, sortable, paged table with
create, edit and delete — on a single port. Its first page is one line that
also says how the filter bar behaves and where each form opens; its second
is the same screen written out, with presets and a pinned filter. The UI and the API share one
model, so renaming a property is a telo check error rather than a broken page,
and a column the database fills in is read by both and written by neither.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/todo-app/telo.yaml
Open in Telo Studio → · View todo-app/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
TodoAppExampleTest — Runs examples/todo-app and walks its REST surface: create, list, replace, delete, a create the model refuses and ones naming what a create may not set. The model IS the request contract in this example, so the rejected create is what shows validation happening before the database is touched. It then asks the declared UI for its shell and its two pages, which is what shows the frontend is served from the same manifest — and that the first page's filter policy and form surfaces, and the second page's presets, pinned filter and plain openers, reach the browser as declared.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/todo-app/tests/serves-the-rest-api.yaml
Show serves-the-rest-api.yaml
kind: Telo.Application
metadata:
name: TodoAppExampleTest
version: 1.0.0
description: >-
Runs `examples/todo-app` and walks its REST surface: create, list, replace,
delete, a create the model refuses and ones naming what a create may not set. The model IS the request contract in
this example, so the rejected create is what shows validation happening before
the database is touched. It then asks the declared UI for its shell and its
two pages, which is what shows the frontend is served from the same manifest
— and that the first page's filter policy and form surfaces, and the second
page's presets, pinned filter and plain openers, reach the browser as declared.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8077
targets:
- !ref app
steps:
# The example keeps its todos in a database file that outlives the test, so the
# row this test creates is identified freshly on every run — otherwise a second
# run would pick up the first run's row when it looks its id up.
- name: subject
value:
text: !cel "'write the tests ' + uuidv4()"
- name: create
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8077/api/todos
method: POST
inputs:
body:
text: !cel "steps.subject.result.text"
- name: verifyCreated
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.create.result.status"
text: !cel "steps.create.result.body.text"
# The answer is the stored todo: not done, with the table's key and its
# timestamp.
isDone: !cel "steps.create.result.body.isDone"
keyed: !cel "steps.create.result.body.id > 0"
stamped: !cel "size(steps.create.result.body.createdAt) > 0"
expected:
status: 201
text: !cel "steps.subject.result.text"
isDone: false
keyed: true
stamped: true
# The list is a page of a collection: `rows`, the `total` the filter matches,
# and a cursor for the next page. Filtering by the text — `contains`, the one
# filter the model declares for it — finds exactly this row.
- name: list
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: http://127.0.0.1:8077/api/todos
query:
text.contains: !cel "steps.subject.result.text"
- name: verifyListed
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.list.result.status"
total: !cel "steps.list.result.body.total"
found: !cel "size(steps.list.result.body.rows)"
expected:
status: 200
total: 1
found: 1
# The listing finds the row under the id the create answered with.
- name: created
value:
id: !cel "steps.create.result.body.id"
- name: verifyListedRow
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.list.result.body.rows[0].id"
expected: !cel "steps.created.result.id"
# A create takes the title alone. `createdAt` is read, never written, and
# `isDone` is an update's to set: the create shape declares neither.
- name: backdated
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8077/api/todos
method: POST
inputs:
body:
text: !cel "steps.subject.result.text"
createdAt: "1999-01-01 00:00:00"
- name: bornDone
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8077/api/todos
method: POST
inputs:
body:
text: !cel "steps.subject.result.text"
isDone: true
- name: verifyCreateShape
invoke: { kind: Assert.Equals }
inputs:
actual:
backdated:
status: !cel "steps.backdated.result.status"
field: !cel "steps.backdated.result.body.details[0].path"
bornDone:
status: !cel "steps.bornDone.result.status"
field: !cel "steps.bornDone.result.body.details[0].path"
expected:
backdated: { status: 400, field: createdAt }
bornDone: { status: 400, field: isDone }
# `text` is required and may not be empty, so an empty one is refused before the
# database is touched — and the refusal names the field.
- name: emptyText
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8077/api/todos
method: POST
inputs:
body:
text: ""
- name: verifyRefused
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.emptyText.result.status"
field: !cel "steps.emptyText.result.body.details[0].path"
expected:
status: 400
field: text
# PUT replaces what a todo's update shape declares, so the body holds both
# of its properties.
- name: complete
invoke:
kind: HttpClient.Request
method: PUT
inputs:
url: !cel "'http://127.0.0.1:8077/api/todos/' + string(steps.created.result.id)"
body:
text: !cel "steps.subject.result.text"
isDone: true
dueOn: "2026-10-15"
# A date range is two filters on `dueOn`: on or after one day, on or before
# another. Both ends are inclusive, and a range that ends before the day
# finds nothing.
- name: dueInRange
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: http://127.0.0.1:8077/api/todos
query:
text.contains: !cel "steps.subject.result.text"
dueOn.gte: "2026-10-15"
dueOn.lte: "2026-10-15"
- name: dueEarlier
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: http://127.0.0.1:8077/api/todos
query:
text.contains: !cel "steps.subject.result.text"
dueOn.gte: "2026-10-01"
dueOn.lte: "2026-10-14"
- name: verifyDateRange
invoke: { kind: Assert.Equals }
inputs:
actual:
due: !cel "steps.complete.result.body.dueOn"
inRange: !cel "steps.dueInRange.result.body.rows.map(r, r.id)"
earlier: !cel "steps.dueEarlier.result.body.total"
expected:
due: "2026-10-15"
inRange: [!cel "steps.created.result.id"]
earlier: 0
- name: remove
invoke:
kind: HttpClient.Request
method: DELETE
inputs:
url: !cel "'http://127.0.0.1:8077/api/todos/' + string(steps.created.result.id)"
- name: gone
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: !cel "'http://127.0.0.1:8077/api/todos/' + string(steps.created.result.id)"
- name: verifyLifecycle
invoke: { kind: Assert.Equals }
inputs:
actual:
completed: !cel "steps.complete.result.status"
deleted: !cel "steps.remove.result.status"
afterDelete: !cel "steps.gone.result.status"
expected:
completed: 200
deleted: 204
afterDelete: 404
# The frontend is declared, not written: the shell at `/` and the page document
# it renders both come from the UiReact.App in the same manifest.
- name: shell
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8077/
method: GET
- name: page
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: http://127.0.0.1:8077/_telo/ui/page
query:
path: /
- name: statusPage
invoke:
kind: HttpClient.Request
method: GET
inputs:
url: http://127.0.0.1:8077/_telo/ui/page
query:
path: /status
- name: verifyUi
invoke: { kind: Assert.Equals }
inputs:
actual:
shell: !cel "steps.shell.result.status"
page: !cel "steps.page.result.status"
title: !cel "steps.page.result.body.title"
root: !cel "steps.page.result.body.children[0].type"
expected:
shell: 200
page: 200
title: { lang: en, message: [Todos] }
root: filters
# `/` is one `Crud.Ui`: what its three keys say is what the bar and the
# table's two openers carry, and what they leave out is the default.
- name: verifyGeneratedPage
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.page.result.body.children.map(c, {'bar': {'show': c.show, 'placement': c.placement, 'controls': c.controls, 'apply': c.apply, 'summary': c.summary, 'state': c.state, 'presets': c.presets}, 'create': {'surface': c.content.create.surface, 'afterSubmit': c.content.create.afterSubmit, 'unsaved': c.content.create.unsaved}, 'edit': {'surface': c.content.edit.surface, 'afterSubmit': c.content.edit.afterSubmit, 'unsaved': c.content.edit.unsaved}})"
expected:
- bar:
show: chosen
placement: { type: above }
controls: direct
apply: commit
summary: chips
state: { key: todos, address: true, store: { type: local }, fields: [text, isDone, dueOn] }
presets: []
create:
surface:
type: drawer
side: end
size: medium
modal: true
dismiss: { escape: true, outside: true, closeButton: true }
compact:
type: dialog
size: full
modal: true
dismiss: { escape: true, outside: true, closeButton: true }
afterSubmit: again
unsaved: confirm
edit:
surface:
type: drawer
side: end
size: medium
modal: true
dismiss: { escape: true, outside: true, closeButton: true }
address: { name: todo }
afterSubmit: close
unsaved: confirm
# `/status` is written by hand: a bar folding a range of due days, a pinned
# search, a filter only the presets set, and openers
# that say nothing but their form.
- name: verifyWrittenPage
invoke: { kind: Assert.Equals }
inputs:
actual:
title: !cel "steps.statusPage.result.body.title"
nodes: !cel "steps.statusPage.result.body.children.map(c, c.type)"
screen: !cel "steps.statusPage.result.body.children.filter(c, c.type == 'filters').map(c, {'bar': {'show': c.show, 'placement': c.placement, 'controls': c.controls, 'apply': c.apply, 'summary': c.summary, 'state': c.state}, 'fields': c.fields.map(f, [f.property, f.operator, f.pinned, f.control]), 'presets': c.presets, 'create': {'fields': c.content.create.form.fields.map(f, f.property), 'surface': c.content.create.surface, 'afterSubmit': c.content.create.afterSubmit, 'unsaved': c.content.create.unsaved}, 'edit': {'fields': c.content.edit.form.fields.map(f, f.property), 'surface': c.content.edit.surface, 'afterSubmit': c.content.edit.afterSubmit, 'unsaved': c.content.edit.unsaved}})"
expected:
title: { lang: en, message: [By status] }
nodes: [text, filters]
screen:
- bar:
show: all
placement: { type: collapsible, open: false }
controls: direct
apply: commit
summary: none
state: { address: false, store: { type: memory }, fields: [text, isDone, dueOn] }
fields:
- [text, contains, true, auto]
- [isDone, eq, false, none]
- [dueOn, gte, false, auto]
- [dueOn, lte, false, auto]
# A preset's value is a list on the wire, whatever was written.
presets:
- { label: { lang: en, message: [All] }, values: [] }
- { label: { lang: en, message: [Open] }, values: [{ property: isDone, operator: eq, value: [false] }] }
- { label: { lang: en, message: [Done] }, values: [{ property: isDone, operator: eq, value: [true] }] }
create:
fields: [text]
surface: &dialog
type: dialog
size: medium
modal: true
dismiss: { escape: true, outside: true, closeButton: true }
afterSubmit: close
unsaved: confirm
edit:
fields: [text, isDone, dueOn]
surface: *dialog
afterSubmit: close
unsaved: confirm
MoneyTransfer
Moving money between two accounts: the debit and the credit are one all-or-nothing unit, and the balance check happens inside it.
The interesting part is not that Sql.Transaction exists — it is that a
statement declaring transaction: becomes unreachable except through that
transaction's body. The requirement is a schema annotation
(x-telo-requires-zone), so telo check refuses a manifest that calls the
debit from anywhere else: a write that escapes the transaction is a static
error, not a 3am incident. broken.yaml in this directory is that mistake,
kept so you can watch the checker reject it.
Run, then:
curl -s localhost:8055/accounts
curl -s -XPOST localhost:8055/transfers -H 'content-type: application/json' \
-d '{"from":1,"to":2,"amountCents":2500}'
curl -s -XPOST localhost:8055/transfers -H 'content-type: application/json' \
-d '{"from":1,"to":2,"amountCents":999999}' # 409, and nothing moved
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/money-transfer/telo.yaml
Open in Telo Studio → · View money-transfer/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
MoneyTransferExampleTest — Runs examples/money-transfer and walks the sequence its README documents: a transfer that fits, and one that does not. The decline is the assertion that matters — it is what shows the balance check ran inside the transaction, so a refused transfer wrote nothing.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/money-transfer/tests/transfers-and-declines.yaml
Show transfers-and-declines.yaml
kind: Telo.Application
metadata:
name: MoneyTransferExampleTest
version: 1.0.0
description: >-
Runs `examples/money-transfer` and walks the sequence its README documents: a
transfer that fits, and one that does not. The decline is the assertion that
matters — it is what shows the balance check ran inside the transaction, so a
refused transfer wrote nothing.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8055
targets:
- !ref app
steps:
- name: transfer
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8055/transfers
method: POST
inputs:
body: { from: 1, to: 2, amountCents: 2500 }
- name: verifyTransferred
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.transfer.result.status"
body: !cel "steps.transfer.result.body"
expected:
status: 200
body:
ok: true
movedCents: 2500
- name: overdraw
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8055/transfers
method: POST
inputs:
body: { from: 1, to: 2, amountCents: 999999 }
- name: verifyDeclined
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.overdraw.result.status"
body: !cel "steps.overdraw.result.body"
expected:
status: 409
body:
ok: false
error: insufficient_funds
# The ledger is in-memory, so this is the balance after the two transfers
# above and nothing else — a listing that starts reporting something different
# means one of them wrote when it should not have.
- name: accounts
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8055/accounts
method: GET
- name: verifyAccounts
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.accounts.result.status"
expected: 200
SupportInboxMcp
A support-ticket service with two front doors onto the same handlers: a REST API for your app, and an MCP endpoint so an assistant (Claude Desktop, Cursor, any MCP client) can search and close tickets itself.
The two surfaces share the resources, not just the database — searchTickets
and closeTicket are each declared once and referenced by both the HTTP route
and the MCP tool entry. Exposing an existing app to a model is a mount and a
tool bundle, not a second service to keep in sync.
Run, then:
curl -s 'localhost:8066/api/tickets?q=login'
# point an MCP client at http://localhost:8066/mcp, or:
curl -s -XPOST localhost:8066/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/support-inbox-mcp/telo.yaml
Open in Telo Studio → · View support-inbox-mcp/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
SupportInboxExampleTest — Runs examples/support-inbox-mcp and reaches the same tickets through both of its front doors — the HTTP route a browser uses and the MCP tool an assistant calls. Asserting both is the point of the example: the handlers know nothing about MCP, so a change that serves one door and breaks the other is exactly what this catches.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/support-inbox-mcp/tests/serves-tickets-over-http-and-mcp.yaml
Show serves-tickets-over-http-and-mcp.yaml
kind: Telo.Application
metadata:
name: SupportInboxExampleTest
version: 1.0.0
description: >-
Runs `examples/support-inbox-mcp` and reaches the same tickets through both of
its front doors — the HTTP route a browser uses and the MCP tool an assistant
calls. Asserting both is the point of the example: the handlers know nothing
about MCP, so a change that serves one door and breaks the other is exactly
what this catches.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8066
targets:
- !ref app
steps:
- name: search
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8066/api/tickets?q=login
method: GET
- name: verifySearch
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.search.result.status"
expected: 200
- name: verifyMatchedSomething
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "size(steps.search.result.body) > 0"
expected: true
- name: tools
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8066/mcp
method: POST
headers:
accept: application/json, text/event-stream
inputs:
body:
jsonrpc: "2.0"
id: 1
method: tools/list
- name: verifyToolsListed
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.tools.result.status"
expected: 200
AI
AgentConsole
Interactive REPL backed by an Ai.Agent with tools. Each turn, the agent may call tools before answering:
- a regular HTTP tool (
get_weather) that fetches current weather from the open-meteo API via Http.Request, and - every tool exposed by the Telo hub's MCP server at
https://telo.sh/mcp, discovered at runtime via AiMcp.ToolProvider.
The conversation is persisted in sqlite so each turn sees prior history and the
chat survives restart. Type
/exit(or send EOF) to leave.
Run with the OpenAI key in a sibling .env.local file:
OPENAI_API_KEY=sk-...
OPENAI_API_KEY='sk-your-openai-key' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/agent-console/telo.yaml
Open in Telo Studio → · View agent-console/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
AgentConsoleExampleTest — Holds two turns with examples/agent-console: one the model answers from nothing, and one it can only answer by calling the weather tool the example gives it. The second turn is what the example is FOR — an agent loop that reaches a tool and comes back — and a REPL can only be driven a turn at a time, since each prompt exists because the previous answer arrived.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/agent-console/tests/answers-and-uses-a-tool.yaml
Show answers-and-uses-a-tool.yaml
kind: Telo.Application
metadata:
name: AgentConsoleExampleTest
version: 1.0.0
description: >-
Holds two turns with `examples/agent-console`: one the model answers from
nothing, and one it can only answer by calling the weather tool the example
gives it. The second turn is what the example is FOR — an agent loop that
reaches a tool and comes back — and a REPL can only be driven a turn at a time,
since each prompt exists because the previous answer arrived.
imports:
App: ../../../modules/app
Channel: ../../../modules/channel
Run: ../../../modules/run
Assert: ../../../modules/assert
variables:
# The DIRECTORY each run's history file goes in, anchored at the working
# directory: a value supplied to the app by name must already be an absolute
# host path. Named after the key it feeds because `telo check` reads
# `variables` inside an App.Instance's own `variables:` map as that map, so a
# differently named variable is reported missing there.
historyFile:
env: AGENT_CONSOLE_TEST_DIR
type: string
x-telo-type: Telo.HostPath
default: ./.telo
targets:
- !ref app
- !ref test
---
kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
# It blocks on its first prompt, so its targets never all finish dispatching.
awaitStart: false
variables:
# A conversation the example persists outlives the test, and a run that resumes
# one left half-finished behaves differently from a fresh one — so each run gets
# its own history.
historyFile: !cel "variables.historyFile.joinPath('agent-console-test-' + uuidv4() + '.sqlite')"
---
kind: Channel.ReadUntil
metadata: { name: read }
channel: !ref app
timeout: "120s"
---
kind: Channel.SendLine
metadata: { name: send }
channel: !ref app
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: firstPrompt
invoke: !ref read
inputs: { until: "you › " }
- name: ask
invoke: !ref send
inputs: { text: "Reply with exactly the word BANANA and nothing else." }
- name: reply
invoke: !ref read
inputs: { until: "you › " }
- name: verifyAnswered
invoke: { kind: Assert.Contains }
inputs:
actual: !cel "steps.reply.result.text"
value: BANANA
# The example prints a `🔧` line naming every tool a turn called, so asking for
# something only the tool knows is checkable without trusting the prose: what is
# asserted is that the agent REACHED the tool, not what the weather was.
- name: askWeather
invoke: !ref send
inputs:
text: >-
Call get_weather for latitude 48.85, longitude 2.35 and tell me the
temperature. Do not use any other tool.
- name: weatherReply
invoke: !ref read
inputs: { until: "you › " }
- name: verifyToolUsed
invoke: { kind: Assert.Contains }
inputs:
actual: !cel "steps.weatherReply.result.text"
value: "🔧"
- name: exit
invoke: !ref send
inputs: { text: "/exit" }
AiImageConsole
Prompts for a phrase on the console, generates a picture for it with OpenAI, and saves the bytes to ./image.png next to wherever you ran it.
Ai.Image hands back raw bytes on images[].data, so they go straight into
Fs.FileWrite — no base64 hop and no JS.Script in between. A refusal is a
result, not an error: the model returns finishReason: content-filter with
an empty images array, so the save is gated on there being an image and
the run reports the refusal instead of failing.
Run with the OpenAI key in a sibling .env.local file:
OPENAI_API_KEY=sk-...
The CLI loads .env.local from the manifest's directory automatically.
Imports are repo-relative because image generation is not published yet;
swap them for pinned oci://ghcr.io/telorun/... refs once ai, openai
and fs ship it.
OPENAI_API_KEY='sk-your-openai-key' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/ai-image-console/telo.yaml
Open in Telo Studio → · View ai-image-console/telo.yaml on GitHub →
ChatConsole
Interactive REPL chat with OpenAI streaming completions. Persists the
conversation in sqlite so each turn sees prior history and the chat survives
process restart. Delete the file to clear history. Type /exit (or
send EOF) to leave.
Run with the OpenAI key in a sibling .env.local file:
OPENAI_API_KEY=sk-...
The CLI loads .env.local from the manifest's directory automatically.
OPENAI_API_KEY='sk-your-openai-key' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/chat-console/telo.yaml
Open in Telo Studio → · View chat-console/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
ChatConsoleExampleTest — Talks to examples/chat-console the way a person does: wait for its prompt, ask something with an answer nobody can get wrong, read the reply, then /exit. A REPL cannot be tested any other way — every turn exists only because the previous one was answered.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/chat-console/tests/answers-a-question.yaml
Show answers-a-question.yaml
kind: Telo.Application
metadata:
name: ChatConsoleExampleTest
version: 1.0.0
description: >-
Talks to `examples/chat-console` the way a person does: wait for its prompt, ask
something with an answer nobody can get wrong, read the reply, then `/exit`. A
REPL cannot be tested any other way — every turn exists only because the
previous one was answered.
imports:
App: ../../../modules/app
Channel: ../../../modules/channel
Run: ../../../modules/run
Assert: ../../../modules/assert
variables:
# The DIRECTORY each run's history file goes in, anchored at the working
# directory: a value supplied to the app by name must already be an absolute
# host path. Named after the key it feeds because `telo check` reads
# `variables` inside an App.Instance's own `variables:` map as that map, so a
# differently named variable is reported missing there.
historyFile:
env: CHAT_CONSOLE_TEST_DIR
type: string
x-telo-type: Telo.HostPath
default: ./.telo
targets:
- !ref app
- !ref test
---
kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
# It blocks on its first prompt, so its targets never all finish dispatching.
awaitStart: false
variables:
# A conversation the example persists outlives the test, and a run that resumes
# one left half-finished behaves differently from a fresh one — so each run gets
# its own history.
historyFile: !cel "variables.historyFile.joinPath('chat-console-test-' + uuidv4() + '.sqlite')"
---
kind: Channel.ReadUntil
metadata: { name: read }
channel: !ref app
timeout: "60s"
---
kind: Channel.SendLine
metadata: { name: send }
channel: !ref app
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: firstPrompt
invoke: !ref read
inputs: { until: "you › " }
- name: ask
invoke: !ref send
inputs: { text: "Reply with exactly the word BANANA and nothing else." }
# The answer streams in between the `ai ›` marker and the next `you ›` prompt,
# so reading up to that prompt reads the whole reply however long it takes to
# arrive — no sleep, no polling.
- name: reply
invoke: !ref read
inputs: { until: "you › " }
- name: verifyAnswered
invoke: { kind: Assert.Contains }
inputs:
actual: !cel "steps.reply.result.text"
value: BANANA
- name: exit
invoke: !ref send
inputs: { text: "/exit" }
DrawShapesAgent
HTTP API where an AI vision agent draws shapes on a canvas and returns an S3 link.
OPENAI_API_KEY='sk-your-openai-key' S3_ACCESS_KEY_ID='your-secret-value' S3_SECRET_ACCESS_KEY='your-secret-value' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/draw-shapes-agent/telo.yaml
Open in Telo Studio → · View draw-shapes-agent/telo.yaml on GitHub →
SemanticSearch
Retrieval-augmented answering, as six resources: embed the documents at boot, embed the question at request time, take the nearest passages, and hand them to the model as context.
The two halves of retrieval are deliberately separate kinds.
Embedding.Passage embeds what you INDEX and Embedding.Query embeds what
you ASK — same model, different wrapping, because providers score a question
against a document better when each is told which it is. And
VectorStore.Match takes a vector rather than text: it never embeds, so the
index does not depend on an embedding provider and swapping one is a
one-resource change.
VectorStoreMemory.Store keeps the index in the process, so this runs with
nothing but an API key. Point the same VectorStore.Record / Match pair
at vector-store-pgvector and the manifest above them is unchanged.
Run with the OpenAI key in a sibling .env.local file:
OPENAI_API_KEY=sk-...
Then ask it something:
curl -s 'localhost:8079/ask?q=how+do+i+roll+back+a+bad+release'
curl -s 'localhost:8079/search?q=database+migrations'
/search returns the retrieved passages and their scores — worth looking at
before /ask, because a wrong answer is usually wrong retrieval rather than
a wrong model.
OPENAI_API_KEY='sk-your-openai-key' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/semantic-search/telo.yaml
Open in Telo Studio → · View semantic-search/telo.yaml on GitHub →
Compute
LocalModulesDemo
Two modules written beside the app and imported by relative path, racing
each other on the same proof-of-work search. pow-ts/ is TypeScript:
bundled from source on load, nothing to install, edit and rerun. pow-rust/
is Rust: compiled to native code by cargo build --release on first load,
and several times faster on CPU-bound work. Both take the same inputs and
find the same nonce, so the time is the only difference. Their kinds are
used and checked exactly like the standard library's, and publishing either
later changes only its import's source:.
Each call does all of its work at once. Values cross between Node and Rust at every call, so Rust pays off on large units of work, not on many tiny calls.
Run it from a checkout, with cargo installed:
telo run ./examples/local-modules
telo run ./examples/local-modules "any text" --difficulty 22
# telo <url> [--difficulty <integer>] [<text>]
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/local-modules/telo.yaml
Open in Telo Studio → · View local-modules/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
LocalModulesExampleTest — Mines the same text with the TypeScript and the Rust module of examples/local-modules and checks that both find the same nonce and hash, against a value computed outside either. Timings vary by machine, so they are not compared.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/local-modules/tests/both-miners-agree.yaml
Show both-miners-agree.yaml
kind: Telo.Application
metadata:
name: LocalModulesExampleTest
version: 1.0.0
description: >-
Mines the same text with the TypeScript and the Rust module of
`examples/local-modules` and checks that both find the same nonce and hash,
against a value computed outside either. Timings vary by machine, so they
are not compared.
imports:
PowTs: ../pow-ts
PowRust: ../pow-rust
Run: ../../../modules/run
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: typescript
invoke: { kind: PowTs.Mine }
inputs:
text: "Hello, Telo!"
difficulty: 12
- name: rust
invoke: { kind: PowRust.Mine }
inputs:
text: "Hello, Telo!"
difficulty: 12
- name: assertResults
invoke: { kind: Assert.Equals }
inputs:
actual:
typescript:
nonce: !cel "steps.typescript.result.nonce"
hash: !cel "steps.typescript.result.hash"
rust:
nonce: !cel "steps.rust.result.nonce"
hash: !cel "steps.rust.result.hash"
expected:
typescript:
nonce: 965
hash: "000d2247648e2b0f32eb94c1946f4253a5f2fffec89813d14234a89f7fffcd71"
rust:
nonce: 965
hash: "000d2247648e2b0f32eb94c1946f4253a5f2fffec89813d14234a89f7fffcd71"
Configuration
GreetCli
A command-line program written as a manifest. Each input is a variables:
entry bound to the command line with arg: — a positional name, a
repeatable flag, short aliases and a boolean with its --no- form — and
two of them fall back to an environment variable. Every argument after
the manifest belongs to the application — run it with --help to list
what it accepts.
# telo <url> [--also|-a <string>]... [--greeting|-g <string>] [--[no-]shout] [<name>]
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/command-line-arguments/telo.yaml --also Grace --also Linus --shout Ada
Open in Telo Studio → · View command-line-arguments/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
CommandLineArgumentsExampleTest — Runs examples/command-line-arguments with its inputs supplied by name — the way a parent application starts it, and the source that wins over the command line — and checks what it printed.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/command-line-arguments/tests/greets-everyone.yaml
Show greets-everyone.yaml
kind: Telo.Application
metadata:
name: CommandLineArgumentsExampleTest
version: 1.0.0
description: >-
Runs `examples/command-line-arguments` with its inputs supplied by name — the
way a parent application starts it, and the source that wins over the command
line — and checks what it printed.
imports:
App: ../../../modules/app
Channel: ../../../modules/channel
Run: ../../../modules/run
Assert: ../../../modules/assert
targets:
- !ref app
- !ref test
---
kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
awaitStart: false
variables:
name: Ada
also: [Grace, Linus]
greeting: Hi
shout: true
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: printed
invoke:
kind: Channel.ReadUntil
channel: !ref app
timeout: "10s"
inputs: { until: "HI, LINUS!\n" }
- name: verify
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.printed.result.text"
expected: "HI, ADA!\nHI, GRACE!\nHI, LINUS!\n"
Coordination
ExclusiveWorker
Three replicas draining one queue, and no job processed twice.
Each replica arms the same schedule, and each tick asks for the same lease
before touching the queue. One replica is inside the work at a time; the
others are told who holds it and skip. Nothing is elected and no coordinator
is deployed — exclusion is a resource (Lease.Critical) wrapped around a
body that knows nothing about it, and the STORE decides how far the
exclusion reaches: kv-store-sql on a shared database excludes across
processes and machines, kv-store-memory only within one.
What the lease is protecting is a read-then-write gap. The worker claims
pending jobs, does the work, and only then marks them done — so two replicas
running it at once would both see the same pending rows and both process
them. The worked_by column is the proof: every job is stamped exactly
once, however many replicas are running.
Run three replicas, each in its own terminal:
INSTANCE_ID=a PORT=8051 telo ./examples/exclusive-worker
INSTANCE_ID=b PORT=8052 telo ./examples/exclusive-worker
INSTANCE_ID=c PORT=8053 telo ./examples/exclusive-worker
Then watch the queue drain and who did what:
curl -s localhost:8051/status
Kill whichever replica is working. Within LEASE_TTL another one takes over
— a lease expires on its own, so a holder that dies frees it rather than
wedging the queue.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/exclusive-worker/telo.yaml
Open in Telo Studio → · View exclusive-worker/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
ExclusiveWorkerExampleTest — Runs three replicas of examples/exclusive-worker over one database — the example's README asks a reader to open three terminals, and this is that, declared. The assertion is the guarantee the example exists for: however many replicas run, each job is processed once.
The replicas start one at a time, in nested scopes. Booting all three at once has them run their first migration against the same SQLite file in the same instant, and two of them fail with "database is locked" — a real race, but one about starting a SQLite-backed application three times over rather than about the lease this test is for. Nesting also tears each replica down in the reverse order it started.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/exclusive-worker/tests/only-one-replica-holds-the-lease.yaml
Show only-one-replica-holds-the-lease.yaml
kind: Telo.Application
metadata:
name: ExclusiveWorkerExampleTest
version: 1.0.0
description: >-
Runs three replicas of `examples/exclusive-worker` over one database — the
example's README asks a reader to open three terminals, and this is that,
declared. The assertion is the guarantee the example exists for: however many
replicas run, each job is processed once.
The replicas start one at a time, in nested scopes. Booting all three at once
has them run their first migration against the same SQLite file in the same
instant, and two of them fail with "database is locked" — a real race, but one
about starting a SQLite-backed application three times over rather than about
the lease this test is for. Nesting also tears each replica down in the
reverse order it started.
imports:
App: ../../../modules/app
Run: ../../../modules/run
Timer: ../../../modules/timer
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
variables:
# Anchored at the working directory, as the example's own `dbFile` is: a value
# supplied by name must already be an absolute host path.
dbFile:
env: EXCLUSIVE_WORKER_TEST_DB
type: string
x-telo-type: Telo.HostPath
default: ./.telo/exclusive-worker-test.db
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: workerA }
source: ../telo.yaml
variables: { instanceId: a, dbFile: !cel "variables.dbFile" }
ports: { http: 8051 }
targets:
- !ref workerA
steps:
- name: settle
invoke: { kind: Timer.Delay }
inputs:
duration: 400ms
- name: withSecondReplica
invoke: !ref secondReplica
---
kind: Run.Sequence
metadata: { name: secondReplica }
with:
- kind: App.Instance
metadata: { name: workerB }
source: ../telo.yaml
variables: { instanceId: b, dbFile: !cel "variables.dbFile" }
ports: { http: 8052 }
targets:
- !ref workerB
steps:
- name: settle
invoke: { kind: Timer.Delay }
inputs:
duration: 400ms
- name: withThirdReplica
invoke: !ref thirdReplica
---
kind: Run.Sequence
metadata: { name: thirdReplica }
with:
- kind: App.Instance
metadata: { name: workerC }
source: ../telo.yaml
variables: { instanceId: c, dbFile: !cel "variables.dbFile" }
ports: { http: 8053 }
targets:
- !ref workerC
steps:
# All three are running here: the two outer scopes are still open.
- name: letThemTick
invoke: { kind: Timer.Delay }
inputs:
duration: 1500ms
- name: statusA
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8051/status
method: GET
- name: statusB
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8052/status
method: GET
- name: statusC
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8053/status
method: GET
- name: verifyIdentities
invoke: { kind: Assert.Equals }
inputs:
actual:
aStatus: !cel "steps.statusA.result.status"
bStatus: !cel "steps.statusB.result.status"
cStatus: !cel "steps.statusC.result.status"
a: !cel "steps.statusA.result.body.me"
b: !cel "steps.statusB.result.body.me"
c: !cel "steps.statusC.result.body.me"
expected:
aStatus: 200
bStatus: 200
cStatus: 200
a: a
b: b
c: c
# The ledger the three share adds up: each of the twelve seeded jobs is either
# done or still pending, never both and never counted twice. Double-processing
# is what the lease prevents, and it would show up here as a total above twelve.
- name: verifyNoJobDoneTwice
invoke: { kind: Assert.Equals }
inputs:
actual: !cel >-
steps.statusA.result.body.pending +
sum(steps.statusA.result.body.processedBy.map(row, row.jobs))
expected: 12
Reliability
ApprovalWorkflow
An expense that needs a manager's approval before it is paid — so the work stops for as long as a human takes, and the process is free to go away while it waits.
Durable.Await is the whole difference between this and a job queue. The
run records where it stopped and stops executing: no connection is held, no
timer, no process. When a delivery arrives on the token it handed out, the
run is picked up and REPLAYS — every step that already finished returns its
record instead of running again — and continues at the wait. That is why
"waiting for two days" costs nothing here and why the app can be restarted
mid-approval.
The demo is the restart:
telo ./examples/approval-workflow &
curl -s -XPOST localhost:8081/expenses -H 'content-type: application/json' \
-d '{"employee":"ada","amountCents":250000,"reason":"conference"}'
# → {"runId":"expense:...","token":"approve:...","status":"running"}
curl -s localhost:8081/expenses/expense:ada:1 # parked, waiting
kill %1 # stop the app entirely
telo ./examples/approval-workflow & # start it again
curl -s -XPOST localhost:8081/approvals -H 'content-type: application/json' \
-d '{"token":"approve:expense:ada:1","approvedBy":"grace","note":"ok"}'
curl -s localhost:8081/expenses/expense:ada:1 # completed, and paid once
Two supporting pieces make that work. DurableLocal.Resumer polls for runs
whose process died and continues them, claiming each one first so two
replicas never take the same run. And a small expense skips the wait
entirely — the branch is a decision the run RECORDS, so a resume cannot
re-derive it differently and hand a recorded result to work it never
described.
Imports are relative because the durable modules are not published yet; they
become pinned oci:// refs like every other example once they are.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/approval-workflow/telo.yaml
Open in Telo Studio → · View approval-workflow/telo.yaml on GitHub →
DurableOrders
Fulfilling an order in four steps, so that killing the process half way and starting it again continues from where it stopped — instead of charging the card a second time.
The demo is the interruption. Run it, HARD-KILL it while it is charging, look at the journal file it left behind, then run it again with the same order id: the steps that already finished do not print, because they are not re-run. Their results come back from the record.
A hard kill, not Ctrl-C, and the difference is the point: a durable run does not inherit the cancellation of whatever triggered it — otherwise a run started by an HTTP request would die with the response — so Ctrl-C stops this app WAITING for the run, not the run. Only the process going away stops that.
pnpm run telo examples/durable-orders/telo.yaml &
sleep 5 && kill -9 %1
cat .telo/orders/*.ndjson # one line per completed step
pnpm run telo examples/durable-orders/telo.yaml # picks up where it stopped
ORDER_ID=A-2 pnpm run telo examples/durable-orders/telo.yaml # a new order runs fresh
What makes that work is not that the results are cached. Each step's outcome is recorded WHEN IT COMPLETES, so a step interrupted mid-flight has no record and runs again — while one that finished is never re-entered. Recording on dispatch instead would mark the charge done the instant it was attempted, and an interruption one line later would skip it forever.
Imports are relative because these modules are not published yet; they become
pinned oci:// refs like every other example once they are.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/durable-orders/telo.yaml
Open in Telo Studio → · View durable-orders/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
DurableOrdersExampleTest — Runs examples/durable-orders to completion. The example's subject is a workflow that survives being killed mid-flight, and its journal outlives the run — so what is asserted here is that a run completes, whether it is starting fresh or replaying a journal an earlier run left behind.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/durable-orders/tests/completes-the-workflow.yaml
Show completes-the-workflow.yaml
kind: Telo.Application
metadata:
name: DurableOrdersExampleTest
version: 1.0.0
description: >-
Runs `examples/durable-orders` to completion. The example's subject is a
workflow that survives being killed mid-flight, and its journal outlives the
run — so what is asserted here is that a run completes, whether it is starting
fresh or replaying a journal an earlier run left behind.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
Scheduling
NightlyReport
The job every service ends up with: read yesterday's rows, roll them up, write a file, say what happened. Here it is a schedule, a query, a group-by and a file write — four resources, no script.
Collection.GroupBy does the aggregation over the result set in CEL, so the
shape of the report is visible in the manifest instead of buried in SQL or in
a JavaScript reduce. The same rollup would work over rows from an HTTP API or
a CSV — it is not a database feature.
Runs once at boot so you see output immediately, then on REPORT_CRON
(default 03:00 daily). To watch it tick:
REPORT_CRON='*/10 * * * * *' telo ./examples/nightly-report
The report lands in ./reports/orders.csv.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/nightly-report/telo.yaml
Open in Telo Studio → · View nightly-report/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
NightlyReportExampleTest — Runs examples/nightly-report and asks for the report over HTTP. The example wires one report resource to two triggers — a cron schedule and a route — and the route is the half a test can reach without waiting for a clock. The cron is set to a date the test will never see, so what comes back is this request's work and nothing else.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/nightly-report/tests/builds-the-report-on-demand.yaml
Show builds-the-report-on-demand.yaml
kind: Telo.Application
metadata:
name: NightlyReportExampleTest
version: 1.0.0
description: >-
Runs `examples/nightly-report` and asks for the report over HTTP. The example
wires one report resource to two triggers — a cron schedule and a route — and
the route is the half a test can reach without waiting for a clock. The cron is
set to a date the test will never see, so what comes back is this request's
work and nothing else.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8044
variables:
reportCron: "0 3 1 1 *"
targets:
- !ref app
steps:
- name: report
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8044/report
method: GET
- name: verify
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.report.result.status"
rows: !cel "size(steps.report.result.body) > 0"
expected:
status: 200
rows: true
Storage
SchemaEvolution
The second release of an app, and what the database does about it.
v1.yaml beside this file is release 1. Run that first, then this one
against the same file, and three kinds of change happen without a migration
being written for any of them:
· email becomes contactEmail — declared with renamedFrom:, applied
as expand-contract. The new column is added, the values are COPIED, and
the old one is kept as a tombstone, so release 1 keeps working while
both are deployed. A native RENAME would break it mid-rollout.
· signup_notes is simply gone from the declaration. It is recorded as
removed and left in place, because dropping a column loses data: it is
reclaimed later, once reclaim: says enough releases and enough time
have passed.
· plan gains a declared enum. SQLite has no named types, so the values
become a CHECK on the column — and the same declaration on PostgreSQL
creates a real type. What the manifest states is the constraint, not the
DDL that expresses it.
Nothing is discovered by diffing the database: the schema keeps a ledger of what it declared last time, which is what tells a REMOVAL apart from an object it never owned.
telo ./examples/schema-evolution/v1.yaml # release 1, seeds two rows
telo ./examples/schema-evolution # release 2: renames, tombstones
telo ./examples/schema-evolution # again: nothing left to do
Every boot prints the schema's observed state — what it tombstoned, revived
or reclaimed. To watch a tombstone actually age out, bump this manifest's
metadata.version twice (to 3.0.0, then 4.0.0) and run again: on the second
of those the removals have outlived afterVersions: 2 and are dropped,
printed under reclaimed. Delete schema-evolution.db to start over.
reclaim.afterDuration is 1ms here so four releases in one afternoon can
actually reclaim something. A deployment wants something like 30d: the
window exists so that a rollback is still possible while an object is only
tombstoned. Setting it to 0ms disables the time backstop entirely, and
telo check warns about that — a declared rule on the kind, not a special
case. Try it and see the warning; this manifest does not ship with one,
because it is meant to be copied.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/schema-evolution/telo.yaml
Open in Telo Studio → · View schema-evolution/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
SchemaEvolutionExampleTest — Runs release 1, then release 2 against the same database, in that order — which is the example: a rename applied as expand-contract, a removed column kept as a tombstone, and a reclaim that only happens once the grace window has passed. Release 2 is then run a second time, because a declaration that cannot be re-applied to the database it just produced is the failure this whole design exists to prevent.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/schema-evolution/tests/upgrades-release-one-in-place.yaml
Show upgrades-release-one-in-place.yaml
kind: Telo.Application
metadata:
name: SchemaEvolutionExampleTest
version: 1.0.0
description: >-
Runs release 1, then release 2 against the same database, in that order — which
is the example: a rename applied as expand-contract, a removed column kept as a
tombstone, and a reclaim that only happens once the grace window has passed.
Release 2 is then run a second time, because a declaration that cannot be
re-applied to the database it just produced is the failure this whole design
exists to prevent.
imports:
App: ../../../modules/app
Run: ../../../modules/run
Timer: ../../../modules/timer
Assert: ../../../modules/assert
variables:
# Anchored at the working directory, as the example's own `dbFile` is: a value
# supplied by name must already be an absolute host path.
dbFile:
env: SCHEMA_EVOLUTION_TEST_DB
type: string
x-telo-type: Telo.HostPath
default: ./.telo/schema-evolution-test.db
targets:
- !ref releaseOne
- !ref releaseTwo
- !ref releaseTwoAgain
- !ref test
---
kind: App.Instance
metadata: { name: releaseOne }
source: ../v1.yaml
variables:
dbFile: !cel "variables.dbFile"
---
kind: App.Instance
metadata: { name: releaseTwo }
source: ../telo.yaml
variables:
dbFile: !cel "variables.dbFile"
---
kind: App.Instance
metadata: { name: releaseTwoAgain }
source: ../telo.yaml
variables:
dbFile: !cel "variables.dbFile"
---
kind: Run.Loop
metadata: { name: awaitReleases }
condition: !cel >-
resources.releaseOne.status.exitCode == null ||
resources.releaseTwo.status.exitCode == null ||
resources.releaseTwoAgain.status.exitCode == null
maxIterations: 300
steps:
- name: wait
invoke: { kind: Timer.Delay }
inputs:
duration: 20ms
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: awaitExits
invoke: !ref awaitReleases
- name: verifyEachRelease
invoke: { kind: Assert.Equals }
inputs:
actual:
releaseOne: !cel "resources.releaseOne.status.exitCode"
releaseTwo: !cel "resources.releaseTwo.status.exitCode"
releaseTwoAgain: !cel "resources.releaseTwoAgain.status.exitCode"
expected:
releaseOne: 0
releaseTwo: 0
releaseTwoAgain: 0
Streaming
StreamingExport
Data in as a file upload, data out as a stream — and the wire format on the way out is a routing decision, not a handler decision.
POST /import takes a multipart upload. The route declares its body as a
stream of bytes, which is what a multipart body is: parsing it as text would
corrupt any binary part, and the decoder is an ordinary resource rather than
a parser configured on the server.
GET /export returns the stored rows in whichever format the caller asked
for. The handler produces a stream of records and knows nothing about NDJSON
or SSE; the route lists an encoder per media type and Accept picks one.
mode: stream pipes the handler's stream through that encoder straight to
the socket, so the response is written as it is produced rather than
assembled in memory first — and a third format is one more line under
content:, not another handler.
Run, then:
printf 'ada,pro\ngrace,free\nlin,enterprise\n' > /tmp/accounts.csv
curl -s -XPOST localhost:8080/import -F file=@/tmp/accounts.csv
curl -s localhost:8080/export -H 'accept: application/x-ndjson'
curl -s localhost:8080/export -H 'accept: text/event-stream'
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/export \
-H 'accept: image/png' # 406
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/streaming-export/telo.yaml
Open in Telo Studio → · View streaming-export/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
StreamingExportExampleTest — Runs examples/streaming-export and asks the one export route for each format it advertises. Content negotiation is the whole example: the same handler streams NDJSON or SSE depending on Accept, and answers 406 for a type it cannot produce rather than sending the wrong one.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/streaming-export/tests/exports-in-the-requested-format.yaml
Show exports-in-the-requested-format.yaml
kind: Telo.Application
metadata:
name: StreamingExportExampleTest
version: 1.0.0
description: >-
Runs `examples/streaming-export` and asks the one export route for each format
it advertises. Content negotiation is the whole example: the same handler
streams NDJSON or SSE depending on `Accept`, and answers 406 for a type it
cannot produce rather than sending the wrong one.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8080
targets:
- !ref app
steps:
- name: ndjson
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8080/export
method: GET
headers:
accept: application/x-ndjson
- name: sse
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8080/export
method: GET
headers:
accept: text/event-stream
- name: unsupported
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8080/export
method: GET
headers:
accept: image/png
- name: verifyNegotiation
invoke: { kind: Assert.Equals }
inputs:
actual:
ndjsonStatus: !cel "steps.ndjson.result.status"
ndjsonType: !cel "steps.ndjson.result.headers['content-type']"
sseStatus: !cel "steps.sse.result.status"
sseType: !cel "steps.sse.result.headers['content-type']"
unsupportedStatus: !cel "steps.unsupported.result.status"
expected:
ndjsonStatus: 200
ndjsonType: application/x-ndjson
sseStatus: 200
sseType: text/event-stream
unsupportedStatus: 406
Testing
TestingAManifest
The same app twice: once as something you run, once as something you test.
A manifest is testable when the part worth asserting is not welded to the
process that serves it. cart/ is the pricing rule and api/ is the route
over it, each a Telo.Library exporting its instance; this application does
nothing but mount the route on a port. So tests/ reaches the very same
instances — one test invoking the rule directly with no HTTP at all, one
standing a server up inside the test with with: and calling the route over
the network. A with: scope is created on entry and torn down on exit,
which is what lets an integration test own a real server without leaking a
listening port into whatever runs next.
Run the app:
telo ./examples/testing-a-manifest
curl -s -XPOST localhost:8078/cart/price -H 'content-type: application/json' \
-d '{"items":[{"sku":"cap","unitCents":2500,"quantity":2}]}'
Run its tests — every test manifest is its own kernel, so nothing leaks between them:
telo ./examples/testing-a-manifest/test-suite.yaml
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/testing-a-manifest/telo.yaml
Open in Telo Studio → · View testing-a-manifest/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
PricesACartTest — Unit test: invoke the pricing rule directly, with no server and no HTTP. The library exports the instance, so the test dispatches the very resource the application mounts — not a copy of it.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/testing-a-manifest/tests/prices-a-cart.yaml
Show prices-a-cart.yaml
kind: Telo.Application
metadata:
name: PricesACartTest
version: 1.0.0
description: |
Unit test: invoke the pricing rule directly, with no server and no HTTP.
The library exports the instance, so the test dispatches the very resource
the application mounts — not a copy of it.
imports:
Cart: ../cart
Run: oci://ghcr.io/telorun/run@0.28.0#sha256-CoPkA-u8-m4kjQAOTDr_TmsfA72HDpnoZI-hHz8N7kE
Assert: oci://ghcr.io/telorun/assert@0.21.1#sha256-9CCELeaW54ph9v7qtM1zBRq9-gqXK4fwF0NubWAhsN4
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: smallOrder
invoke: !ref Cart.priceCart
inputs:
items:
- { sku: cap, unitCents: 2500, quantity: 2 }
- { sku: pin, unitCents: 500, quantity: 1 }
# Assert.Equals compares the whole result against a literal, so a field that
# appears, disappears or changes shape fails here rather than in production.
- name: verifySmallOrder
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.smallOrder.result"
expected:
subtotalCents: 5500
discountCents: 0
totalCents: 5500
# At the threshold the discount applies — the boundary, not a value safely
# past it, because that is the case a wrong comparison operator gets wrong.
- name: atThreshold
invoke: !ref Cart.priceCart
inputs:
items:
- { sku: jacket, unitCents: 10000, quantity: 1 }
- name: verifyThreshold
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.atThreshold.result"
expected:
subtotalCents: 10000
discountCents: 1000
totalCents: 9000
ServesTheApiTest — Integration test: mount the exported route on a server the test itself owns,
then call it over the network. The server is declared in the sequence's
with: block, so it starts before the first step and is torn down after the
last one — the port is free again the moment the test ends, and the run
exits on its own instead of hanging on a listening socket.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/testing-a-manifest/tests/serves-the-api.yaml
Show serves-the-api.yaml
kind: Telo.Application
metadata:
name: ServesTheApiTest
version: 1.0.0
description: |
Integration test: mount the exported route on a server the test itself owns,
then call it over the network. The server is declared in the sequence's
`with:` block, so it starts before the first step and is torn down after the
last one — the port is free again the moment the test ends, and the run
exits on its own instead of hanging on a listening socket.
imports:
Api: ../api
Http: oci://ghcr.io/telorun/http-server@0.38.0#sha256-QUCZmb_cvTor4YhWUAj3YdRj-kacNkmkXLnycjXLBtA
HttpClient: oci://ghcr.io/telorun/http-client@0.23.0#sha256-6WAIh0ajnTlkWNuP-59e1qNVUXVSJDWZxvPVACQg1AM
Run: oci://ghcr.io/telorun/run@0.28.0#sha256-CoPkA-u8-m4kjQAOTDr_TmsfA72HDpnoZI-hHz8N7kE
Assert: oci://ghcr.io/telorun/assert@0.21.1#sha256-9CCELeaW54ph9v7qtM1zBRq9-gqXK4fwF0NubWAhsN4
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: Http.Server
metadata: { name: Server }
listen:
port: 8971
address: 127.0.0.1
openapi:
info:
title: Cart pricing under test
version: 1.0.0
mounts:
- path: /cart
mount: !ref Api.cartApi
targets:
- !ref Server
steps:
- name: price
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8971/cart/price
method: POST
inputs:
body:
items:
- { sku: jacket, unitCents: 10000, quantity: 1 }
- { sku: pin, unitCents: 500, quantity: 2 }
- name: verifyPriced
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.price.result.status"
body: !cel "steps.price.result.body"
expected:
status: 200
body:
subtotalCents: 11000
discountCents: 1100
totalCents: 9900
# The route declares its request schema, so a malformed body is refused before
# the handler runs. Asserting the 400 is what keeps that guarantee from being
# quietly removed later.
- name: badBody
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8971/cart/price
method: POST
inputs:
body:
items:
- { sku: pin, unitCents: 500 }
- name: verifyRejected
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.badBody.result.status"
expected: 400
Transport
ApiIntegration
Calling somebody else's API as a user, from a machine with no browser on it.
The device grant is the flow for exactly that: this prints a short code and a URL, you approve on your phone or laptop, and the grant is stored. The redirect flow would need a callback address the provider can reach, which a terminal, a container and a CI job all lack.
The point worth copying is where the token is NOT. No step here reads an access token or sets an Authorization header: the credential sits on the HTTP client, so every request through that client is authenticated, and the 401-refresh-and-retry lives in the request controller rather than in this manifest. Swap the device grant for client credentials and nothing below the client changes.
The grant is stored in SQLite, so the second run does not sign in again — it reads the stored grant, refreshes it if the access token has expired, and goes straight to the call.
Defaults are GitHub, which publishes no discovery document, so its three
endpoints are given explicitly; a provider that publishes one needs only
issuer: and discovers the rest. Create an OAuth app (Settings → Developer
settings → OAuth Apps) with device flow enabled, then:
OAUTH_CLIENT_ID=Ov23li... telo ./examples/api-integration
# → open the URL, type the code, and it prints who you are
OAUTH_CLIENT_ID=Ov23li... telo ./examples/api-integration
# → second run: no sign-in, straight to the call
A 404 without a device_code from the start step means GitHub does not know
that client id, or the app has device flow switched off — it answers 404
rather than naming either.
OAUTH_CLIENT_ID='your-secret-value' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/api-integration/telo.yaml
Open in Telo Studio → · View api-integration/telo.yaml on GitHub →
CustomKindDemo
Telo's kind library is not a closed set. notify/ is a Telo.Library that
defines two new kinds in YAML alone — Notify.Webhook, built from an HTTP
request, and Notify.Slack, which extends it and narrows the schema to a
single webhookUrl — and this app imports them and uses them like any
built-in. No controller code, no npm package, no publish step.
To keep the demo self-contained, the "Slack" it posts to is a throwaway
Http.Server scoped to the run with with:, so it starts before the first
notification and is torn down after the last one — which is also why the app
exits on its own instead of hanging on a listening port.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/custom-kind/telo.yaml
Open in Telo Studio → · View custom-kind/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
CustomKindExampleTest — Runs examples/custom-kind to completion. The example defines two notification kinds in YAML alone and posts through both to a receiver it stands up itself, so what it prints is the round trip: a kind declared, instantiated and delivered.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/custom-kind/tests/delivers-to-both-receivers.yaml
Show delivers-to-both-receivers.yaml
kind: Telo.Application
metadata:
name: CustomKindExampleTest
version: 1.0.0
description: >-
Runs `examples/custom-kind` to completion. The example defines two notification
kinds in YAML alone and posts through both to a receiver it stands up itself,
so what it prints is the round trip: a kind declared, instantiated and
delivered.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
stdout: "received on /generic: {\"event\":\"deploy.finished\",\"service\":\"checkout\",\"version\":\"2026.8.1\"}\nreceived on /slack: {\"text\":\"checkout 2026.8.1 is live\"}\n✔ webhook -> HTTP 200, slack -> HTTP 200\n"
FeedbackApiRepo
Feedback REST API with no hand-written SQL past the schema migration:
SqlRepository.Create / SqlRepository.Read are the route handlers, and CEL
maps request.* into them and result.rows back out.
Reach for this shape when the routes are yours to design but the queries are
plain table access; reach for Crud.Resource (see todo-app) when you want
the whole REST surface generated, and for Sql.Query when you need a join.
Run, then:
curl -X POST localhost:8844/v1/feedback \
-H 'content-type: application/json' \
-d '{"text":"the docs are great","source":"web","rating":5}'
curl localhost:8844/v1/feedback
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/feedback-api-repo/telo.yaml
Open in Telo Studio → · View feedback-api-repo/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
FeedbackApiExampleTest — Runs examples/feedback-api-repo and posts, lists and rejects feedback. The rejected rating is the assertion worth having: the request schema is what keeps an out-of-range value from ever reaching the database.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/feedback-api-repo/tests/stores-and-lists-feedback.yaml
Show stores-and-lists-feedback.yaml
kind: Telo.Application
metadata:
name: FeedbackApiExampleTest
version: 1.0.0
description: >-
Runs `examples/feedback-api-repo` and posts, lists and rejects feedback. The
rejected rating is the assertion worth having: the request schema is what keeps
an out-of-range value from ever reaching the database.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8844
targets:
- !ref app
steps:
- name: post
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8844/v1/feedback
method: POST
inputs:
body:
text: the docs made sense
rating: 5
- name: verifyPosted
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.post.result.status"
body: !cel "steps.post.result.body"
expected:
status: 201
body:
ok: true
message: Feedback received
- name: badRating
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8844/v1/feedback
method: POST
inputs:
body:
text: out of range
rating: 9
- name: verifyRejected
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.badRating.result.status"
expected: 400
- name: list
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8844/v1/feedback
method: GET
- name: verifyListed
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.list.result.status"
text: !cel "steps.list.result.body[0].text"
rating: !cel "steps.list.result.body[0].rating"
expected:
status: 200
text: the docs made sense
rating: 5
OrderWebhook
A webhook receiver that survives the things webhooks actually do to you: redelivery, bursts, and work that must not block the response.
Providers retry until they get a 2xx, so the same delivery arrives more than
once. Idempotency.Once claims the delivery id in a key/value store, runs the
body at most once, and replays the stored result for every retry — the reply
reports fresh or replayed, so you can watch it happen. Fulfilment is not
done inline: the handler records a pending row and a Schedule.Interval
drains the queue in the background, so the provider gets its 202 immediately.
Nothing here is a library call — retry-safety and the background drain are
both declared, and telo check sees them.
Run, then send the same delivery twice:
curl -s -XPOST localhost:8099/webhooks/orders \
-H 'content-type: application/json' \
-H 'authorization: Bearer local-dev-token' \
-d '{"deliveryId":"dlv_1","orderId":"ord_1","amountCents":4999,"currency":"EUR"}'
The first reply says "fresh", the second "replayed" — and only one order row exists. Watch stdout for the sweeper shipping it.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/order-webhook/telo.yaml
Open in Telo Studio → · View order-webhook/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
OrderWebhookExampleTest — Runs examples/order-webhook and redelivers the same webhook twice, which is what the example exists to make safe: the first delivery is fresh, the second is replayed and writes nothing. An unauthorized call is asserted too, since the shared-secret check is the other half of the contract.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/order-webhook/tests/accepts-a-redelivery.yaml
Show accepts-a-redelivery.yaml
kind: Telo.Application
metadata:
name: OrderWebhookExampleTest
version: 1.0.0
description: >-
Runs `examples/order-webhook` and redelivers the same webhook twice, which is
what the example exists to make safe: the first delivery is fresh, the second is
replayed and writes nothing. An unauthorized call is asserted too, since the
shared-secret check is the other half of the contract.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8099
targets:
- !ref app
steps:
# The example keeps its claims in a database file that outlives the test, so
# the delivery is identified freshly on every run: a fixed id would be
# "replayed" the second time this test is run on the same machine, and the
# first assertion would fail for a reason that has nothing to do with the code.
- name: delivery
value:
id: !cel "'delivery-' + uuidv4()"
orderId: !cel "'order-' + uuidv4()"
- name: first
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8099/webhooks/orders
method: POST
headers:
authorization: Bearer local-dev-token
inputs:
body:
deliveryId: !cel "steps.delivery.result.id"
orderId: !cel "steps.delivery.result.orderId"
amountCents: 2500
currency: EUR
- name: verifyFresh
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.first.result.status"
body: !cel "steps.first.result.body"
expected:
status: 202
body:
orderId: !cel "steps.delivery.result.orderId"
state: fresh
- name: redelivery
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8099/webhooks/orders
method: POST
headers:
authorization: Bearer local-dev-token
inputs:
body:
deliveryId: !cel "steps.delivery.result.id"
orderId: !cel "steps.delivery.result.orderId"
amountCents: 2500
currency: EUR
- name: verifyReplayed
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.redelivery.result.status"
body: !cel "steps.redelivery.result.body"
expected:
status: 202
body:
orderId: !cel "steps.delivery.result.orderId"
state: replayed
- name: unauthorized
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8099/webhooks/orders
method: POST
headers:
authorization: Bearer wrong-token
inputs:
body:
deliveryId: !cel "'delivery-' + uuidv4()"
orderId: !cel "'order-' + uuidv4()"
amountCents: 100
currency: EUR
- name: verifyUnauthorized
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.unauthorized.result.status"
expected: 401
UrlShortener
A URL shortener exercising the cache, rate-limit, run (Detach), sql, and http-server modules end to end. A thin root that owns the shared schema and HTTP server and wires two libraries:
redirect/ POST /links {code,url} → {code,shortUrl}, GET /{code} (cache
read-through, rate-limit, detached click recording)
admin/ GET /admin/links, GET /admin/links/{code}
Zero external infra: a shared SQLite file + in-process CacheMemory.Store.
The address short links point at is the site need, so run it locally with
TELO_NEED_SITE__URL=http://localhost:8080.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/url-shortener/telo.yaml
Open in Telo Studio → · View url-shortener/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
UrlShortenerExampleTest — Runs examples/url-shortener and walks the path a link takes: created through one library, resolved by another, and listed by a third, all over one server. The redirect is asserted as a 302 with its Location — a computed response header, which is the shape that used to be refused at boot. The short link is built from the application's site need, which .env beside this test sets.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/url-shortener/tests/shortens-and-redirects.yaml
Show shortens-and-redirects.yaml
kind: Telo.Application
metadata:
name: UrlShortenerExampleTest
version: 1.0.0
description: >-
Runs `examples/url-shortener` and walks the path a link takes: created through
one library, resolved by another, and listed by a third, all over one server.
The redirect is asserted as a 302 with its `Location` — a computed response
header, which is the shape that used to be refused at boot. The short link is
built from the application's `site` need, which `.env` beside this test sets.
requires:
# 0.98.0: `dbFile` is a `Telo.HostPath`, which an older analyzer rejects as
# X_TELO_TYPE_UNKNOWN.
telo: ">=0.98.0"
# A value supplied to a child application is not anchored; this variable
# resolves the relative default against the working directory.
variables:
dbFile:
env: URL_SHORTENER_TEST_DB
type: string
x-telo-type: Telo.HostPath
default: ./.telo/url-shortener-test.db
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8085
variables:
dbFile: !cel "variables.dbFile"
targets:
- !ref app
steps:
# The database outlives the test and a code is a primary key, so each run
# shortens under a code of its own.
- name: link
value:
code: !cel "'t' + replace(uuidv4(), '-', '')"
- name: create
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8085/links
method: POST
inputs:
body:
code: !cel "steps.link.result.code"
url: https://telo.run/docs
- name: verifyCreated
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.create.result.status"
code: !cel "steps.create.result.body.code"
shortUrl: !cel "steps.create.result.body.shortUrl"
expected:
status: 201
code: !cel "steps.link.result.code"
shortUrl: !cel "'http://127.0.0.1:8085/' + steps.link.result.code"
# `redirect: manual` — the client must report the 302 rather than follow it,
# which is the thing under test.
- name: follow
invoke:
kind: HttpClient.Request
method: GET
redirect: manual
inputs:
url: !cel "'http://127.0.0.1:8085/' + steps.link.result.code"
- name: verifyRedirect
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.follow.result.status"
location: !cel "steps.follow.result.headers.location"
expected:
status: 302
location: https://telo.run/docs
- name: unknown
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8085/nothing-here
method: GET
redirect: manual
- name: listed
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8085/admin/links
method: GET
- name: verifyAdminView
invoke: { kind: Assert.Equals }
inputs:
actual:
unknown: !cel "steps.unknown.result.status"
listStatus: !cel "steps.listed.result.status"
listed: !cel "size(steps.listed.result.body.filter(row, row.code == steps.link.result.code))"
expected:
unknown: 404
listStatus: 200
listed: 1
UI
ReportConsole
A data-driven report console with no hand-written frontend, in English and Polish. A sign-in gate reads who is signed in; a form picks a timesheet and an upload folder from a searchable file list, a period with presets, the projects of the chosen sheet, columns and output options; the answer is drawn as headings with plural counts, warning boxes and a results list whose rows draft an e-mail or upload the report. A second page lists a sheet's projects in a table. The backend is a set of stub routes over fixture data.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/report-console/telo.yaml
Open in Telo Studio → · View report-console/telo.yaml on GitHub → · Read the walkthrough →
Tests — each test is an application of its own, runnable by URL like the example:
ReportConsoleRoutesTest — Runs examples/report-console and calls every route its screen calls, with the records the page would send: the sign-in check with and without a session, the consent stand-in granting and refusing, connecting and signing out; the Drive and the projects under the collection contract — by kind, by search, by id, a page at a time; generating a billing report, an hours report, an empty one and the refusals; drafting, uploading and reminding. Every route but the sign-in ones answers 401 without a session.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/report-console/tests/answers-every-route.yaml
Show answers-every-route.yaml
kind: Telo.Application
metadata:
name: ReportConsoleRoutesTest
version: 1.0.0
description: >-
Runs `examples/report-console` and calls every route its screen calls, with
the records the page would send: the sign-in check with and without a
session, the consent stand-in granting and refusing, connecting and signing
out; the Drive and the projects under the collection contract — by kind, by
search, by id, a page at a time; generating a billing report, an hours
report, an empty one and the refusals; drafting, uploading and reminding.
Every route but the sign-in ones answers 401 without a session.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
# A browser with no session. Redirects are read, not followed.
kind: HttpClient.Client
metadata: { name: visitor }
baseUrl: http://127.0.0.1:8142
followRedirects: false
---
kind: HttpClient.Client
metadata: { name: signedIn }
baseUrl: http://127.0.0.1:8142
headers:
cookie: report_session=signed-in
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8142
targets:
- !ref app
steps:
# Signing in: the check, the consent stand-in, connecting and signing out.
- name: anonymous
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/auth }
- name: expired
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/auth }
inputs:
headers: { cookie: "theme=light; report_signin=expired" }
- name: session
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/auth }
- name: granted
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/auth/consent }
- name: denied
invoke: { kind: HttpClient.Request, client: !ref visitor, url: "/api/auth/consent?outcome=denied" }
- name: connect
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/auth/connect, method: POST }
inputs:
body: {}
- name: signOut
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/auth/signout, method: POST }
inputs:
body: {}
- name: account
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/auth/user }
- name: verifySignIn
invoke: { kind: Assert.Equals }
inputs:
actual:
anonymous: !cel "steps.anonymous.result.body"
expired: !cel "steps.expired.result.body"
session: !cel "steps.session.result.body"
granted:
status: !cel "steps.granted.result.status"
location: !cel "steps.granted.result.headers['location']"
cookie: !cel "steps.granted.result.headers['set-cookie']"
denied:
status: !cel "steps.denied.result.status"
location: !cel "steps.denied.result.headers['location']"
cookie: !cel "steps.denied.result.headers['set-cookie']"
connect: !cel "steps.connect.result.body"
signOut:
status: !cel "steps.signOut.result.status"
cookie: !cel "steps.signOut.result.headers['set-cookie']"
account: !cel "steps.account.result.body"
expected:
anonymous: { authorized: false }
expired: { authorized: false, failure: expired }
session: { authorized: true }
granted:
status: 302
location: /
cookie: report_session=signed-in; Path=/; HttpOnly; SameSite=Lax
denied:
status: 302
location: /?signin=denied
cookie: report_signin=denied; Path=/; HttpOnly; SameSite=Lax
connect: { url: /api/auth/consent }
signOut:
status: 200
cookie: report_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax
account: { email: ada.nowak@example.com }
# Without a session every other route is refused, with a body the page's
# failure tree reads.
- name: anonymousAccount
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/auth/user }
- name: anonymousFiles
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/drive/files }
- name: anonymousGenerate
invoke: { kind: HttpClient.Request, client: !ref visitor, url: /api/generate, method: POST }
inputs:
body: {}
- name: verifyGuarded
invoke: { kind: Assert.Equals }
inputs:
actual:
- [ !cel "steps.anonymousAccount.result.status", !cel "steps.anonymousAccount.result.body" ]
- [ !cel "steps.anonymousFiles.result.status", !cel "steps.anonymousFiles.result.body" ]
- [ !cel "steps.anonymousGenerate.result.status", !cel "steps.anonymousGenerate.result.body" ]
expected:
- [ 401, &refused { code: UNAUTHORIZED, message: Sign in to Google first. } ]
- [ 401, *refused ]
- [ 401, *refused ]
# The Drive: the sheets a page at a time, a search, and the lookup that
# labels a remembered sheet and folder by id.
- name: sheets
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/drive/files?kind=spreadsheet&limit=2" }
- name: moreSheets
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/drive/files?kind=spreadsheet&limit=2&cursor=2" }
- name: searched
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/drive/files?kind=folder&name.contains=2026" }
- name: labelled
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/drive/files?id.in=sheet-2026&id.in=folder-archive" }
- name: verifyDrive
invoke: { kind: Assert.Equals }
inputs:
actual:
sheets:
ids: !cel "steps.sheets.result.body.rows.map(r, r.id)"
total: !cel "steps.sheets.result.body.total"
next: !cel "steps.sheets.result.body.next"
moreSheets:
ids: !cel "steps.moreSheets.result.body.rows.map(r, r.id)"
next: !cel "steps.moreSheets.result.body.next"
searched: !cel "steps.searched.result.body.rows.map(r, r.id)"
labelled:
rows: !cel "steps.labelled.result.body.rows.map(r, [r.id, r.name, r.kind, r.owner, r.url])"
total: !cel "steps.labelled.result.body.total"
next: !cel "steps.labelled.result.body.next"
expected:
sheets: { ids: [ sheet-2026, sheet-2025 ], total: 3, next: "2" }
moreSheets: { ids: [ sheet-support ], next: null }
searched: [ folder-invoices ]
labelled:
rows:
- [ sheet-2026, Hours 2026, spreadsheet, ada.nowak@example.com, "https://drive.example.com/sheet-2026" ]
- [ folder-archive, Archive, folder, jan.kowalski@example.com, "https://drive.example.com/folder-archive" ]
total: 2
next: null
# The projects of the chosen sheet, and the lookup that keeps only the
# chosen projects the sheet still has.
- name: projects
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/projects?spreadsheetId=sheet-2026" }
- name: keptProjects
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: "/api/projects?spreadsheetId=sheet-2026&id.in=atlas&id.in=helpdesk" }
- name: verifyProjects
invoke: { kind: Assert.Equals }
inputs:
actual:
projects: !cel "steps.projects.result.body.rows.map(r, [r.id, r.name, 'email' in r ? r.email : ''])"
total: !cel "steps.projects.result.body.total"
kept: !cel "steps.keptProjects.result.body.rows.map(r, r.id)"
expected:
projects:
- [ atlas, Atlas, billing@atlas.example.com ]
- [ borealis, Borealis, "" ]
- [ cirrus, Cirrus, finance@cirrus.example.com ]
- [ delta, Delta, ap@delta.example.com ]
total: 4
kept: [ atlas ]
# A billing report: an amount among the columns makes it one, so the files
# carry amounts and what could not be priced is listed. Delta logged no time
# in September and is skipped.
- name: billing
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body:
spreadsheetId: sheet-2026
period: { from: "2026-09-01", to: "2026-09-30" }
columns: [ date, person, hours, amount ]
hoursFormat: clock
merge: true
chart: true
- name: verifyBilling
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.billing.result.status"
body: !cel "steps.billing.result.body"
expected:
status: 200
body:
kind: billing
period: 2026-09-01 – 2026-09-30
projectsInPeriod: 3
skipped: 1
files:
- projectId: atlas
project: Atlas
email: billing@atlas.example.com
hours: "42:30"
amount: 8500
currency: PLN
pdf: https://drive.example.com/reports/sheet-2026/2026-09/atlas.pdf
- projectId: borealis
project: Borealis
hours: "12:15"
amount: 2205
currency: EUR
pdf: https://drive.example.com/reports/sheet-2026/2026-09/borealis.pdf
- projectId: cirrus
project: Cirrus
email: finance@cirrus.example.com
hours: "8:00"
pdf: https://drive.example.com/reports/sheet-2026/2026-09/cirrus.pdf
unpriced: [ { person: Jan Kowalski, hours: 3 } ]
unassigned: [ { task: Weekly sync, hours: 2 } ]
noCurrency: [ { project: Cirrus } ]
combined: https://drive.example.com/reports/sheet-2026/2026-09/all.pdf
chart: https://drive.example.com/reports/sheet-2026/2026-09/chart.svg
projectsLink: /projects?sheet=sheet-2026
# An hours report over two chosen projects, one of which logged nothing.
- name: hours
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body:
spreadsheetId: sheet-2026
period: { from: "2026-09-01", to: "2026-09-30" }
projects: [ atlas, delta ]
columns: [ person, hours ]
hoursFormat: decimal
# A month nobody logged time in: the empty state.
- name: quiet
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body:
spreadsheetId: sheet-2026
period: { from: "2026-07-01", to: "2026-07-31" }
columns: [ hours ]
hoursFormat: decimal
- name: verifyHours
invoke: { kind: Assert.Equals }
inputs:
actual:
hours: !cel "steps.hours.result.body"
quiet: !cel "steps.quiet.result.body"
expected:
hours:
kind: hours
period: 2026-09-01 – 2026-09-30
projectsInPeriod: 1
skipped: 1
files:
- projectId: atlas
project: Atlas
email: billing@atlas.example.com
hours: "42.5"
pdf: https://drive.example.com/reports/sheet-2026/2026-09/atlas.pdf
unpriced: []
unassigned: [ { task: Weekly sync, hours: 2 } ]
noCurrency: []
projectsLink: /projects?sheet=sheet-2026
quiet:
kind: hours
period: 2026-07-01 – 2026-07-31
projectsInPeriod: 0
skipped: 4
files: []
unpriced: []
unassigned: []
noCurrency: []
projectsLink: /projects?sheet=sheet-2026
# Refusals the failure tree draws: a closed sheet, an unknown one, a period
# that ends before it starts and a record the route's schema refuses — the
# last two as the request-validation envelope, which marks the field.
- name: closed
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body: { spreadsheetId: sheet-2025, period: { from: "2025-09-01", to: "2025-09-30" }, columns: [ hours ], hoursFormat: decimal }
- name: unknown
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body: { spreadsheetId: sheet-1999, period: { from: "2026-09-01", to: "2026-09-30" }, columns: [ hours ], hoursFormat: decimal }
- name: reversed
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body: { spreadsheetId: sheet-2026, period: { from: "2026-09-30", to: "2026-09-01" }, columns: [ hours ], hoursFormat: decimal }
- name: noColumns
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/generate, method: POST }
inputs:
body: { spreadsheetId: sheet-2026, period: { from: "2026-09-01", to: "2026-09-30" }, columns: [], hoursFormat: decimal }
- name: verifyRefusals
invoke: { kind: Assert.Equals }
inputs:
actual:
closed: [ !cel "steps.closed.result.status", !cel "steps.closed.result.body" ]
unknown: [ !cel "steps.unknown.result.status", !cel "steps.unknown.result.body" ]
reversed: [ !cel "steps.reversed.result.status", !cel "steps.reversed.result.body" ]
noColumns: [ !cel "steps.noColumns.result.status", !cel "steps.noColumns.result.body.details.map(d, [d.location, d.path])" ]
expected:
closed: [ 409, { code: SHEET_CLOSED, message: The timesheet 'Hours 2025' is closed for reporting. } ]
unknown: [ 404, { code: SHEET_UNKNOWN, message: There is no timesheet 'sheet-1999'. } ]
reversed:
- 400
- error: ValidationError
message: Request validation failed
status: 400
details: [ { location: body, path: period, message: must not start after it ends } ]
noColumns: [ 400, [ [ body, columns ] ] ]
# A result row's two actions, and the projects table's.
- name: draft
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/draft, method: POST }
inputs:
body: { email: billing@atlas.example.com, pdf: "https://drive.example.com/reports/sheet-2026/2026-09/atlas.pdf", project: Atlas }
- name: upload
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/upload, method: POST }
inputs:
body: { pdf: "https://drive.example.com/reports/sheet-2026/2026-09/atlas.pdf", folder: folder-invoices, from: "2026-09-01", to: "2026-09-30" }
- name: uploadNowhere
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/upload, method: POST }
inputs:
body: { pdf: "https://drive.example.com/reports/sheet-2026/2026-09/atlas.pdf", folder: sheet-2026, from: "2026-09-01", to: "2026-09-30" }
- name: remind
invoke: { kind: HttpClient.Request, client: !ref signedIn, url: /api/projects/remind, method: POST }
inputs:
body: { projectId: atlas, email: billing@atlas.example.com, sheet: sheet-2026 }
- name: verifyRowActions
invoke: { kind: Assert.Equals }
inputs:
actual:
draft: [ !cel "steps.draft.result.status", !cel "steps.draft.result.body" ]
upload: [ !cel "steps.upload.result.status", !cel "steps.upload.result.body" ]
uploadNowhere: [ !cel "steps.uploadNowhere.result.status", !cel "steps.uploadNowhere.result.body" ]
remind: [ !cel "steps.remind.result.status", !cel "steps.remind.result.body" ]
expected:
draft:
- 201
- url: https://mail.example.com/drafts/new?to=billing%40atlas.example.com&attach=https%3A%2F%2Fdrive.example.com%2Freports%2Fsheet-2026%2F2026-09%2Fatlas.pdf
upload:
- 201
- link: https://drive.example.com/folder-invoices/2026-09-01_2026-09-30/atlas.pdf
target: Invoices 2026 / 2026-09-01 – 2026-09-30
uploadNowhere: [ 404, { code: FOLDER_UNKNOWN, message: There is no Drive folder 'sheet-2026'. } ]
remind: [ 200, { sentTo: billing@atlas.example.com } ]
ReportConsoleScreenTest — Runs examples/report-console and reads what its screen is given: the application document in English and in Polish, the report page — the sign-in gate and its conditions, the account reader, the report form with its pickers, date range, project choice, columns, toggles, groups, kept values and enablement, and the answer it draws: plural headings, warning boxes, the files list with its billing-only columns and its draft and upload row actions, the failure box — and the projects page's table with its query and row action. The routes these call are asserted by answers-every-route.yaml.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/report-console/tests/serves-the-console.yaml
Show serves-the-console.yaml
kind: Telo.Application
metadata:
name: ReportConsoleScreenTest
version: 1.0.0
description: >-
Runs `examples/report-console` and reads what its screen is given: the
application document in English and in Polish, the report page — the
sign-in gate and its conditions, the account reader, the report form with
its pickers, date range, project choice, columns, toggles, groups, kept
values and enablement, and the answer it draws: plural headings, warning
boxes, the files list with its billing-only columns and its draft and
upload row actions, the failure box — and the projects page's table with
its query and row action. The routes these call are asserted by
`answers-every-route.yaml`.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8141
targets:
- !ref app
steps:
- name: app
invoke: { kind: HttpClient.Request, url: "http://127.0.0.1:8141/_telo/ui/app" }
- name: polishApp
invoke: { kind: HttpClient.Request, url: "http://127.0.0.1:8141/_telo/ui/app" }
inputs:
headers: { accept-language: "pl-PL, en;q=0.5" }
- name: page
invoke: { kind: HttpClient.Request, url: "http://127.0.0.1:8141/_telo/ui/page?path=/" }
- name: polishPage
invoke: { kind: HttpClient.Request, url: "http://127.0.0.1:8141/_telo/ui/page?path=/" }
inputs:
headers: { accept-language: pl }
- name: projectsPage
invoke: { kind: HttpClient.Request, url: "http://127.0.0.1:8141/_telo/ui/page?path=/projects" }
# The nodes the checks below read, by name.
- name: nodes
value:
gate: !cel "steps.page.result.body.children[1]"
connect: !cel "steps.page.result.body.children[1].content.children[2].children[1]"
account: !cel "steps.page.result.body.children[1].content.children[3].children[0]"
form: !cel "steps.page.result.body.children[1].content.children[3].children[1]"
polishForm: !cel "steps.polishPage.result.body.children[1].content.children[3].children[1]"
files: !cel "steps.page.result.body.children[1].content.children[3].children[1].result.children[9]"
table: !cel "steps.projectsPage.result.body.children[1].content.children[1]"
# The application: two pages, the stylesheet beside the theme, and a
# language read from Accept-Language.
- name: verifyApp
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.app.result.status"
lang: !cel "steps.app.result.body.lang"
title: !cel "steps.app.result.body.title"
pages: !cel "steps.app.result.body.pages"
stylesheet: !cel "steps.app.result.body.stylesheets[size(steps.app.result.body.stylesheets) - 1].endsWith('/console.css')"
vary: !cel "steps.app.result.headers['vary']"
expected:
status: 200
lang: en
title: { lang: en, message: [ Reports ] }
pages:
- { path: /, title: { lang: en, message: [ Reports ] } }
- { path: /projects, title: { lang: en, message: [ Projects ] } }
stylesheet: true
vary: Accept-Language
# In Polish: the application's texts, its models' titles and enumeration
# labels, a plural heading over the answer and the renderer's own words.
- name: verifyPolish
invoke: { kind: Assert.Equals }
inputs:
actual:
lang: !cel "steps.polishApp.result.body.lang"
title: !cel "steps.polishApp.result.body.title"
save: !cel "steps.polishApp.result.body.words.save"
longerThanMonths: !cel "steps.polishApp.result.body.words.rangeLongerThanMonths"
heading: !cel "steps.polishPage.result.body.children[0].text"
sheetLabel: !cel "steps.nodes.result.polishForm.fields[0].label"
hoursAs: !cel "steps.nodes.result.polishForm.fields[5].labels"
group: !cel "steps.nodes.result.polishForm.groups[0].label"
found: !cel "steps.nodes.result.polishForm.result.children[0].text"
expected:
lang: pl
title: { lang: pl, message: [ Raporty ] }
save: { lang: pl, message: [ Zapisz ] }
longerThanMonths:
lang: pl
message:
plural: count
cases:
one: [ "Dłużej niż ", { placeholder: count }, " miesiąc" ]
few: [ "Dłużej niż ", { placeholder: count }, " miesiące" ]
many: [ "Dłużej niż ", { placeholder: count }, " miesięcy" ]
other: [ "Dłużej niż ", { placeholder: count }, " miesiąca" ]
heading: { lang: pl, message: [ Raporty miesięczne ] }
sheetLabel: { lang: pl, message: [ Arkusz czasu pracy ] }
hoursAs:
- { value: decimal, label: { lang: pl, message: [ "Ułamek dziesiętny (7,5)" ] } }
- { value: clock, label: { lang: pl, message: [ "Godziny i minuty (7:30)" ] } }
group: { lang: pl, message: [ Źródło ] }
found:
lang: pl
message:
plural: count
cases:
one: [ { placeholder: count }, " projekt z zalogowanym czasem, ", { placeholder: period } ]
few: [ { placeholder: count }, " projekty z zalogowanym czasem, ", { placeholder: period } ]
many: [ { placeholder: count }, " projektów z zalogowanym czasem, ", { placeholder: period } ]
other: [ { placeholder: count }, " projektu z zalogowanym czasem, ", { placeholder: period } ]
values:
count: { root: result, path: [ projectsInPeriod ] }
period: { root: result, path: [ period ] }
# The sign-in gate: a reader over /api/auth with the page's `signin`
# parameter, read again on focus and on any 401 inside it, drawing the
# cancelled and expired notices, the banner or the console by its answer.
- name: verifyGate
invoke: { kind: Assert.Equals }
inputs:
actual:
type: !cel "steps.nodes.result.gate.type"
source: !cel "steps.nodes.result.gate.source"
query: !cel "steps.nodes.result.gate.query"
reloadOn: !cel "steps.nodes.result.gate.reloadOn"
loading: !cel "steps.nodes.result.gate.loading"
branches: !cel "steps.nodes.result.gate.content.children.map(c, [c.type, c.shown])"
connect:
path: !cel "steps.nodes.result.connect.path"
label: !cel "steps.nodes.result.connect.label"
onSuccess: !cel "steps.nodes.result.connect.onSuccess"
account:
type: !cel "steps.nodes.result.account.type"
source: !cel "steps.nodes.result.account.source"
email: !cel "steps.nodes.result.account.content.children[0].value"
signOut:
path: !cel "steps.nodes.result.account.content.children[1].path"
onSuccess: !cel "steps.nodes.result.account.content.children[1].onSuccess"
expected:
type: data
source: { path: /api/auth, params: {} }
query:
signin: { type: string, enum: [ denied, expired ] }
reloadOn: [ focus, unauthorized ]
loading: { type: text, text: { lang: en, message: [ Checking your sign-in… ] }, style: muted }
branches:
- [ text, { at: { root: query, path: [ signin ] }, matches: { const: denied } } ]
- [ text, { at: { root: data, path: [ failure ] }, matches: { const: expired } } ]
- [ stack, { at: { root: data, path: [ authorized ] }, matches: { const: false } } ]
- [ stack, { at: { root: data, path: [ authorized ] }, matches: { const: true } } ]
connect:
path: /api/auth/connect
label: { lang: en, message: [ Connect Google ] }
onSuccess: { navigate: { root: result, path: [ url ] }, refreshes: [] }
account:
type: data
source: { path: /api/auth/user, params: {} }
email: { root: data, path: [ email ] }
signOut:
path: /api/auth/signout
onSuccess: { refreshes: [ /api/auth ] }
# The report form: its fields by kind and group, each option source, the
# date range, the columns' fixed order and default, the remembered sheet
# and folder, the sections shown once a sheet is chosen, and when the
# button can be pressed.
- name: verifyForm
invoke: { kind: Assert.Equals }
inputs:
actual:
path: !cel "steps.nodes.result.form.path"
label: !cel "steps.nodes.result.form.label"
fields: !cel "steps.nodes.result.form.fields.map(f, [f.type, f.property, f.group, 'control' in f ? f.control : '-'])"
hints: !cel "steps.nodes.result.form.fields.map(f, f.description == null ? '-' : f.description.message[0])"
sources: !cel >-
steps.nodes.result.form.fields.filter(f, 'options' in f).map(f, {
'basePath': f.options.basePath, 'filters': f.options.filters, 'pageSize': f.options.pageSize,
'value': f.options.value, 'label': f.options.label,
'hint': 'hint' in f.options ? f.options.hint : null,
'href': 'href' in f.options ? f.options.href : null,
'search': 'search' in f.options ? f.options.search : null})
surfaces: !cel "steps.nodes.result.form.fields.filter(f, 'surface' in f).map(f, f.surface.type)"
period: !cel >-
steps.nodes.result.form.fields.filter(f, f.type == 'dateRange').map(f, {
'presets': f.presets.map(p, [p.label.message[0], p.unit, p.offset]),
'weekStart': f.weekStart, 'initialPreset': f.initialPreset, 'warnLongerThan': f.warnLongerThan})
columns:
order: !cel "steps.nodes.result.form.fields[4].labels.map(l, l.value)"
default: !cel "steps.nodes.result.form.schema.properties.columns.default"
groups: !cel "steps.nodes.result.form.groups.map(g, [g.name, g.label.message[0], 'shown' in g ? g.shown : null])"
state: !cel "steps.nodes.result.form.state"
enabled: !cel "steps.nodes.result.form.enabled"
clearAnswerOn: !cel "steps.nodes.result.form.clearAnswerOn"
concurrency: !cel "steps.nodes.result.form.concurrency"
expected:
path: /api/generate
label: { lang: en, message: [ Generate ] }
fields:
- [ picker, spreadsheetId, source, "-" ]
- [ picker, folder, source, "-" ]
- [ dateRange, period, period, "-" ]
- [ choice, projects, content, options ]
- [ input, columns, content, auto ]
- [ input, hoursFormat, output, options ]
- [ input, merge, output, toggle ]
- [ input, chart, output, toggle ]
hints:
- "-"
- Where Upload puts a report.
- "-"
- None chosen means every project of the sheet.
- Always in this order.
- "-"
- Merge every project into one PDF.
- Add a chart of hours per person.
sources:
- basePath: /api/drive/files
filters: { kind: { value: spreadsheet } }
pageSize: 25
value: &rowId { root: row, path: [ id ] }
label: &rowName { root: row, path: [ name ] }
hint: { root: row, path: [ owner ] }
href: &rowUrl { root: row, path: [ url ] }
search: &byName { property: name, operator: contains }
- basePath: /api/drive/files
filters: { kind: { value: folder } }
pageSize: 25
value: *rowId
label: *rowName
hint: null
href: *rowUrl
search: *byName
- basePath: /api/projects
filters: { spreadsheetId: { root: values, path: [ spreadsheetId ] } }
pageSize: 25
value: *rowId
label: *rowName
hint: { root: row, path: [ email ] }
href: null
search: null
surfaces: [ popover, dialog ]
period:
- presets:
- [ This month, month, 0 ]
- [ Last month, month, -1 ]
- [ Last week, week, -1 ]
- [ This quarter, quarter, 0 ]
weekStart: monday
initialPreset: 1
warnLongerThan: { unit: month, count: 1 }
columns:
order: [ date, person, task, hours, amount ]
default: [ date, person, hours ]
groups:
- [ source, Source, null ]
- [ period, Period, &sheetChosen { at: { root: values, path: [ spreadsheetId ] }, matches: { minLength: 1 } } ]
- [ content, Content, *sheetChosen ]
- [ output, Output, *sheetChosen ]
state: { key: report, address: false, store: { type: local }, fields: [ spreadsheetId, folder ] }
enabled:
at: { root: values, path: [ columns ] }
matches: { minItems: 1 }
disabledText: { lang: en, message: [ Choose at least one column. ] }
clearAnswerOn: [ spreadsheetId, period, projects, columns ]
concurrency: parallel
# The answer: headings and notices over its counts, the three warning boxes
# only when they hold something, the merged file and chart only when
# produced, and the failure box.
- name: verifyAnswer
invoke: { kind: Assert.Equals }
inputs:
actual:
drawn: !cel "steps.nodes.result.form.result.children.map(c, [c.type, 'style' in c ? c.style : '-', 'shown' in c ? c.shown : null])"
skipped: !cel "steps.nodes.result.form.result.children[1].text"
warnings: !cel "steps.nodes.result.form.result.children.filter(c, c.type == 'box').map(c, [c.children[0].heading.message[0], c.children[0].rows])"
failure: !cel "steps.nodes.result.form.failure"
expected:
drawn:
- [ text, heading, { at: { root: result, path: [ projectsInPeriod ] }, matches: { minimum: 1 } } ]
- [ text, muted, { at: { root: result, path: [ skipped ] }, matches: { minimum: 1 } } ]
- [ text, "-", { at: { root: result, path: [ projectsInPeriod ] }, matches: { const: 0 } } ]
- [ text, "-", { at: { root: result, path: [] }, matches: { properties: { files: { maxItems: 0 }, projectsInPeriod: { minimum: 1 } } } } ]
- [ box, danger, { at: { root: result, path: [ unpriced ] }, matches: { minItems: 1 } } ]
- [ box, danger, { at: { root: result, path: [ unassigned ] }, matches: { minItems: 1 } } ]
- [ box, danger, { at: { root: result, path: [ noCurrency ] }, matches: { minItems: 1 } } ]
- [ value, "-", { at: { root: result, path: [] }, matches: { required: [ combined ] } } ]
- [ value, "-", { at: { root: result, path: [] }, matches: { required: [ chart ] } } ]
- [ list, "-", { at: { root: result, path: [ files ] }, matches: { minItems: 1 } } ]
- [ value, "-", null ]
skipped:
lang: en
message:
plural: count
cases:
one: [ { placeholder: count }, " chosen project has no time in the period and was skipped." ]
other: [ { placeholder: count }, " chosen projects have no time in the period and were skipped." ]
values:
count: { root: result, path: [ skipped ] }
warnings:
- [ Hours without a rate, { root: result, path: [ unpriced ] } ]
- [ Hours on no project, { root: result, path: [ unassigned ] } ]
- [ Projects with no currency, { root: result, path: [ noCurrency ] } ]
failure:
type: stack
style: danger
children:
- type: text
style: strong
text:
lang: en
message: [ "HTTP ", { placeholder: status }, " · ", { placeholder: code } ]
values:
status: { root: error, path: [ status ] }
code: { root: error, path: [ code ] }
shown: { at: { root: error, path: [] }, matches: { required: [ status ] } }
- type: text
style: strong
text:
lang: en
message: [ "No answer · ", { placeholder: code } ]
values:
code: { root: error, path: [ code ] }
shown: { at: { root: error, path: [] }, matches: { not: { required: [ status ] } } }
- { type: value, value: { root: error, path: [ message ] }, present: { type: string }, labels: [] }
# The files list: an amount and a currency only on a billing report, the
# PDF as a link, and on each row a draft that needs the project's address
# and an upload that reads the folder chosen now and the period sent.
- name: verifyFiles
invoke: { kind: Assert.Equals }
inputs:
actual:
rows: !cel "steps.nodes.result.files.rows"
columns: !cel "steps.nodes.result.files.columns.map(c, [c.header.message[0], c.value, c.present, 'shown' in c ? c.shown : null])"
rowActions: !cel >-
steps.nodes.result.files.rowActions.map(r, {
'path': r.action.path, 'label': r.action.label.message[0], 'concurrency': r.action.concurrency,
'inputs': r.inputs, 'enabled': r.enabled, 'result': r.action.result})
expected:
rows: { root: result, path: [ files ] }
columns:
- [ Project, { root: row, path: [ project ] }, { type: string }, null ]
- [ E-mail, { root: row, path: [ email ] }, { type: string }, null ]
- [ Hours, { root: row, path: [ hours ] }, { type: string }, null ]
- [ Amount, { root: row, path: [ amount ] }, { type: number }, &billing { at: { root: result, path: [ kind ] }, matches: { const: billing } } ]
- [ Currency, { root: row, path: [ currency ] }, { type: string }, *billing ]
- [ PDF, { root: row, path: [ pdf ] }, { type: string, format: uri-reference }, null ]
rowActions:
- path: /api/draft
label: Draft e-mail
concurrency: parallel
inputs:
email: { root: row, path: [ email ] }
pdf: { root: row, path: [ pdf ] }
project: { root: row, path: [ project ] }
enabled:
at: { root: row, path: [ email ] }
matches: { type: string, minLength: 1 }
disabledText: { lang: en, message: [ The project has no billing e-mail. ] }
result:
type: value
value: { root: result, path: [ url ] }
text: { lang: en, message: [ Open the draft ] }
present: { type: string, format: uri }
labels: []
- path: /api/upload
label: Upload
concurrency: serial
inputs:
pdf: { root: row, path: [ pdf ] }
folder: { root: values, path: [ folder ] }
from: { root: input, path: [ period, from ] }
to: { root: input, path: [ period, to ] }
enabled:
at: { root: values, path: [ folder ] }
matches: { minLength: 1 }
disabledText: { lang: en, message: [ Choose an upload folder first. ] }
result:
type: value
value: { root: result, path: [ link ] }
text:
lang: en
message: [ "Uploaded to ", { placeholder: target } ]
values:
target: { root: result, path: [ target ] }
present: { type: string, format: uri }
labels: []
# The projects page: a table over the sheet the address names, offering a
# reminder on every row that has an address.
- name: verifyProjectsPage
invoke: { kind: Assert.Equals }
inputs:
actual:
nodes: !cel "steps.projectsPage.result.body.children.map(c, c.type)"
table:
type: !cel "steps.nodes.result.table.type"
shown: !cel "steps.nodes.result.table.shown"
basePath: !cel "steps.nodes.result.table.basePath"
query: !cel "steps.nodes.result.table.query"
filters: !cel "steps.nodes.result.table.filters"
remind:
path: !cel "steps.nodes.result.table.rowActions[0].action.path"
inputs: !cel "steps.nodes.result.table.rowActions[0].inputs"
confirm: !cel "steps.nodes.result.table.rowActions[0].confirm"
enabled: !cel "steps.nodes.result.table.rowActions[0].enabled"
expected:
nodes: [ text, data ]
table:
type: table
shown: { at: { root: data, path: [ authorized ] }, matches: { const: true } }
basePath: /api/projects
query:
sheet: { type: string, minLength: 1 }
filters:
spreadsheetId: { root: query, path: [ sheet ] }
remind:
path: /api/projects/remind
inputs:
projectId: { root: row, path: [ id ] }
email: { root: row, path: [ email ] }
sheet: { root: query, path: [ sheet ] }
confirm: { lang: en, message: [ Remind the client to log this month's time? ] }
enabled:
at: { root: row, path: [ email ] }
matches: { minLength: 1 }
disabledText: { lang: en, message: [ The project has no billing e-mail. ] }
Visualization
PdfChartReport
A quarterly sales report: two charts and a table, laid out as a PDF and written to disk. Six resources, no script.
The rows are read once and drawn three ways — a donut of revenue by region, a bar chart of the same rows split by channel, and a table — because a chart takes flat rows and maps them with expressions, so the same result set feeds all of them without being reshaped in between.
pnpm run telo examples/pdf-chart-report/telo.yaml
open ./reports/sales-2026-Q1.pdf
ONE Font.Family is declared here, and both the charts and the document
reference it. That is the point worth copying: the charts measure their
legends and labels against the typeface the PDF actually embeds, so the
layout is computed for the type that renders. Declared twice — once for the
chart and once for the document — the two are separate strings that silently
disagree, and the only symptom is labels that clip.
Imports are relative because these modules are not published yet; they become
pinned oci:// refs like every other example once they are.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/pdf-chart-report/telo.yaml
Open in Telo Studio → · View pdf-chart-report/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
PdfChartReportExampleTest — Runs examples/pdf-chart-report to completion. It renders a chart, lays out a PDF around it and writes the file — a chain of four modules where a break anywhere shows up as a failed run rather than a wrong pixel, which is exactly what a run-to-completion check is good for.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/pdf-chart-report/tests/writes-a-report.yaml
Show writes-a-report.yaml
kind: Telo.Application
metadata:
name: PdfChartReportExampleTest
version: 1.0.0
description: >-
Runs `examples/pdf-chart-report` to completion. It renders a chart, lays out a
PDF around it and writes the file — a chain of four modules where a break
anywhere shows up as a failed run rather than a wrong pixel, which is exactly
what a run-to-completion check is good for.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
More examples
ConfigurableHttpServer
HTTP server demonstrating how application-level variables (env-bound,
non-secret) and secrets flow into a JavaScript.Script handler at
runtime. The handler echoes the configured upstream URL and a masked
hint of the secret so you can verify both values made it end-to-end.
Set the inputs in a sibling .env.local:
API_BASE_URL=https://api.example.com
STRIPE_SECRET_KEY=sk_test_demo_value
Run, then:
curl -X POST http://localhost:4444/v1/charge
API_BASE_URL='https://api.example.com' STRIPE_SECRET_KEY='sk_test_your_stripe_key' telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/configurable-http-server/telo.yaml
Open in Telo Studio → · View configurable-http-server/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
ConfigurableHttpServerExampleTest — Runs examples/configurable-http-server with configuration this test supplies and checks that the values reached the handler. The example is about configuration arriving from outside the manifest, so the assertion is that what was handed in is what comes back — with the secret still hinted rather than echoed.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/configurable-http-server/tests/reads-its-configuration.yaml
Show reads-its-configuration.yaml
kind: Telo.Application
metadata:
name: ConfigurableHttpServerExampleTest
version: 1.0.0
description: >-
Runs `examples/configurable-http-server` with configuration this test supplies
and checks that the values reached the handler. The example is about
configuration arriving from outside the manifest, so the assertion is that what
was handed in is what comes back — with the secret still hinted rather than
echoed.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 4444
variables:
apiBaseUrl: https://api.example.test
secrets:
stripeSecretKey: sk_test_0000000000001234
targets:
- !ref app
steps:
- name: charge
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:4444/v1/charge
method: POST
inputs:
body: {}
- name: verify
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.charge.result.status"
body: !cel "steps.charge.result.body"
expected:
status: 200
body:
ok: true
upstream: https://api.example.test
keyHint: "sk_t…1234"
ConsoleInputExample
Reads two lines from stdin (username, password) and echoes them back via Console.writeLine.
Each step's result.value is wired into a later step's inputs via a
steps.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/console-user-input/telo.yaml
Open in Telo Studio → · View console-user-input/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
ConsoleUserInputExampleTest — Answers examples/console-user-input the way a person would: wait for the prompt, type, wait for the next one, type again, and check what it echoed back. The second prompt only exists once the first is answered, so this is the shape the example cannot be tested in any other way.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/console-user-input/tests/reads-two-lines.yaml
Show reads-two-lines.yaml
kind: Telo.Application
metadata:
name: ConsoleUserInputExampleTest
version: 1.0.0
description: >-
Answers `examples/console-user-input` the way a person would: wait for the
prompt, type, wait for the next one, type again, and check what it echoed back.
The second prompt only exists once the first is answered, so this is the shape
the example cannot be tested in any other way.
imports:
App: ../../../modules/app
Channel: ../../../modules/channel
Run: ../../../modules/run
Assert: ../../../modules/assert
targets:
- !ref app
- !ref test
---
kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
# It blocks on its first prompt, so its targets never all finish dispatching.
awaitStart: false
---
kind: Channel.ReadUntil
metadata: { name: read }
channel: !ref app
timeout: "10s"
---
kind: Channel.SendLine
metadata: { name: send }
channel: !ref app
---
kind: Run.Sequence
metadata: { name: test }
steps:
- name: usernamePrompt
invoke: !ref read
inputs: { until: "Username: " }
- name: sendUsername
invoke: !ref send
inputs: { text: Ada }
- name: passwordPrompt
invoke: !ref read
inputs: { until: "Password: " }
- name: sendPassword
invoke: !ref send
inputs: { text: secret }
- name: echoed
invoke: !ref read
inputs: { until: "\n" }
- name: verify
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.echoed.result.text"
expected: "Username: Ada, Password: secret\n"
HelloApiExample
Simplest possible HTTP API: a single GET /v1/hello?name=… route backed
by a typed Run.Value. Shows how Http.Server mounts an Http.Api, how
the request schema feeds a CEL expression on inputs, and how the
handler's output flows into the response body via result.*.
Run, then:
curl 'http://localhost:8845/v1/hello?name=World'
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/hello-api/telo.yaml
Open in Telo Studio → · View hello-api/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
HelloApiExampleTest — Runs examples/hello-api — the file a reader copies, unmodified — on a port of this test's own, and calls the route its README documents.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/hello-api/tests/serves-a-greeting.yaml
Show serves-a-greeting.yaml
kind: Telo.Application
metadata:
name: HelloApiExampleTest
version: 1.0.0
description: >-
Runs `examples/hello-api` — the file a reader copies, unmodified — on a port of
this test's own, and calls the route its README documents.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8845
targets:
- !ref app
steps:
- name: greet
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8845/v1/hello?name=World
method: GET
- name: verifyGreeting
invoke: { kind: Assert.Equals }
inputs:
actual:
status: !cel "steps.greet.result.status"
body: !cel "steps.greet.result.body"
expected:
status: 200
body:
greeting: "Hello World!!"
nice: WOW
# The route declares `name` as required, so a request without it is refused
# before the handler runs.
- name: missingName
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8845/v1/hello
method: GET
- name: verifyRejected
invoke: { kind: Assert.Equals }
inputs:
actual: !cel "steps.missingName.result.status"
expected: 400
JavaScriptDemo
A demo showcasing a JavaScript script run from flat targets.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/javascript-demo/telo.yaml
Open in Telo Studio → · View javascript-demo/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
JavaScriptDemoExampleTest — Runs examples/javascript-demo to completion and checks the value it computed.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/javascript-demo/tests/evaluates-inline-javascript.yaml
Show evaluates-inline-javascript.yaml
kind: Telo.Application
metadata:
name: JavaScriptDemoExampleTest
version: 1.0.0
description: >-
Runs `examples/javascript-demo` to completion and checks the value it computed.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
stdout: "Evaluated JavaScript code.\nResult is: 84\n\n"
StarlarkDemo
A demo showcasing a Starlark script run from flat targets.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/starlark-basic/telo.yaml
Open in Telo Studio → · View starlark-basic/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
StarlarkBasicExampleTest — Runs examples/starlark-basic to completion and checks the value it computed.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/starlark-basic/tests/evaluates-starlark.yaml
Show evaluates-starlark.yaml
kind: Telo.Application
metadata:
name: StarlarkBasicExampleTest
version: 1.0.0
description: >-
Runs `examples/starlark-basic` to completion and checks the value it computed.
imports:
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Assert.Manifest
metadata: { name: test }
source: ../telo.yaml
expect:
runs: true
stdout: "Evaluated starlark code.\nResult is: 84\n\n"
TemplatedApiUsage
Imports library.yaml as a Telo.Library and mounts its
helloApi twice on the same Http.Server — under /v1 and /v2.
Shows that a single library-defined kind can be reused across multiple
mount points without duplicating the route definition.
Run, then:
curl 'http://localhost:8845/v1/hello?name=World'
curl 'http://localhost:8845/v2/hello?name=World'
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/templated-api/telo.yaml
Open in Telo Studio → · View templated-api/telo.yaml on GitHub →
Tests — each test is an application of its own, runnable by URL like the example:
TemplatedApiExampleTest — Runs examples/templated-api and calls both mounts. One library-exported route mounted at two paths is the whole point of the example, so both are asserted — a mount that quietly stopped serving would otherwise show up as a 404 nobody looks for.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/templated-api/tests/serves-both-mounts.yaml
Show serves-both-mounts.yaml
kind: Telo.Application
metadata:
name: TemplatedApiExampleTest
version: 1.0.0
description: >-
Runs `examples/templated-api` and calls both mounts. One library-exported route
mounted at two paths is the whole point of the example, so both are asserted —
a mount that quietly stopped serving would otherwise show up as a 404 nobody
looks for.
imports:
App: ../../../modules/app
Run: ../../../modules/run
HttpClient: ../../../modules/http-client
Assert: ../../../modules/assert
targets:
- !ref test
---
kind: Run.Sequence
metadata: { name: test }
with:
- kind: App.Instance
metadata: { name: app }
source: ../telo.yaml
ports:
http: 8846
targets:
- !ref app
steps:
- name: v1
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8846/v1/hello?name=World
method: GET
- name: v2
invoke:
kind: HttpClient.Request
url: http://127.0.0.1:8846/v2/hello?name=World
method: GET
- name: verifyBothMounts
invoke: { kind: Assert.Equals }
inputs:
actual:
v1Status: !cel "steps.v1.result.status"
v1Body: !cel "steps.v1.result.body"
v2Status: !cel "steps.v2.result.status"
v2Body: !cel "steps.v2.result.body"
expected:
v1Status: 200
v1Body:
greeting: "Hello World!!"
nice: WOW
v2Status: 200
v2Body:
greeting: "Hello World!!"
nice: WOW
AWS Lambda
Lambda-specific recipes. See the AWS Lambda example README for the deployment walkthrough.
LambdaDirectExample
Lambda.Direct example: a synchronous AWS Lambda that receives a raw
event payload, maps it via CEL into a JS handler, and returns the
handler's result. Use this shape for SDK invokes, Step Functions
tasks, EventBridge Scheduler events, or any catch-all trigger that
isn't HTTP API or SQS. See examples/aws/lambda/README.md for the
full packaging + deploy walkthrough.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/aws/lambda/direct.yaml
Open in Telo Studio → · View aws/lambda/direct.yaml on GitHub →
LambdaHttpApiExample
Lambda.HttpApi example: an AWS Lambda fronted by API Gateway HTTP API
v2. Declares two routes (GET /users/{id}, POST /orders) plus a CORS
block and a per-route catches: mapping that turns a thrown
ValidationError into a 400 with a structured body. See
examples/aws/lambda/README.md for the full packaging + deploy walkthrough.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/aws/lambda/http-api.yaml
Open in Telo Studio → · View aws/lambda/http-api.yaml on GitHub →
LambdaMultiKindExample
Multi-kind AWS Lambda example: one Lambda artifact (one ARN, one IAM
role) wired up to three event sources at once — API Gateway HTTP API,
SQS event-source mapping, and direct SDK invokes. Telo dispatches each
incoming event to the matching Lambda.HttpApi / Lambda.Sqs /
Lambda.Direct handler based on the event shape. See
examples/aws/lambda/README.md for the full packaging + deploy walkthrough.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/aws/lambda/multi-kind.yaml
Open in Telo Studio → · View aws/lambda/multi-kind.yaml on GitHub →
LambdaSqsExample
Lambda.Sqs example: an AWS Lambda triggered by an SQS event-source
mapping. The handler processes the message batch and uses
partialBatchResponse: true to report per-message failures via the
batchItemFailures shape, so AWS only retries the ones that actually
failed. See examples/aws/lambda/README.md for the full packaging +
deploy walkthrough.
telo https://raw.githubusercontent.com/telorun/telo/refs/heads/main/examples/aws/lambda/sqs.yaml
Open in Telo Studio → · View aws/lambda/sqs.yaml on GitHub →