Imported from tikoci/centrs (
commands/api/AGENTS.md). Install upstream withnpx skills add tikoci/centrs --skill api. Copyright stays with the author.
commands/api — local constraints + grounded RouterOS facts
This file is the durable home for the RouterOS API-behavior facts the api
command depends on. It exists so the knowledge survives context resets: a fresh
agent picking up any api phase reads this first. Add a fact here only with a
source (a doc URL, a tikoci/* project, or a CHR run); follow the global
grounding discipline — one CHR result is a signal, reproduce before treating it as
fact.
Read order for any api work: docs/CONSTITUTION.md → commands/api/README.md →
commands/api/examples.md → this file.
Local constraints
apiis the structured one-command-per-operation surface (the verb trichotomy inREADME.md). It must not grow code-block / multi-command behavior — that isexecute's job.- Reuse the shared cores, do not fork:
src/core/inspect.ts(the/console/inspectclient) andsrc/core/fanout.ts(the fan-out engine). - Honor
--vialiterally; never silently downgrade transport.
Grounded facts (doc sources)
REST API — https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API ·
native API — https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API ·
inspect-client TS patterns — tikoci/lsp-routeros-ts
(server/src/routeros.ts InspectRequest, server/src/validation.ts).
- HTTP method map (REST):
GET→print,PUT→add (create),PATCH→set,DELETE→remove,POST→universal (run any console command). PUT — not POST — is RouterOS's create. - Multi-frame results: bounded → array; open-ended → native
/listenonly. Nuance (do not over-read the REST docs). RouterOS REST does runduration=/monitorcommands — it regularizes the native!reframes into a JSON array of.sectionrecords and returns the whole array in one response, bounded by the 60 s REST cap (a command asked to run longer terminates early with an error). Native has no such cap. So aduration=/monitorcommand is an ordinary boundedapicall that returns a.sectionarray on both transports (native'stalk()accumulates the!reuntil!done) — not NDJSON. The only thing that needs the NDJSON streaming path is open-ended follow (native/listen, which never sends!done); REST genuinely cannot do open-ended follow. ⇒--listen(open-ended) is native-only;duration=commands are normal one-shot calls. (CONFIRMED below.) /listensemantics. Emits!reon change; a deleted/disappeared item's!recarries the dead flag (docs say=.dead=yes; CHR 7.23.1 sends.dead=true— see CONFIRMED below); it never self-terminates. Cancel with/cancel tag=<listen-tag>(the/cancelcarries its own.tag); the listener then gets!trap … message=interruptedfollowed by!done.- Query. REST
.querywords are identical to native?words minus the?prefix (CONFIRMED below). Forms: eqname=val; comparison>name=val/<name=val(operator before the name); has-propertyname; absence-name; stack ops#!(NOT top),#&(AND),#|(OR). Multiple words with no stack op are implicitly AND-ed. See the centrs mapping section below. - Proplist. REST
.proplistaccepts"name,type"or["name","type"]. Native:=.proplist=name,type. - Object id. Set/remove by id: REST id-in-URL (
PATCH /rest/ip/address/*1), native=.id=*1word. No native get-one-by-id shorthand — useprint ?.id=*1. REST has the convenienceGET /rest/ip/address/*1→ a single object; native maps it toprint ?.id=*1. /console/inspectrequest modes (InspectRequestin lsp):request ∈ {child, completion, highlight, syntax}, withinput?,path?,.proplist?,.query?.childlists menus/args (path + attribute validity);completioninput=yields allowed values (tips/warnings);highlight+input=is token-level error detection (lsp's primary validator);syntaxis the parse tree.
Query mapping (centrs --query / --raw-query)
Two layers, both grounded above:
--query/--filter— structured convenience, AND-combined (RouterOS default), repeatable. Each maps to one query word:name=value→name=value(eq)name!=value→ two wordsname=value,#!(eq then NOT-top)name>value/name<value→>name=value/<name=valuename(no=) →name(has-property) Multiple--queryemit their words in order; RouterOS's implicit AND applies (no#&injected).--querycannot express OR or arbitrary stacks — that is--raw-query.
--raw-query <word>— power-user escape hatch, repeatable. Each value is a verbatim RouterOS query word (absence-name, OR#|, AND#&, NOT#!, or any>/</=form). Emitted as-is: a.queryelement over REST, a?-prefixed word over native. centrs does not parse or reorder raw words — the caller owns the stack. When--queryand--raw-queryare both given, the structured words are emitted first, then the raw words, in order.
This keeps the common case (--query type=ether) clean while giving full
stack-language access without centrs modelling the whole query grammar.
CONFIRMED ON CHR (Phase 0 grounding spikes — CHR 7.23.1, 2026-06-29)
Probes: scratchpad api-grounding-spike.ts (listen/CRUD/execute) and
api-grounding-spike2.ts (duration/monitor + query forms + inspect path). Raw
native codec for the listen probe; fetch for REST; connectNativeApi().talk()
for native.
- CONFIRMED — native
/listen+/cancel./ip/address/listen .tag=2emits an!re tag=2carrying the full new record on add; on delete it emits a minimal!re tag=2 { ".id":"*3", ".dead":"true" }. Correction to the docs: the wire flag is.dead=true, not=.dead=yes./cancel =tag=2 .tag=3yields!trap tag=2 {category:2, message:interrupted}, then!done tag=3(the cancel) and!done tag=2(the listen closes). Modern plaintext/loginreturns a bare!done(no challenge) on 7.23.1. - CONFIRMED — native CRUD by id.
addreturns the new id in=ret=;setandremovevia=.id=<id>succeed with zero!re(only!done); get-one isprint ?.id=<id>→ exactly one record.numbers=is not needed (that is execute's CLI idiom, not the API's). - CONFIRMED — query + proplist. Native
/interface/print ?type=etherfilters server-side;=.proplist=address,interfacereturns only those keys. RESTPOST /rest/interface/print {".query":["type=ether"],".proplist":"name,type"}→200 [{"name":"ether1","type":"ether"}](a complete array, not a stream). - CONFIRMED — duration/monitor returns a bounded
.sectionarray (both transports).POST /rest/interface/monitor-traffic {interface:ether1, duration:"5s"}→200, ~5.0 s, a JSON array of 5 records keyed.section:"0".."4"(each…-bits-per-secondetc.). The native/interface/monitor-traffic duration=2sreturns the same frames viatalk()(2 records, each with.section) before!done. So duration commands are ordinary bounded calls, not NDJSON. (Corrects the first spike's over-strong "REST does not support continuous commands" reading; the/rest/interface/monitor400 there was just a wrong command path —monitor-trafficis the real menu.) The 60 s unbounded cap stays doc-grounded — not worth a 60 s CI wait to reproduce. - CONFIRMED —
/console/inspectpathis array-typed; pass COMMA tokens, not a slash command.request=child path=system,license→ childrenlicense:dir, export:cmd, generate-new-id:cmd, get:cmd, print:cmd, renew:cmd; the slash forms (system/license,/system/license) return nothing. (This is why the first spike's license probe was empty — wrong path form.) Why (rationale, not yet re-spiked): RouterOS's inspectpathargument is internally an array type — a comma string likeip,address,setis:toarray-split into["ip","address","set"], the menu walk inspect expects. A/-prefixed command-style string is not an array and isn't split, so it matches no menu. A JSON array body over REST ({"path":["ip","address"]}) should therefore work too — plausible but unconfirmed; do not rely on it. The contract centrs uses is the comma-joined string (confirmed; what bothretrieveandexecutealready build via their duplicatejoin(",")helpers — the Phase-1src/core/inspect.tsis the single home for it). Andrenewis acmdnode →/system/license/renewis a write/command, confirming the "streaming ≠ read-only" rule concretely. - CONFIRMED —
.queryforms (REST word == native?-word minus?). On/interface/print(2 interfaces: ether, loopback): eqtype=ether→ 1; ne["type=ether","#!"]→ loopback; OR["type=ether","type=loopback","#|"]→ 2; gt[">actual-mtu=1000"]→ 2; has["running"]→ 2; absence["-running"]→ 0; implicit-AND["type=ether","running"]→ 1. Identical results native (?-prefixed). REST license singleton:GET /rest/system/license→{"level":"free","system-id":"…"}. - CONFIRMED —
POST /rest/executemodes. With"as-string":""it runs synchronously →{"ret":"CHR"}. Withoutas-stringit is fire-and- forget: it schedules a job and returns the job id ({"ret":"*18"}, HTTP 200) — a:errordoes not surface synchronously. ⇒api's script-POST must sendas-string(reuse execute'srestPost("/execute",{script,"as-string":""}),adapter.ts:199) so output/errors are synchronous; the bare async-job form is not the default. - RESOLVED —
/system/license(was inconclusive in spike 1 due to the slash path; see the COMMA-path finding above). Independently,isApiMutatingkeys the write gate on the method/verb, so even an async/streamable write like/system/license/renewconfirms regardless.
CONFIRMED ON CHR (Phase 2+3 integration — CHR 7.23.1, 2026-06-29)
Validated by test/integration/api.test.ts (rest, examples 1–20, 98 assertions)
and test/integration/api-native.test.ts (native, N1–N8, 31 assertions), both
green on CHR 7.23.1.
- CONFIRMED — native
/executesupports=as-string=for synchronous output. Nativetalk /execute =script=…withoutas-stringis fire-and-forget: it schedules a job and returns the job id in=ret=(observed"*31"), not the script output — exactly the REST behavior. Adding=as-string=(empty value) makes it run synchronously and return the captured output, identical to REST/rest/execute {as-string}. This correctssrc/execute.ts's long-standing assumption that native script mode is unsupported (execute still blocks it; theapipath proves native/execute =as-string=works).apialways sendsas-stringfor a script run on both transports. - CONFIRMED — native CRUD re-mapping to rest-style.
addreturns the new id only in the!done=ret=word → centrs re-maps it to{".id": ret}(REST PUT returns the full created object with.id; native gives just the id).set/removereply with a bare!done(no!re) → no body → centrs surfacesnull. get-one isprint ?.id=<id>→ exactly one record → returned as a single object (not a 1-element array). All grounded inrestStyleMutationData/restStyleRunData(src/protocols/adapter.ts). - CONFIRMED — REST
monitor-trafficover theapipath returns a.sectionarray.POST /rest/interface/monitor-traffic {interface,duration}→[{".section":"0",…}, {".section":"1",…}](re-confirms the spike-2 finding via the realapiREST adapter). It is a command (terminal verbmonitor-traffic, notprint/get), so it is aPOSTand write-classed → needs--yes. - CONFIRMED — the inspect gate over both transports. Path existence
(
request=childempty ⇒validation/unknown-path) and add/set attribute validity (request=child⇒validation/unknown-attribute) fire identically over rest-api and native-api, before any write — no:put [:parse]involved (api input is a path, not a CLI string). A/executescript is a CLI string ⇒meta.validation.semantic = "not-applicable".
CONFIRMED ON CHR (RouterOS 7.12.2 compatibility — GH#343, 2026-09-14)
- Command-level
request=completionis unsafe on 7.12.2. Forpath=system,identity,print, REST did not answer before the 10 s client timeout and native API closed the connection immediately. Adding.proplist=completiondid not change either result. The preceding:parseandrequest=childcontrols returned in 1–8 ms on both transports, andchildcontained the complete command argument list. Execute/API attribute validation therefore uses command-levelrequest=childonly. This does not prohibit argument-level completion probes such asprint,proplist, which are a separate shape and returned immediately on the same 7.12.2 controls.
CONFIRMED ON CHR (Phase 4 --stream follow — CHR 7.23.1, 2026-06-30)
Validated by test/integration/api-listen.test.ts (L1–L4) on CHR 7.23.1.
--stream is the primary flag; --listen is an alias; a /listen endpoint
infers both. Native-api only (REST's 60s cap → transport/capability-unsupported).
- CONFIRMED —
NativeApiSession.listen()+/cancelend-to-end. Opening/ip/address/listen(own.tag) and adding an address over REST yields an!reframe; stopping (--count/--duration/Ctrl-C) sends/cancel =tag=<t> .tag=<n>and the listen closes via theinterruptedtrap (category 2) then!done— the generator ends without throwing (the interrupted trap is the normal cancel, not an error). A non-interrupted trap or transport closure does throw. Re-confirms the Phase-0 spike through the realapiListen()path. - CONFIRMED — deletion frames carry
.dead=true. Removing a pre-seeded address over REST while listening emits a minimal{ ".id", ".dead":"true" }!re(re-confirms.dead=true, not the docs'=.dead=yes). - Stream shape: each
!re→ one NDJSON envelope frame (meta.operation.stream.kind="frame", string values,.deadpreserved); the stream ends with a summary envelope (stream.kind="summary",data.stopReason∈count-reached/duration-elapsed/interrupted/transport-error, plusframes/durationMs). The CLI exit code keys on whether the stream started cleanly, not on per-frameok.