Write an Automation

For a walkthrough see Tutorial: Create Your First Automation. This guide is the cookbook.

File shape

Automations live in /sdcard/atak/custos/automations/<name>.lua. Each file has a comment block of annotations and a top-level function run(ctx). The scheduler invokes run(ctx) deterministically in a sandboxed Lua state. Automations never call the LLM implicitly — if LLM reasoning is needed, the body must explicitly call tools.call("delegate", ...).

--- Human-readable description
-- @automation <unique_name>
-- @trigger <cron | duration | one-shot | broadcast-action>
-- @session MAIN | ISOLATED
-- @description <longer description — optional>

function run(ctx)
    -- Lua code. Invoke skills via tools.call(name, args).
end

Easiest way: ask the agent

The custos.automation skill has one tool — write_automation — that auto-detects the trigger type from the expression you pass:

Create an automation that every 15 seconds checks if hostile contacts are within
300 meters and plays an alarm if so.

Trigger shapes

Shape Type Example ctx param
Cron (5-field) schedule 0 7 * * * schedule
One-shot schedule in 20m schedule
Duration interval 30s, 15m, 2h interval
Broadcast action event com.atakmap.android.maps.COT_RECD event

Template: interval (duration)

--- 300m hostile sweep every 15 seconds
-- @automation perimeter_monitor
-- @trigger 15s
-- @session ISOLATED

function run(interval)
    local pos = tools.call("get_self_position", {})
    local result = tools.call("find_nearby", {
        lat = pos.lat, lon = pos.lon, radius_m = 300, affiliation = "hostile",
    })
    if result.count > 0 then
        tools.call("play_tone", { type = "alarm" })
    end
end

Template: schedule with LLM via delegate

--- Daily morning sitrep
-- @automation morning_sitrep
-- @trigger 0 6 * * *
-- @session MAIN

function run(schedule)
    local result = tools.call("delegate", {
        agent_name = "analyst",
        task = "Generate a comprehensive SITREP from the current tactical picture.",
    })
    tools.call("speak_alert", { message = result.response })
end

delegate is the only way an automation touches the LLM. This is intentional — it keeps small on-device models from being hammered by every tick. The agent_name must match an agent defined in custos.yaml exactly; the example above assumes an analyst agent has been configured.

Template: event with inline filter

--- Alert on hostile CoT
-- @automation hostile_alert
-- @trigger com.atakmap.android.maps.COT_RECD
-- @session ISOLATED

function run(event)
    if not (event.cot_type and event.cot_type:find("a-h-")) then return end
    tools.call("speak_alert", { message = "Hostile: " .. (event.callsign or "unknown") })
end

Session semantics

Session Behavior
MAIN Any delegate response flows into the operator's active chat
ISOLATED (default) Stays in a hidden background context

Hot-reload

Writing or editing an automation file triggers a reload automatically. No manual restart.

Failure modes

  • No run() function — loader rejects the file at parse time.
  • Uses an unsupported annotation (@prompt, @filter, @cooldown, @type) — rejected with a pointer to the correct shape (@trigger, @session, run(ctx)).
  • Lua runtime error — logged; next trigger fires fresh.
  • Invalid broadcast action — receiver registers but never fires. Ask the agent to verify via discover_api.