Gotchas
Subtle behaviors and language limits worth knowing before they bite you. Each shows the wrong assumption and the right approach.
when is reserved
when is a control-flow keyword — you can't use it as a variable name.
// WRONG
set { when = true }
// RIGHT
set { flow.is_open = true }
Destructure rename is unsupported
When destructuring a subflow's outputs, bind each name as-is. Renaming (let { x: alias }) isn't supported.
// WRONG
let { result: total } = await format.money(amount: 9.5, symbol: "$")
// RIGHT
let { result } = await format.money(amount: 9.5, symbol: "$")
set { flow.total = result }
return only works inside a subflow
return produces a subflow's outputs — it's not valid in a flow. To finish a flow early, use stop; to branch, use when / else.
// WRONG — return in a flow
flow check on command "/check" {
when user.banned { return } // ❌ not allowed in a flow
send.text("Welcome!")
}
// RIGHT — stop / when in a flow
flow check on command "/check" {
when user.banned {
send.text("🚫 You're banned.")
stop
}
send.text("Welcome!")
}
// return is for subflow outputs
subflow is_open() -> { ok: Boolean } {
return { ok: true }
}
on error doesn't terminate the flow
An on error { } handler (on await subflow(...), http.*, send.*, library calls) runs when the call fails — but execution continues afterward. It does not stop the flow. If a later statement depends on the call having succeeded, guard it: set a flow.failed flag in the handler and check it.
// WRONG — runs the success path even after the error
let { temp } = await openweather.current_weather(city: flow.city, api_key: global.key) on error {
send.text("Couldn't fetch weather.")
}
send.text(`It's ${temp}°C`) // ❌ runs anyway, with no temp
// RIGHT — flag the failure and guard
set { flow.failed = false }
let { temp } = await openweather.current_weather(city: flow.city, api_key: global.key) on error {
set { flow.failed = true }
send.text("Couldn't fetch weather.")
}
when !flow.failed {
send.text(`It's ${temp}°C`)
}
To bail out entirely instead, put stop inside the handler.
A bare local assignment must call a function or node
You can introduce a bare local (no scope prefix) only when its value comes from a function or node call — for example ask.*, http.*, or a builtin. A plain expression assignment to a bare name isn't allowed; use a scoped set { flow.x = … } instead.
// WRONG — bare local from a plain expression
name = str.trim(flow.raw)
// RIGHT — bare local from a node/function call
raw = ask.text("Your name?", timeout: "60s")
// RIGHT — plain expressions go in a scoped set { }
set { flow.name = str.trim(raw) }
chat.mute uses until_date, and delete(message) removes a message
chat.mute takes an until_date (a unix timestamp), not a duration — add seconds to time.timestamp(). Pass 0 (or omit it) for an indefinite mute. To delete a message, call delete(message) — there is no chat.delete_message.
// WRONG
chat.mute(user_id: flow.uid, duration: "1h")
chat.delete_message(message_id: message.message_id)
// RIGHT
chat.mute(user_id: flow.uid, until_date: time.timestamp() + 3600) // 1 hour
delete(message)
${} takes simple references only
Only a variable or a dotted/indexed path goes inside ${}. No arithmetic, no function calls.
// WRONG
send.text(`You have ${util.len(user.todos)} todos`)
// RIGHT
set { flow.count = util.len(user.todos) }
send.text(`You have ${flow.count} todos`)
Cross-user state must be shared.*
user.* is private to one person. A leaderboard or ledger that everyone reads/writes must be shared.*. A schedule or webhook flow can read shared.* but cannot read another user's user.*.
// WRONG — other users can't see your leaderboard
user leaderboard: Array<Any> = []
// RIGHT
shared leaderboard: Array<{ uid: Number, score: Number }> = []
No nested in-place array mutation
You can't mutate an element of an array in place. Rebuild the array with array.reject + <<, or rebuild it whole with foreach.
// WRONG
set { shared.scores[0].points = 10 }
// RIGHT — upsert: drop then re-add
set {
shared.scores = array.reject(shared.scores, "uid", user.id)
shared.scores << [{ uid: user.id, points: 10 }]
}
Whole-array reassignment (set { shared.scores = flow.fresh }) is fine.
ask.* binds to a bare local, then branch
The result of ask.* is a local value. To handle timeout/cancel safely, put the ask.* directly in a branch; the answer is available as answer in the default arm.
// Pattern
branch ask.text("Name?", timeout: "60s") {
"timeout" { send.text("Timed out.") stop }
default { set { user.name = answer } }
}
No user while loop
There's no while. Iterate with foreach. For a counted loop, build a range:
// WRONG
while flow.i < 5 { /* … */ }
// RIGHT
foreach n in array.range(0, 5) {
send.text(`Step ${n}`)
}
foreach index is 0-based
The optional second name in foreach is the 0-based index. Add 1 for display positions.
foreach item, i in flow.ranked {
set { flow.pos = i + 1 } // 1, 2, 3, …
send.text(`${flow.pos}. ${item.name}`)
}
Scheduled & webhook flows have no chat
Neither has a Telegram chat in scope. Always pass chat_id to send.* — usually one you captured earlier into a shared.* variable, or parsed from a webhook payload.
flow daily on schedule "0 9 * * *" {
send.text("Morning!", chat_id: shared.group_chat_id)
}
ask.photo returns the photo (last size)
Telegram delivers a photo as multiple sizes; ask.photo gives you the photo and you generally use the largest. Don't assume it's a plain URL string.