Skip to main content

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. The data table will contain uuid (the contact's UUID) and changes (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

  1. 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
  2. External Webhook Arrives: Later, your payment provider sends a webhook to your app's HTTP endpoint. Your app's http_request handler parses it.

  3. App Resumes the Journey: Inside the http_request handler, you use turn.leases.send_input() with the original chat_uuid to 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.

AI Agent Skills

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