Core Concepts
The on_event Function
The on_event function in your main .lua file is the heart of your application. It's the single entry point that receives and routes all events from the Turn platform. Common events include:
"install": Your app is being installed."uninstall": Your app is being uninstalled."upgrade": Your app is being upgraded from an older version. See App Upgrades for details."downgrade": Your app is being downgraded to an older version. See App Upgrades for details."config_changed": Your app's configuration has been updated. This is a good time to re-evaluate settings or subscriptions."contact_fields_changed": A contact was updated, and one or more of the changed fields are fields your app has subscribed to. Thedatatable will containuuid(the contact's UUID) andchanges(a list of{field, old_value, new_value}tuples)."http_request": Your app's public webhook URL received a request."journey_event": A Journey has called your app.
Your function will receive four arguments:
app: A table containing your app's instance configuration, including its unique UUID.number: A table with information about the number where the app is installed.event: A string identifying the event type.data: A table containing data specific to that event.
A core feature of apps is subscribing to changes on specific contact fields. You can set these subscriptions using the new turn.app API, typically in response to the install and config_changed events.
local App = {}
local turn = require("turn")
function App.on_event(app, number, event, data)
if event == "install" or event == "config_changed" then
-- On install or config change, (re)set the contact fields we want to watch.
-- This uses the new `turn.app` API.
turn.app.set_contact_subscriptions({"name", "surname", "age"})
return true
elseif event == "contact_fields_changed" then
-- React to specific, subscribed fields being updated on a contact.
local contact_uuid = data.uuid
local changes = data.changes -- A table of {field, old_value, new_value} tuples
local _updated_contact = data.contact -- The full contact with all the fields, including the changed ones
for _, change in ipairs(changes) do
local field, old_val, new_val = change[1], change[2], change[3]
turn.logger.info(
"Contact " .. contact_uuid .. " field '" .. field .. "' changed from '" ..
tostring(old_val) .. "' to '" .. tostring(new_val) .. "'"
)
end
return true
elseif event == "http_request" then
-- Handle an incoming HTTP request to your app's unique endpoint
return true, { status = 200, body = "Hello from my app!" }
else
-- It's good practice to handle unhandled events like `uninstall` or the general `contact_changed`.
turn.logger.warning("Received unhandled event: " .. event)
return false
end
end
return App
Integrating with Journeys
One of the most powerful features of Lua Apps is their ability to integrate directly with Journeys using the app() block. This allows you to add custom logic, calculations, or API calls right in the middle of a conversation.
Calling an App from a Journey
In your Journey you can call an app function like this:
card GetWeather do
# Calls the 'get_forecast' function in the 'weather_app'
weather_data = app("weather_app", "get_forecast", ["Cape Town"])
# The result is available in the 'weather_data' variable
text("The weather in Cape Town is: @(weather_data.result.temperature)°C")
end
This triggers a journey_event in your Lua app.
Handling a journey_event
Your app must handle the journey_event and can control the flow of the Journey by its return value.
Synchronous Flow: continue
For operations that complete instantly, return "continue" along with the result. The Journey will proceed to the next block without pausing.
- Use cases: Data validation, simple calculations, formatting text.
- Return signature:
return "continue", result_table
-- In your on_event function
elseif event == "journey_event" and data.function_name == "add" then
local sum = tonumber(data.args[1]) + tonumber(data.args[2])
-- The Journey continues immediately with the result
return "continue", { value = sum }
end
Asynchronous Flow: wait and turn.leases
For long-running tasks, like waiting for a payment confirmation webhook, you can tell the Journey to pause by returning "wait".
The Journey will remain paused until your app explicitly resumes it by sending data to its lease. A lease is a temporary hold on the Journey's state, identified by the chat_uuid.
- Use cases: Waiting for webhooks, human approvals, or timed delays.
- Return signature:
return "wait"
Example Workflow: Waiting for a Payment Webhook
-
Journey Initiates and Waits: The Journey calls your app, which initiates a payment and tells the Journey to wait.
-- journey_event handler
if data.function_name == "waitForPayment" then
-- The app might call an external payment API here
-- ...
-- Now, tell the Journey to pause
return "wait"
end -
External Webhook Arrives: Later, your payment provider sends a webhook to your app's HTTP endpoint. Your app's
http_requesthandler parses it. -
App Resumes the Journey: Inside the
http_requesthandler, you useturn.leases.send_input()with the originalchat_uuidto wake up the correct Journey and deliver the result.-- http_request handler
-- Assume you got the chat_uuid from the webhook's metadata
local chat_uuid = webhook_payload.metadata.chat_uuid
local result_data = {
payment_confirmed = true,
transaction_id = "txn_123"
}
turn.leases.send_input(chat_uuid, result_data)
The Journey receives the result_data and automatically resumes execution.
Lua apps can also expose skills—functions that AI agents call during conversations to retrieve data or perform actions. Skills use a simple LDoc format and have access to the full Turn Lua API. See AI Agent Skills for details.
Understanding the journey_event Data
The data parameter in your journey_event handler contains important context:
elseif event == "journey_event" then
-- data contains:
-- data.function_name - The function being called (e.g., "calculate", "fetch_data")
-- data.args - Array of arguments passed from the Journey (already evaluated)
-- data.chat_uuid - UUID of the chat (may be nil in simulator)
local function_name = data.function_name
local args = data.args or {}
local chat_uuid = data.chat_uuid -- Store this for async operations!
if function_name == "process_order" then
local order_id = args[1]
local amount = args[2]
-- Process and return result
return "continue", { order_id = order_id, status = "processed" }
end
end
Error Handling in journey_event
Proper error handling ensures your Journey can gracefully handle both expected and unexpected issues:
elseif event == "journey_event" then
local function_name = data.function_name
if function_name == "validate_input" then
local input = data.args[1]
-- Business logic error (expected, Journey continues)
if not input or input == "" then
return "continue", { valid = false, error = "Input cannot be empty" }
end
-- Success case
return "continue", { valid = true, processed_input = string.upper(input) }
elseif function_name == "external_api_call" then
-- System error (unexpected, Journey should handle error path)
local response, status = turn.http.request({
url = "https://api.example.com/data",
method = "GET"
})
if status ~= 200 then
return "error", "API call failed with status: " .. status
end
return "continue", turn.json.decode(response)
else
-- Unknown function
return "continue", {
success = false,
message = "Unknown function: " .. function_name
}
end
end