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.

  1. Tap + in the tab bar to create a new scratch file.
  2. Write test code that calls your tool function directly. All registered tools are available as globals.
  3. 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 status field -- 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.lat rather than result.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