CHR from the shell: quickchr CLI + centrs

SkillDev tools

Answer a RouterOS question by asking RouterOS. quickchr boots a real, disposable MikroTik CHR router on the local machine under QEMU, no hardware, no cloud, gone when you remove it, and centrs drives it. Covers the shell path end to end: the add/start/stop/remove lifecycle and what --bg actually does, getting REST endpoints and credentials out of a machine, L2 links between two VMs, and the provisioning steps (device-mode) that only work before a machine's first boot. Use when: checking a claim or config against real RouterOS instead of guessing; needing a throwaway router for REST/CLI/API iteration; giving a shell script or CI job a router to talk to. For TypeScript harnesses that import the QuickCHR class, use routeros-quickchr instead.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the CHR from the shell: quickchr CLI + centrs skill

What this skill tells your AI

The instructions your AI receives, as published by tikoci/routeros-skills in routeros-quickchr-cli/SKILL.md and read by ahel’s review.

quickchr (@tikoci/quickchr, CLI + Bun/TS library) downloads a MikroTik CHR image, boots it under QEMU, and hands you a router. This skill is the shell-first path: you have a terminal, you want a router to interrogate, and the answer will be quoted as shell + RouterOS commands (e.g. in a forum.mikrotik.com reply). If you are writing a bun:test harness that imports QuickCHR, use the routeros-quickchr skill instead — one scheme per task, never both.1

Behavior below is pinned to quickchr 0.4.8. Several of these are 0.4.8 changes, called out inline; on 0.4.7 and earlier the answer differs.2

One limit to keep in view: a CHR booted this way runs the free license (quickchr get <name> shows Level: free), which MikroTik caps at 1 Mbps upload per interface — every other RouterOS feature is unrestricted. Config, API and CLI grounding are unaffected; a throughput or queue number measured here is the license talking, not the feature.3

Lifecycle: add is not start

quickchr add --name lab-a --version 7.24.4 --arch x86 --add-network user
quickchr start lab-a --bg
quickchr exec lab-a "/system/resource/print"
quickchr stop lab-a
quickchr remove lab-a
  • add only writes config (0s on a warm cache) and prints quickchr start <name> as its tip. It never boots — the single most common wrong assumption about this CLI.4
  • start --bg blocks until the guest is REST-ready, then prints the REST/SSH/WinBox endpoints (≈22s first boot, ≈11s restart, warm-cache HVF). Under TCG software emulation expect minutes, not seconds — a sandboxed field lab saw ~4 min boots and a real BOOT_TIMEOUT at 480s. Size harness timeouts from the slow end.5
  • exec <name> <command…> runs one RouterOS command over REST. --via is auto|rest|qga here, and qga is narrower than it looks: RouterOS only starts its guest agent for an x86 CHR under Linux KVM. Under macOS HVF or TCG the port is presented but the guest never opens it, and ARM64 CHR has no QGA at all — both time out. Stay on REST unless you are on Linux/KVM with an x86 guest. (The library's ExecTransport type also lists ssh and console; those are not CLI surface.) stop is instant; remove deletes the machine. list shows state and PIDs.6
  • Version selection: --version 7.24.4 pins; --channel stable|long-term|testing|development resolves the newest of a channel. Bare quickchr --version prints what each channel currently resolves to.7
  • Ports are auto-allocated from 9100 in per-machine blocks of ten (9140, 9150, 9160 …), so parallel machines do not collide and you need no port bookkeeping. --port-base overrides; --forward name:host:guest/proto adds extras, ranges included.8
  • Pre-warm without booting: quickchr cache add --version 7.24.4 --arch x86 resolves and downloads one image, no QEMU and no machine required. cache key prints dir=/version=/arch= for CI. (Both new in 0.4.8 — before it, cache was list|prune|clear only and the only way to warm the cache was to boot something.)9
  • Never hand-edit machine.json to change networks or options — it breaks boot. Everything is reachable through add flags.10

--bg is a no-op — background it yourself

--bg changes nothing: background is already the default. The CLI sets background = !wantFg, so the flag only fails to select the foreground mode you did not ask for; --fg/--foreground is the switch that does something. And start waits for REST-readiness by contract either way, so --bg does not hand your shell back. Both halves of what the name promises are things it does not do (tikoci/quickchr#159 — the --no-wait half and the naming decision are still open).11

Examples here pass --bg only because it is harmless and widespread in existing scripts. To actually get the shell back, background it yourself and poll readiness:

nohup quickchr start lab-a --bg >lab-a.start.log 2>&1 &

ready() { centrs retrieve --quickchr lab-a /system/resource >/dev/null 2>&1; }
for _ in $(seq 60); do ready && break; sleep 5; done   # 60 x 5s = 5 min
ready || { quickchr list; tail -20 lab-a.start.log; exit 1; }

Bound the wait. start can fail outright — MISSING_QEMU, SPAWN_FAILED, BOOT_TIMEOUT — and an unbounded until loop then polls forever, which in CI is a hung job with no diagnostic. Size the bound off the slow end (TCG minutes, not HVF seconds) and on timeout print quickchr list plus the start log rather than just exiting.12

A killed start does not stop the VM. On 0.4.8 QEMU is spawned detached (its own session), so even a harness that SIGKILLs the whole process group leaves the guest booting — it comes up REST-ready with nothing left to report it. Verified against both published releases: group-SIGKILL 8s into a first boot took the VM down on 0.4.7, and left it running and REST-ready on 0.4.8. So never infer "start failed" from a killed CLI — check quickchr list and poll REST, and stop/remove to clean up. (A BOOT_TIMEOUT can likewise auto-clean the machine while QEMU keeps running.)13

If a harness group-kills commands and you are pinned below 0.4.8, that kill does take the VM with it — upgrade rather than work around it.

NIC order and the two-VM link

  • Put user first: --add-network user --add-network 'socket::lab-link'. RouterOS auto-DHCPs only ether1 (observed: dynamic 10.0.2.15/24), and the host port-forwards assume it.14
  • For an L2 link between two VMs, prefer a named socket socket::<name>. It resolves through a registry entry, defaults to dgram (a unix datagram pair) off Windows, and either machine may start first — there are no listener/connector roles to get wrong. start prints the transport each link resolved to. Verified: two machines on one socket:: link, started in arbitrary order, static /24s on ether2, ping 3/3 on a macOS host.15
  • The raw socket:listen:<port> / socket:connect:<port> pair is the ordering-sensitive form — a TCP pair where the listener must start first. Reach for it only when you need those explicit roles; socket::<name> exists to avoid the ordering problem.16
  • A third machine on a two-machine link is refused at start (not at add), naming the two holders and pointing at --mode mcast. That refusal is the configuration check working.17

Which transport: default dgram, opt-in mcast

quickchr networks sockets create <name> takes --mode dgram|listen-connect|mcast and prints the transport it created — believe the output, not the name. Default is dgram (unix datagram pair, filesystem-confined, two machines) on macOS/Linux and listen-connect (TCP pair) on Windows, which has no AF_UNIX datagram socket. --mode mcast is UDP multicast 230.0.0.1 — the only N-way transport, and the only one that fails silently.18

quickchr prints all three hazards below as a warning when you create an mcast link. Two of them fail silently — same signature each time: interfaces up, addresses assigned, 100% loss, nothing logged:19

  • No macOS delivery — QEMU's multicast socket omits SO_REUSEPORT, which BSD/macOS require before two sockets on one group both receive (tikoci/quickchr#167).
  • Refused where unconnected UDP sends are blocked — seccomp-filtered sandboxes and some Linux CI return EPERM on sendto() while the group join succeeds (tikoci/quickchr#169).

The third is the opposite failure — traffic flows where it should not:

  • Not confined to your machine — quickchr passes no localaddr=, so the group rides the host's default multicast interface. Another host on your LAN using the same group joins your segment, and the link keeps working, so nothing looks wrong. That is an isolation failure, not a loss one. Give an N-way link a group of its own, or stay on dgram for two machines.

Driving the router: centrs --quickchr

Once the machine is up, reads and writes go through centrs (@tikoci/centrs) targeting the machine by name — it resolves the endpoint through quickchr inspect, so there is no credential plumbing:20

centrs retrieve --quickchr lab-a /system/resource
centrs execute --quickchr lab-a --yes '/ip/address/add interface=ether2 address=198.51.100.1/24'

Reads need no flag; writes refuse without --yes ([usage/confirmation-required]). One RouterOS statement per call.

Prefer it over quickchr exec: as of 0.4.8 quickchr itself says so, in exec --help and in a stderr tip on a bare quickchr exec (suppressible with QUICKCHR_NO_TIPS=1; tips never touch stdout, so --json stays parseable). quickchr exec is a raw /rest/execute pipe with no validation — whatever you type is what RouterOS is asked to run, and its output is screen-scraped text where centrs returns typed JSON.21

Need the raw endpoint instead — curl, or some other tool? quickchr inspect <name> --json carries the resolved URL and ready-to-use credentials. CHR here is admin with an empty password, and inspect hands you the Basic header already encoded:

"rest-api": { "url": "http://127.0.0.1:9150/rest", "port": 9150,
              "auth": { "username": "admin", "password": "", "basic": "admin:" } }

(auth also carries a pre-encoded header if you would rather not build the Basic value yourself.) Straight to curl:

url=$(quickchr inspect lab-a --json | jq -r '.services["rest-api"].url')
curl -s -u admin: "$url/system/resource" | jq .

That is the same door centrs uses — --quickchr resolves through quickchr inspect, never by reading machine.json — which is why it needs no credential configuration from you.22

Device-mode: the window closes at first boot

Two traps, and agents hit them in this order.

The guest-side route does not work. /system/device-mode/update container=yes over REST or API does not complete — RouterOS is waiting for a power cycle it cannot perform on itself — and the feature stays false. Observed live: the call did not return within the 10s client timeout and container was unchanged afterwards. (Whether it would ever return is not established; a field lab reported waiting ~5 min.) Do not reach for this, and do not read the timeout as a transport problem.23

The flags are honored only on a machine's first boot. quickchr applies device-mode by provisioning — boot the guest, set the mode, power-cycle QEMU via the monitor, re-read to confirm (≈45s vs ≈22s without). That runs only while the machine has never started. Once lastStartedAt is set, --device-mode-enable is parsed, accepted and silently ignored: no warning, no extra boot time, no change (tikoci/quickchr#176 — open).24

machine statestart --device-mode-enable container
added, never startedapplies — "Device-mode verified: mode=rose container=yes"
started at least oncesilently ignored — stays container: false

So add --device-mode-enable <feature> when you can; if you only realize you need it after creating the machine, you can still pass it to the first start. Once it has booted, the CLI has no way to change device-mode — remove + add --device-mode-enable … + start is the only CLI path. (The library exposes instance.setDeviceMode(), which power-cycles for you — see routeros-quickchr.)

Which side of the line a machine is on, and what it currently has:

quickchr inspect <name> --json | jq -r '.lastStartedAt // "never started"'
quickchr get <name>    # live Device Mode / License / Admin Users

Enabling container moves the mode itself too (advanced → rose). Provisioning needs RouterOS 7.20.8+ and a user-mode NIC — with only socket NICs it fails NETWORK_UNAVAILABLE; older 7.x is boot-only.25

Grounding behind this skill

  • references/cli-grounding.md — the live runs (commands, outputs, timings, environment) and an explicit list of what was not re-verified there.
  • quickchr MANUAL.md, DESIGN.md, docs/networking.md, CHANGELOG.md — authoritative behavior reference; the skill favors stable CLI concepts over version-specific flags.
  • Field lab that motivated this skill: external 3-CHR lab notes (muse-app, 2026-09-19/20) — add-doesn't-start, detached starts + REST polling, cold power-cycle for device-mode, cache-warming gap.

Footnotes

  1. Split rationale: tikoci/routeros-skills#21. Library consumers stay on routeros-quickchr; shell-first agents come here. ↩

  2. Pinned observations: published @tikoci/quickchr 0.4.8 (and 0.4.7 as a control), CHR 7.24.4 x86, QEMU 11.1.1 + HVF on macOS Intel. The named-socket dgram default, the printed transport, the three-machine refusal and the POSIX detach are all 0.4.8 (quickchr #158, #159) — they are absent from 0.4.7. ↩

  3. MikroTik, CHR: Licensing → CHR License Levels: "The Free license level allows CHR to run indefinitely with a 1 Mbps upload limit per interface. All other RouterOS features are available without restrictions." Paid tiers raise the cap (P1 1 Gbit, P10 10 Gbit, P-Unlimited), and a 60-day trial of a paid tier is free with a MikroTik account. This is MikroTik licensing, not a quickchr behavior; quickchr get <name> reports License Level: free on a default machine. ↩

  4. references/cli-grounding.md §Lifecycle timings. ↩

  5. Same; TCG band and the 480s BOOT_TIMEOUT from the field lab notes. ↩

  6. Same, §Lifecycle timings. ↩

  7. quickchr add --help (--version, --channel); quickchr --version prints the resolved stable/long-term versions. ↩

  8. quickchr add --help — "--port-base <port> Starting port number (default: auto-allocated from 9100)"; successive machines observed at 9140/9150/9160 in references/cli-grounding.md. ↩

  9. references/cli-grounding.md §Cache; quickchr CHANGELOG 0.4.8 "Added" (cache add, cache key). ↩

  10. Field lab notes: --add-network at add time, never a hand-edited machine.json. ↩

  11. --bg as a no-op: src/cli/index.ts — "Background default: true. Explicitly foreground only with --fg / --foreground / --no-background / --no-bg", and quickchr start --help renders --bg / --background Run in background (default). tikoci/quickchr#159 — part 1 (--no-wait) and the --bg naming decision remain open; part 2 (POSIX process group) shipped in 0.4.8. ↩

  12. Failure codes from quickchr's ErrorCode union (src/lib/types.ts); the bound-and-diagnose shape is this skill's recommendation, verified as a script in references/cli-grounding.md §Bounded readiness poll. ↩

  13. references/cli-grounding.md §Group-kill, which records the 0.4.7-vs-0.4.8 before/after. ↩

  14. references/cli-grounding.md §First NIC. ↩

  15. references/cli-grounding.md §Named-socket link. ↩

  16. parseSocketSpecifier() in quickchr src/lib/network.ts accepts socket::<name>, socket:listen:<port>, socket:connect:<port>, socket:mcast:<group>:<port>; docs/networking.md — "Named sockets avoid the listen/connect ordering problem". ↩

  17. references/cli-grounding.md §Third machine refused. ↩

  18. references/cli-grounding.md §Socket create output; defaultSocketMode() in quickchr src/lib/socket-registry.ts. ↩

  19. quickchr DESIGN.md "A named socket says what it is"; tikoci/quickchr#167 (delivery) and #169 (permission); warning text quoted in references/cli-grounding.md. ↩

  20. references/cli-grounding.md §Driving with centrs (retrieve, execute --yes, and the refusal without --yes). ↩

  21. quickchr exec --help and bare quickchr exec on 0.4.8, quoted in references/cli-grounding.md §Driving with centrs; CHANGELOG 0.4.8 "Added" (tips). ↩

  22. references/cli-grounding.md §Endpoints and credentials (quickchr inspect --json, quickchr get). ↩

  23. references/cli-grounding.md §Device-mode — the guest-side /system/device-mode/update container=yes timing out with container unchanged, reproduced on 0.4.8 / CHR 7.24.4. ↩

  24. Same section: the never-started machine applied and verified the change, the already-booted one silently did not. The gate is !existing.lastStartedAt in QuickCHR.start() (src/lib/quickchr.ts), which passes provisioning options to _launchExisting() on a first boot and undefined afterwards. Filed as tikoci/quickchr#176, which also asks which provisioning steps should be allowed to run later; until it lands, treat the silence as the documented behavior. ↩

  25. quickchr MANUAL.md "Order of operations" (_provisionInstance); --device-mode-enable in src/cli/flags.ts (accepted by both add and start, though the condensed add --help lists only --device-mode <m>); the user-mode-NIC requirement is the NETWORK_UNAVAILABLE guard in QuickCHR.start(). ↩

Signals

GitHub stars
63
Forks
15
Last commit
Sep 2026
Advanced
Catalog kind
skill
Key
routeros-quickchr-cli
Source
github.com/tikoci/routeros-skills