The Automation Model
CUST/OS lets automations run on their own — on a schedule, at a fixed interval, or in response to an ATAK broadcast event.
What an automation is
An automation is a .lua file in /sdcard/atak/custos/automations/. It declares:
- When to fire —
@trigger(cron, duration, one-shot, or broadcast action; type is auto-detected from shape). - What to do — a top-level
function run(ctx)that executes deterministically when the trigger fires. - Where to run it —
@session MAIN(shares the operator's chat) or@session ISOLATED(hidden background context, the default).
Automations never route to the LLM implicitly. run(ctx) is Lua — it invokes other skills through tools.call("skill_name", {args}). If the task genuinely requires LLM reasoning, the body must explicitly call tools.call("delegate", {agent_name = "...", task = "..."}). This is the only path from an automation to an LLM.
This design means an interval automation firing every 15 seconds costs milliseconds, not a full LLM ReAct loop. Small on-device models aren't hammered by schedules; they only run when the author explicitly delegates.
Three trigger types
Trigger type is classified automatically from the expression shape:
| Type | Shapes | UI tab |
|---|---|---|
schedule |
cron (0 7 * * *), one-shot (in 20m) |
Scheduled |
interval |
duration (30s, 15m, 2h) |
Monitors |
event |
broadcast action (com.atakmap.android.maps.COT_RECD) |
Events |
Schedule and interval share the same time loop in the scheduler. The UI split is about operator intent — Monitors shows ALL_CLEAR status, Scheduled shows next-fire time.
Schedule
Time-based automation. Fires on a cron expression or once after a delay.
--- Daily morning sitrep
-- @automation morning_sitrep
-- @trigger 0 7 * * *
-- @session MAIN
function run(schedule)
local result = tools.call("delegate", {
agent_name = "analyst",
task = "Generate a morning SITREP.",
})
tools.call("speak_alert", { message = result.response })
end
agent_name must match an agent configured in custos.yaml — the example above assumes an analyst agent has been added.
Interval (Monitors)
Simple repeating duration — intended for "check state, report if anomalous" patterns.
--- 300m hostile sweep every 15s
-- @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
Event
Broadcast-driven. The run(event) body receives a table of parsed broadcast fields and decides what to do.
--- Hostile contact alarm
-- @automation hostile_alarm
-- @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
Filtering is a guard clause — no separate annotation.
The ctx parameter
run() takes one argument. The parameter name is convention (Lua doesn't enforce names) but matching the trigger type makes the signature self-documenting:
function run(schedule)—{ fired_at, cron }function run(interval)—{ fired_at, interval }function run(event)—{ fired_at, action, cot_type, uid, lat, lon, callsign, hae, how, cot_xml, extra_* }
Sessions: MAIN vs ISOLATED
| Session | Behavior |
|---|---|
MAIN |
Any delegate response flows into the operator's active chat |
ISOLATED (default) |
Stays in a hidden background context |
For purely deterministic automations (no delegate), session affects audit tagging only.
How automations are created
Ask the agent:
- "Create an automation that checks for hostiles every 15 seconds and plays an alarm if found."
- The agent picks
custos.automationand callswrite_automationwith an auto-detected trigger type. - The tool writes the file, validates Lua syntax, and triggers a reload.
- The new automation appears in the Automation panel and is armed.
Automations do not preempt operator chat
Only one delegate call executes at a time. If an automation's run() calls delegate while the operator is mid-chat, the delegation serializes behind the operator's turn.
Deterministic run() bodies (no delegate) don't wait on the operator — they execute immediately.