Data model
Every document the API takes and returns, commented field by field.
| Model | Sent to or read from | Walkthrough |
|---|---|---|
| Scenario | POST /api/scenarios | Running a simulation |
| Asset | POST /api/assets, GET /api/assets | Working with assets |
| Command | POST /api/assets/{id}/command | Commanding assets |
| Task | GET /api/tasks | Commanding assets |
| Observation | POST /api/signals | Working with assets |
Every answer is wrapped: { "ok": true, "data": … }, or { "ok": false, "error": "…", "code": "…" }.
Credentials: Authentication. Every route, with a console that sends it:
API explorer.
Scenario
The spec in POST /api/scenarios, alongside a name.
{
"seed": 20260918, // required. Fixes every random draw: same seed, same fight
"description": "Recon from the north, strike from the east", // optional, one line
"region": { // required. The site
"name": "Harbour",
"center": [1.3644, 103.9915], // [latitude, longitude]. Latitude first
"geofence_radius_m": 1500 // attackers spawn at this radius + 400 m
},
"environment": { "kind": "lite" }, // default. Kinematic, in the server, needs nothing
// { "kind": "gazebo", "environment": "SG_CBD", "vehicles": [ { "label": "BLUE-01", "model_name": "x500_0" } ] }
// { "kind": "air_sim", "environment": "CityLevel", "vehicles": [ { "label": "BLUE-01", "vehicle_name": "Drone1" } ] }
// a rendered world names its compiled level, and binds drone labels to its models
"force": { "kind": "synthetic" }, // default. Our side is invented for the run
// { "kind": "copy_of", "asset_ids": ["ast_…"] } fly copies of registered assets: their
// airframes, payloads and performance
"release": "at_start", // at_start (inbound from t=0) | on_command
"protects": [ // what the run defends. Empty means the site itself
{ "id": "berth-4",
"name": "Berth 4", // shown verbatim
"enu": { "x": 0, "y": 0, "z": 0 }, // metres east, north, up from region.center
"radius_m": 60, // counts as "at the asset" for impact and closest approach
"priority": 2 } // relative worth when several are threatened. 1 by default
],
"win": { // what ends the run, and who won it
"blue_holds": ["berth-4"], // the protected ids blue has to keep
"red_needs": 1 // how many of them red has to reach
},
"engagement": { // what an effect does when it connects, per rung
"terminal": { "connects": 0.9, "effect": "falls" },
"capture": { "connects": 0.8, "effect": "recovered" },
"emit": { "connects": 0.6, "effect": "link_lost",
"defeated_by": ["autonomous"] }, // a target this describes shrugs it off
"deterministic": true // the seed decides, so a run replays
},
// effect is falls | recovered | link_lost; defeated_by is autonomous | ins | gnss | rf_control
"forces": [ // any number. One per side, or one per wave
{
"id": "blue", // required. Every body and sensor is reported under it
"side": "defender", // defender (default) | adversary | neutral
"controller": { "kind": "c2" }, // c2 (default): yours to task through the decision loop
// scenario: the simulator flies it; the console cannot task it
"objective": { "kind": "patrol" }, // optional. What the whole force is trying to do.
// patrol | loiter | intercept (defender)
// kamikaze | payload_drop | recon (adversary)
// { "kind": "intercept", "target": { "kind": "label", "label": "RED-1" } }
// target is { "kind": "label", "label" } | { "kind": "point", "lat", "lon" } | { "kind": "asset", "asset_id" }
"drones": { // the bodies, as a list ...
"kind": "specific",
"drones": [
{
"label": "BLUE-01", // callsign. What an intercept targets, and what the run reports
"drone_type": "quadcopter", // airframe: quadcopter | fpv | fixed_wing | vtol | loitering_munition
"approach_bearing": 0, // degrees: an attacker's inbound axis, 0 = from the north
"formation": "station", // layout hint: line | wedge | column | swarm | loiter | station | single
"intent": { "kind": "loiter" }, // optional. Overrides the force objective for this drone
"behavior": { "strategy": "loiter_guard", "params": {} }, // optional. A named strategy
// from GET /api/strategies; wins over intent
"platform": { "kind": "model", "model": "DJI Mavic 3" }, // optional. Which catalogue airframe it flies
// platform can also be { "kind": "asset", "asset_id": "ast_…" } to fly as a registered asset
"start": { // optional. Where this body begins, and whether it is flying
"at": { "kind": "enu", "x": 300, "y": -200, "z": 0 },
// or { "kind": "polar", "bearing_deg": 180, "range_m": 400, "alt_m": 80 }
"posture": "landed" // landed: on the ground, armed and climbing only when tasked.
}, // airborne is the default, and a landed body ignores the height it names
"performance": { "cruise_mps": 30, "climb_mps": 8 }, // optional. Overrides the airframe's figures
"flight_stack": "kinematic", // px4 (a real autopilot, slower) | kinematic (a written pose)
"effect": "capture" // what it does on arrival: observe | identify | emit | capture | terminal
}
]
},
"assets": [ // sensors and effectors, one list
{
"id": "radar-north",
"roles": ["sensor"], // sensor | effector | platform. A set: an interceptor can be two
"domain": "ground", // aerial (default) | ground | maritime | ew
"profile": "ground-radar-360", // catalogue profile: reach, field of view, accuracy come from here
"sensor": { // present exactly when roles has "sensor"
"id": "radar-north",
"profile": "ground-radar-360",
"placement": { "kind": "fixed", "pos_enu": { "x": 0, "y": 200, "z": 10 } },
// metres east, north, up from region.center
// or mounted: { "kind": "on_asset", "asset_id": "BLUE-01", "mount_offset_enu": { "x": 0, "y": 0, "z": 0 } }
"boresight_deg": 0 // where a directional sensor faces
}
},
{
"id": "battery-1",
"roles": ["effector"],
"domain": "ground",
"effector": { // present exactly when roles has "effector"
"id": "battery-1",
"pk": 0.7, // what it claims per shot. The outcome is read off the picture
"magazine": 4, // rounds. Empty stops it offering
"cooldown_s": 8, // seconds between launches
"range_m": 2000,
"x": -150, "y": 0 // where it stands, metres from region.center
}
}
]
},
{
"id": "red-recon", // wave 1
"side": "adversary",
"controller": { "kind": "scenario" }, // write every adversary force this way
"objective": { "kind": "recon" }, // approach, then orbit at a standoff
"drones": { // ... or as a swarm: N identical drones on one bearing
"kind": "swarm",
"count": 1,
"drone_type": "quadcopter",
"approach_bearing": 0,
"formation": "single"
}
},
{
"id": "red-strike", // wave 2
"side": "adversary",
"controller": { "kind": "scenario" },
"objective": { "kind": "kamikaze" }, // fly into the defended point
"drones": { "kind": "swarm", "count": 3, "drone_type": "fpv",
"approach_bearing": 90, "formation": "line" }
}
],
"time": {
"duration_s": 240, // optional. Simulated seconds; absent runs until stopped
"scale": 1.0 // 2.0 runs twice as fast
},
"video": { "enabled": false, "fps": 10 } // required. The simulated camera feed
}
What happens to it
| Stored in canonical form | A document written in the older shorthand, one friendlies fleet and one attack fleet, is converted to forces as it is saved, and the defending force gets the default sensor layout written into it. Read the answer rather than assuming it matches what you sent. |
| Refused when written | A spec that does not parse is 400, naming the field. |
| Swarm labels | A swarm's drones are labelled by the run. Use a specific list for a drone you need to name. |
| No start | A body with no start takes its force's laydown: defenders on a ring about the site, attackers at the geofence radius plus 400 m on their bearing. |
| Sensors in Dome Lite | Dome Lite adds its default layout of nine ground sensors to every run. A force's declared sensors run alongside it. |
A landed defender is on the ground until something gives it work: it arms, climbs and flies the order, which is what a site defended from its pads actually costs.
Routes: Simulation. Walkthrough: Running a simulation.
Asset
Registering one
The body of POST /api/assets. A vehicle:
{
"name": "HAWK-1", // required. The callsign, unique in the workspace
"catalog": "dji/mavic-3-pro", // a type id from GET /api/catalogue/types,
// or a name from GET /api/catalog/builtins ("DJI Mavic 3 Pro")
"kind": "vehicle", // default. Spell it exactly
"spec": { // optional. Laid over the catalogue entry key by key
"remote_id_serial": "1581F5FHD228Q00A1234",
"performance": { "cruise_mps": 12, "max_mps": 21, "endurance_min": 43, "ceiling_m": 6000 }
// a nested object replaces the catalogue's whole
},
"connection": { // optional. Adopt something a link has heard
"integration": "mavlink", // from GET /api/discoveries
"address": "mav-udp-14550-42" // 404 if nobody heard it, 409 if another asset holds it
}
}
A fixed sensor:
{
"name": "MAST-1", // also the sensor.id its detections carry
"kind": "sensor", // required for a sensor
"catalog": "ground-radar-360", // required. A profile id from GET /api/catalog/sensor-profiles.
// Reach, field of view and accuracy come from it
"placement": { // required for a sensor
"lat": 1.3644, // latitude first; a swapped pair is refused
"lon": 103.9915,
"height_m": 12, // optional, above ground
"boresight_deg": 90 // optional, the direction it faces
}
}
POST /api/assets/{id} edits: send only name, model, performance, placement or
switches. DELETE /api/assets/{id} removes.
Reading one
What GET /api/assets and GET /api/assets/{id} return, for vehicles and sensors alike:
{
"id": "ast_339a78dc2b9146aa8736bf2c05a4ccbb", // every call names it by this. A scenario's drone is
// sim/<run>/BLUE-01: URL-encode it in a path
"name": "HAWK-1",
"kind": "vehicle", // vehicle | sensor
"domain": "aerial", // aerial | ground | maritime | ew
"affiliation": "unknown",
"performance": { "cruise_mps": 12.0, "max_mps": 21.0, "endurance_min": 43.0, "ceiling_m": 6000.0 },
"payloads": [ { "profile": "eo-turret", "label": "Hasselblad 4/3 main", "source": "catalog" } ],
"link": { // what is carrying it
"state": "connected", // unlinked | connected | stale
"protocol": "mavlink",
"link_id": "GCS"
},
"health": { // from its own telemetry
"armed": true, "in_air": true, "mode": "loiter",
"energy": {}, "nav": { "fix": "no_fix", "satellites": 0 }
},
"switches": { // what an operator has turned on or off
"receiving": true, // its reports enter the picture
"commanding": false, // it may be commanded
"video": false,
"automation": "site"
},
"capabilities": [ // absent until a link carries it. See Command
{ "verb": "move_to", "params": "point", "class": "navigation", "available": true },
{ "verb": "takeoff", "params": "altitude", "class": "lifecycle",
"available": false, "reason": "already airborne" }
],
"track_id": "T-09001", // its own track on the picture, once heard
"saved": true, // in the registry, as opposed to heard only
// sensors only
"profile": "ground-radar-360",
"sensor_id": "MAST-1",
"placement": { "lat": 1.3644, "lon": 103.9915, "height_m": 12.0, "boresight_deg": 90.0 }
}
Filters on GET /api/assets: kind, free=true (not committed to a plan),
nearest=lat,lon (sorted by distance). Routes and refusals:
Assets & commands.
Command
The body of POST /api/assets/{id}/command: a verb, and parameters tagged by kind.
{
"verb": "move_to", // one the asset advertises in capabilities, and available now
"params": { "kind": "point", "lat": 1.3700, "lon": 103.9950, "alt_m": 100 }
}
// params, by kind
{ "kind": "none" } // arm, disarm, land, hold, stop, return_to_base, cue_release
{ "kind": "altitude", "alt_m": 50 } // takeoff
{ "kind": "point", "lat": 1.37, "lon": 103.995, "alt_m": 100 } // move_to. alt_m optional
{ "kind": "area", "lat": 1.3644, "lon": 103.9915, "radius_m": 300, "alt_m": 90 }
// orbit, surveil. alt_m optional
{ "kind": "target", "track_id": "T-00481" } // follow, intercept, cue_sensor, jam, spoof, designate
{ "kind": "route", // follow_route
"points": [
{ "lat": 1.3660, "lon": 103.9900, "alt_m": 80 },
{ "lat": 1.3680, "lon": 103.9950, "alt_m": 80,
"speed_mps": 10, // optional
"hold_s": 30, // optional, wait here
"action": "loiter", // optional: pass | loiter | land
"params": {} } // optional, namespaced vendor parameters
],
"on_complete": "hold" } // hold | return_to_base | land | loop
| Class | Verbs | Acts on |
|---|---|---|
| lifecycle | arm disarm takeoff land return_to_base hold stop | the asset |
| navigation | move_to follow_route orbit | the asset |
| effect | surveil follow intercept cue_sensor cue_release jam spoof designate | a track or a place, and passes the rules of engagement |
The answer is a Task, 201 with Location: /api/tasks/tsk_…. A verb not
advertised, or not available now, is 409, and the refusal is kept as a task too.
The ladder
Every verb sits on one rung. The autonomy line answers each task: runs at or below it, asks above it, not_permitted above the deployment's ceiling.
// observe < manoeuvre < watch < shadow < deny < destroy
// seen on a simulated drone:
// move_to, orbit, hold manoeuvre
// surveil watch
// follow shadow
// intercept destroy
// GET /api/decision-config → .data.autonomy, and PUT /api/decision-config/autonomy with:
{ "kind": "linear", "line": "watch" } // takes effect on the next tick
Task
Every command writes one, accepted or refused. GET /api/tasks, GET /api/tasks/{id}:
{
"id": "tsk_d2082c75efe246b7881b5213b0e8c9ba",
"actor": "sim/96cfff7e-…/BLUE-01", // the asset told to act
"verb": "follow",
"params": { "kind": "target", "track_id": "T-00481" }, // as sent
"object": { "kind": "track", "track_id": "T-00481" }, // what it is aimed at: a track or a place
"origin": { "source": "operator" }, // operator | plan (with plan_id) | autonomy (with the line)
"level": "shadow", // the rung, stamped when written
"gate": { // the line's answer, stamped when written
"gate": "runs", // runs | asks | not_permitted
"stop_within": 0 // seconds left to take it back, when it runs
},
"status": "issued", // proposed → issued → executing → complete,
// or refused | superseded | reverted
"reason": null, // why it was refused, superseded or reverted, verbatim
"issued_at": "2026-09-18T04:54:43.115264Z"
}
Filters: actor, status, origin, plan, object, since, limit. A release with a
stop window is taken back at POST /api/tasks/{id}/stop. See
Plans & tasks.
Observation
One detection from one sensor. A batch of them is the body of POST /api/signals:
{
"source": "live", // live | simulated. Simulated stays out of the live picture
"observations": [ // one tick. 1 to 5000
{
"schema": "obs.v1", // required, always this
"obs_id": "mast1-000148", // required, yours, unique. Returned as track evidence
"t": "2026-09-18T08:14:22.000Z", // required. When the sensor saw it
"platform": { // required. What is doing the sensing
"id": "MAST-1",
"geo": { "lat": 1.3644, "lon": 103.9915, "alt_m_agl": 12 } // height above ground
},
"sensor": { // required
"id": "MAST-1", // a registered sensor's name counts the return against it
"modality": "radar" // radar | rf | eo | ir | acoustic | remote_id | adsb | …
},
"detection": { // required
"class": "uav_multirotor", // optional: uav_multirotor | uav_fixedwing | bird | helicopter | …
"class_conf": 0.9,
"geo": { "lat": 1.3712, "lon": 103.9950, "alt_m": 110 }, // alt_m here, not alt_m_agl
// or "enu": { "x", "y", "z" }, or sensor-relative "rel": { "range_m", "bearing_deg", "elevation_deg" }
"vel_mps": { "vx": 0, "vy": -8, "vz": 0 }, // optional
"signal": { "modality": "radar", "rcs_m2": 0.05 } // required. The raw measurement
// rf: { "modality": "rf", "freq_mhz": 5806, "bearing_deg": 206 } bearing only: no geo
// acoustic: { "modality": "acoustic", "bearing_deg": 120 }
// signal.modality is one of radar | rf | eo | ir | acoustic | remote_id | adsb | telemetry | cot | other
},
"provenance": { "decoder": "my-bridge/1.2" }, // required, free-form, carried through untouched
"quality": { // required, every field optional
"confidence": 0.9,
"geoloc_sigma_m": 15, // position error, metres
"bearing_sigma_deg": 4 // bearing error, degrees
}
}
]
}
→ { "ok": true, "data": { "accepted": 1 } }. A missing field is 400 with a sentence that
names it. What comes out is a track; its evidence lists the obs_ids behind it. See
Observations & events.
Also worth knowing
| Picture & stream | Hydrate once from GET /api/picture, then hold the stream. The events and their payloads. |
| Plans & tasks | Candidate plans, approving part of one, the veto window. |
| Configuration | Rules, thresholds, the autonomy line, and doctrine as one YAML document. |
| Simulation | Scenario, run lifecycle and recorded-run routes. |
| API explorer | Every operation, with a console that sends the request. |