Write a Skill
This is the cookbook for skill authors. For a guided lesson, see Tutorial: Write Your First Skill.
Skill anatomy
/sdcard/atak/custos/skills/
└── custos.your_skill/
├── SKILL.md <- metadata + description (YAML frontmatter)
├── tool_one.lua <- one or more Lua tools
└── tool_two.lua
The directory name must match <group>.<name> from SKILL.md. Both halves are mandatory.
Minimal SKILL.md
---
group: custos
name: my_skill
description: One-line description shown in the agent's system prompt
script_paths:
- custos.my_skill/tool_one.lua
tags:
- keyword1
- keyword2
examples:
- "example user request that this skill handles"
- "another way an operator might ask for this"
---
# My Skill
Markdown body. The agent sees this in the system prompt when this skill
is selected, so explain when it should be used and any rules to follow.
The examples: field provides representative user queries that improve skill selection. Each example is matched against the operator's message, so write them the way an operator would actually ask.
Minimal Lua tool
--- Short description shown in tool docs
-- @tool tool_name
-- @description One-sentence summary the LLM uses to decide when to call this
-- @tparam string param_name What this parameter is for
-- @tparam number [optional_param=42] Optional parameter with default
-- @impact READ_ONLY
function tool_name(params)
-- params.param_name and params.optional_param are available
return {
status = "success",
result = "..."
}
end
The LDoc comment block before function is required -- it is how CUST/OS extracts the tool name, parameter schema, and impact level.
Impact levels
| Level | Behavior |
|---|---|
READ_ONLY |
No approval needed (queries, lookups) |
INFORMATIONAL |
No approval needed (logging, memory writes) |
PROCEDURAL |
No approval by default -- single user-visible action |
SIGNIFICANT |
Approval dialog fires before the tool runs |
STRATEGIC |
Approval dialog fires; coarse/high-impact actions like package distribution or broadcasts |
LETHAL |
Approval dialog fires; reserved for actions with irreversible kinetic consequences |
The gate fires at SIGNIFICANT or higher. Unrecognized @impact values fall back to PROCEDURAL with a warning in logcat — match one of the six exactly.
Common patterns
Read the operator's position
function get_my_pos(params)
local MapView = import("com.atakmap.android.maps.MapView")
local mapView = MapView:getMapView()
local self = mapView:getSelfMarker()
if not self then return { status = "error", message = "no self marker" } end
local point = self:getPoint()
return {
status = "success",
lat = point:getLatitude(),
lon = point:getLongitude(),
}
end
Place a marker via CoT (the right way)
Direct addItem on the root group renders markers but they are not findable by other parts of ATAK. Always go through the CoT pipeline:
function place(params)
local CotEvent = import("com.atakmap.coremap.cot.event.CotEvent")
local CotPoint = import("com.atakmap.coremap.cot.event.CotPoint")
local CotDetail = import("com.atakmap.coremap.cot.event.CotDetail")
local CotMapComponent = import("com.atakmap.android.cot.CotMapComponent")
local CoordinatedTime = import("com.atakmap.coremap.maps.time.CoordinatedTime")
local Bundle = import("android.os.Bundle")
local UUID = import("java.util.UUID")
local now = CoordinatedTime()
local stale = CoordinatedTime(now:getMilliseconds() + 300000)
local event = CotEvent()
event:setUID(UUID:randomUUID():toString())
event:setType(params.type or "a-f-G")
event:setHow(CotEvent.HOW_MACHINE_GENERATED)
event:setPoint(CotPoint(params.lat, params.lon, 0, 0, 0))
event:setTime(now)
event:setStart(now)
event:setStale(stale)
local detail = CotDetail()
local contact = CotDetail("contact")
contact:setAttribute("callsign", params.callsign)
detail:addChild(contact)
event:setDetail(detail)
runOnUiThread(function()
CotMapComponent:getInstance():processCotEvent(event, Bundle())
end)
return { status = "success", callsign = params.callsign }
end
Iterate every map item
function list_items(params)
local MapView = import("com.atakmap.android.maps.MapView")
local rootGroup = MapView:getMapView():getRootGroup()
local items = rootGroup:getItemsRecursive()
local iter = items:iterator()
local results = {}
while iter:hasNext() do
local item = iter:next()
if item:getMetaString("callsign", nil) then
table.insert(results, {
uid = item:getUID(),
callsign = item:getMetaString("callsign", ""),
})
end
end
return { items = results }
end
Important: Do not use forEachItem or deepForEachItem with a callback -- iteration will stop after one item due to a Lua/Java bridging quirk. Use getItemsRecursive():iterator() instead.
Send a broadcast intent
The right way to talk to other ATAK plugins. Intent action strings survive obfuscation; direct method calls on other plugins do not.
function send_to_grg(params)
local Intent = import("android.content.Intent")
local AtakBroadcast = import("com.atakmap.android.ipc.AtakBroadcast")
local intent = Intent("com.atakmap.android.grgbuilder.SHOW_BUILDER")
intent:putExtra("uid", params.uid)
AtakBroadcast:getInstance():sendBroadcast(intent)
return { status = "success" }
end
To find the right action string, use the discover_api tool:
discover_api(query="intents:grg")
Build a Java array
Lua tables do not auto-convert to Java arrays. If a method expects Foo[], build the array explicitly:
local Array = import("java.lang.reflect.Array")
local GeoPoint = import("com.atakmap.coremap.maps.coords.GeoPoint")
local points = Array:newInstance(GeoPoint, 4)
Array:set(points, 0, GeoPoint(lat1, lon1))
Array:set(points, 1, GeoPoint(lat2, lon2))
Array:set(points, 2, GeoPoint(lat3, lon3))
Array:set(points, 3, GeoPoint(lat4, lon4))
someMethod(points) -- now works
Call an interface callback
Lua/Java bridge callbacks pass self as the first argument. Declare it explicitly or your parameter indices will be off by one:
local handler = {
onCaptureTile = function(self, tile, tileNum, col, row)
-- self is the proxy object; tile is the real first arg
end,
onCaptureFinished = function(self, captured)
-- ...
end,
}
tileCapture:capture(params, handler)
Use a host-injected service
custos.yaml can expose services like rag, tts, scheduling, and others as globals in Lua:
function index_text(params)
rag:store(params.id, params.text, {}, "default")
return { status = "success" }
end
function search_text(params)
local hits = rag:retrieve(params.query, 5, "default")
return { hits = hits }
end
Helper functions
The custos.helpers skill provides shared utilities for common tactical calculations:
local result = calc_bearing({lat1 = 41.63, lon1 = -93.85, lat2 = 42.0, lon2 = -93.0})
-- result = { bearing_deg = 48.7 }
Available helpers
| Helper | Purpose | Key params |
|---|---|---|
calc_bearing |
Bearing between two points in degrees | lat1, lon1, lat2, lon2 |
compass_dir |
Convert bearing to compass direction (e.g., "NE") | bearing_deg |
format_mgrs |
Convert lat/lon to MGRS grid reference | lat, lon |
format_dms |
Convert lat/lon to degrees-minutes-seconds string | lat, lon |
parse_color |
Parse color name or hex to ATAK color int | color |
parse_cot_type |
Parse a human description to a CoT type string | description |
build_cot_event |
Build a CotEvent from a params table | uid, type, lat, lon, callsign, ... |
dispatch_cot_event |
Send a CotEvent through the CoT pipeline | event |
resolve_item |
Find a map item by UID, callsign, or partial match | query |
Helpers are available as globals in scratch files (and inside skill scripts via the registered-tools surface). For in-skill composition, prefer tools.call("helper_name", {...}) so the call is audited and goes through the same invocation path as any other tool.
Testing skills
The easiest way to test a skill is from the editor's scratch pad.
- Tap
+in the tab bar to create a new scratch file. - Write test code that calls your tool function directly. All registered tools are available as globals.
- Hit Run. The console shows the return value.
return focus_map({lat = 41.63, lon = -93.85})
Use pcall for tools that may fail depending on map state:
local ok, result = pcall(find_items, {query = "nonexistent"})
if ok then
return result
else
console.log("Expected error: " .. tostring(result))
return "handled"
end
Returning structured data
The return value is serialized to JSON and fed back to the LLM. Best practices:
- Always include a
statusfield --success,error,partial. The agent uses this to decide whether to retry. - Keep results compact -- large results are truncated.
- Use stable keys -- the LLM learns to expect
result.latrather thanresult.position.coordinates[0]. - Do not return raw Java objects -- they marshal to opaque references. Pull out scalars.
Error handling
function risky(params)
local ok, err = pcall(function()
-- ...
end)
if not ok then
return { status = "error", message = tostring(err) }
end
return { status = "success" }
end
Uncaught Lua errors are automatically caught and returned to the LLM as an error result. The error message is also shown in the editor's debug bar and recorded in the audit log.
Hot-reload
Save the file. CUST/OS picks up the change within a second -- the skill registry rebuilds, the keyword index rebuilds, and the new tools are immediately callable. No plugin restart needed.
Sandbox limits
Lua scripts run inside a sandbox with hard caps:
- Instruction limit per execution (configurable in
custos.yaml) - Concurrent execution limit
- Call depth limit
- Package allowlist (set in
custos.yaml) - Method denylist (dangerous operations like
System.exit,Runtime.exec,File.delete) - File I/O restricted to
/sdcard/atak/custos/
If you exceed any of these the script throws and the LLM sees an error result.
Sharing scripts between skills
script_paths are full paths from the skills root, not relative. This lets you reference the same .lua file from multiple skills:
script_paths:
- custos.markers/place_marker.lua
- custos.tactical_picture/get_self_position.lua