How-to: Debug a running app with rk-devtools
This walkthrough takes you end to end with the DevTools CLI: build the tool, point a running app at it, reproduce a bug, and pin down the exact action and state change that caused it — all from the terminal, no browser extension, no IDE debugger.
The CLI is headless and scriptable, which makes it the right tool for:
- debugging on devices/targets without a browser DevTools extension (iOS, Desktop, Wasm, CI),
- capturing a reproducible
.jsonltrace to attach to a bug report, - letting an AI agent or script answer "what fired before the crash?" — see the agent walkthrough.
:::caution Experimental The DevTools modules are experimental and the CLI is an unpublished developer tool — you build it from the repo, it is not a Maven artifact. :::
Screenshots in this guide are placeholders. Image slots reference the labelled placeholder PNGs in
./img/devtools-cli/; replace them with real captures against the TaskFlow sample when filling this page in.
The scenario
We use the TaskFlow Kanban sample. TaskFlow does optimistic card moves
against a fake backend: a move applies immediately, then the server result
either confirms it (CardOpSucceeded) or rejects it and rolls back
(CardOpFailed). To make rejections happen on demand, open Settings and set
the failure-rate slider to 100%. Now every move you make snaps back. We'll
use the CLI to watch that optimistic-apply → reject → rollback trace and confirm
the in-flight bookkeeping behaves.

Step 1 — Build the CLI
rk-devtools installs from the repo with Gradle's installDist:
./gradlew :redux-kotlin-devtools-cli:installDist
# resulting launcher:
redux-kotlin-devtools-cli/build/install/rk-devtools/bin/rk-devtools
Put it on your PATH for the session so the rest of the commands read cleanly:
export PATH="$PWD/redux-kotlin-devtools-cli/build/install/rk-devtools/bin:$PATH"
rk-devtools --help
Usage: rk-devtools [<options>] <command> [<args>]...
Options:
-h, --help Show this message and exit
Commands:
serve
stores
actions
diff
state
tail
Run rk-devtools <command> --help for a command's flags (e.g. rk-devtools actions --help).

Step 2 — Start the receiver
serve hosts the bridge receiver on 127.0.0.1:9090 and writes one
<storeKey>.jsonl capture per store under .rk-devtools/. Leave it running in
its own terminal before you launch the app:
rk-devtools serve
rk-devtools: listening on 127.0.0.1:9090
rk-devtools: writing captures to ./.rk-devtools/
rk-devtools: waiting for app… (Ctrl-C to stop)
Useful flags: --port, --host, --out <dir>, --token <t> (required for
non-loopback binds), and --ui to also launch the desktop GUI monitor against
the same ingest.

Step 3 — Point the app at the bridge
TaskFlow already does this — AppStore.kt / AccountStore.kt wire the
devTools(...) enhancer and attach a BridgeOutput(BridgeConfig(clientId = "taskflow"))
to the hub, pointing at 127.0.0.1:9090. So you can skip straight to launching
the app.
For your own app, add a BridgeOutput to the DevTools hub in
debug-only code (the store must already carry the devTools(...) enhancer —
see Wiring the store):
import org.reduxkotlin.devtools.DevToolsHub
import org.reduxkotlin.devtools.bridge.BridgeConfig
import org.reduxkotlin.devtools.bridge.BridgeOutput
DevToolsHub.registerOutput(
BridgeOutput(
BridgeConfig(
host = "127.0.0.1",
port = 9090,
startEnabled = true,
clientLabel = "my-app",
),
),
)
Launch the app; serve prints the connection (TaskFlow streams two stores — its
per-account board store and the root store):
rk-devtools: client connected — TaskFlow
rk-devtools: capturing store taskflow::TaskFlow
rk-devtools: capturing store taskflow::TaskFlow-root
Step 4 — List the captured stores
Move a card in the app (with failure-rate at 100% it'll snap back), then confirm the CLI is recording:
rk-devtools stores
taskflow::TaskFlow TaskFlow
taskflow::TaskFlow-root TaskFlow-root
(Each row is <storeKey> then the store's display name.)
The clientId::storeInstanceId key is what you pass to --store when more than
one store is captured (here clientId = "taskflow" and the instance id is each
store's DevToolsConfig name — TaskFlow for the board store, TaskFlow-root
for the root store). The board/card actions live in taskflow::TaskFlow. With a
single store, the query commands resolve it automatically.

Step 5 — Scan the recent action log
rk-devtools actions --store taskflow::TaskFlow --last 5
Output is one JSON object per line (pipe it to jq); ts is epoch millis:
{"actionId":1,"type":"AddCard","store":"taskflow::TaskFlow","ts":1718450691120}
{"actionId":2,"type":"CardMoveRequested","store":"taskflow::TaskFlow","ts":1718450691402}
{"actionId":3,"type":"CardOpFailed","store":"taskflow::TaskFlow","ts":1718450692118}
{"actionId":4,"type":"CardMoveRequested","store":"taskflow::TaskFlow","ts":1718450692980}
{"actionId":5,"type":"CardOpFailed","store":"taskflow::TaskFlow","ts":1718450693640}
There's the pair we want: an optimistic CardMoveRequested at 2 and its
rejection CardOpFailed at 3. Filter to just the card traffic with a type
glob:
rk-devtools actions --store taskflow::TaskFlow --type '*Card*' --last 5

Step 6 — Diff the offending action
diff shows the per-field JSON change each action produced. TaskFlow's state is
a ModelState, so the diff is keyed by model class. Look at what the rejection
did:
rk-devtools diff --store taskflow::TaskFlow --since 3 --until 3 --pretty
The diff tier adds a diff array of {op, path, before, after} entries
(--pretty expands it; drop it for one object per line):
{
"actionId": 3,
"type": "CardOpFailed",
"store": "taskflow::TaskFlow",
"ts": 1718450692118,
"diff": [
{ "op": "CHANGED", "path": "SyncModel.inFlight", "before": ["card-7"], "after": [] },
{ "op": "ADDED", "path": "SyncModel.lastError", "before": null, "after": "card-7 rejected by backend" },
{ "op": "CHANGED", "path": "BoardModel.board.card-7-column", "before": "done", "after": "doing" }
]
}
The trace confirms the rollback is correct: card-7 leaves SyncModel.inFlight
the moment the op resolves, lastError records why, and BoardModel reverts the
optimistic move via the action's inverse op. (Exact serialization depends on the
serializer tier — JVM renders structured JSON; other targets may render
toString().)

Step 7 — Confirm against full state
Print the full state snapshot at that action to confirm the post-rollback shape:
rk-devtools state --store taskflow::TaskFlow --at 3 --pretty
state prints the whole serialized state at that action (a ModelState, keyed
by model class):
{
"SyncModel": {
"online": true,
"pendingCount": 0,
"inFlight": [],
"lastError": "card-7 rejected by backend"
},
"BoardModel": {
"board": { "name": "Launch", "card-7-column": "doing" }
}
}
inFlight is empty and card-7 is back in its original column — the optimistic
move was cleanly reverted. If inFlight had retained card-7, that would be the
"stuck Saving…" bug to chase; here it's clean.
Watching live: tail --follow
While iterating, stream actions as they fire instead of re-running actions:
rk-devtools tail --follow
--follow polls the capture every 300ms and prints new actions (same JSON-line
format as actions). Combine with --type to watch only what you care about:
rk-devtools tail --follow --type '*Card*'

Other scenarios
| Goal | Command |
|---|---|
| Everything since a known-good action | rk-devtools actions --since 40 |
| A bounded window | rk-devtools actions --since 40 --until 50 |
| Only actions in a time window | rk-devtools actions --since-time 2026-06-15T12:04:00Z |
| Full state + diff for every action | rk-devtools actions --format full --pretty |
| One specific store when several are captured | rk-devtools diff --store 'taskflow::TaskFlow' --last 5 |
| Inspect captures with the GUI too | rk-devtools serve --ui |
Captures are just files
Each store is a plain <storeKey>.jsonl under .rk-devtools/ — commit one to a
bug report, diff two runs, or decode it programmatically with the bridge codec
(decodeRecording / decodeRecordingLenient). See the
bridge module.
See also
- DevTools reference — full module map, store wiring, in-app drawer, remote streaming, standalone monitor, security notes.
- CLI README — command/flag table.
- Agent walkthrough — the same loop driven by an AI agent.