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.