devtools CLI
Every command, action, flag, topic, output line, error and exit code of the devtools command line tool.
The devtools command line tool reads what the devtools recorded in a running app, and can mock,
block or send a request in it. This page lists everything it takes and prints. Use with AI agents shows how to use
it, with a snippet for your agent.
Running it
| Package manager | Command | Where |
|---|---|---|
| npm | npx devtools <command> | The app's project folder |
| yarn | yarn devtools <command> | The app's project folder |
| pnpm | pnpm exec devtools <command> | The app's project folder |
| bun | bunx devtools <command> | The app's project folder |
bun devtools runs a devtools.ts file first if the folder has one. In a monorepo, or outside the
app's folder, npx devtools can miss the local install and fetch an unrelated npm package named
devtools. npx @axonpack/expo-devtools <command> always runs this one. Below, devtools stands
for whichever form you use.
| Needs | Why |
|---|---|
| Node 22 or newer | Its built-in WebSocket sends the Origin header Metro asks for. Older Node exits with code 1. |
| A development bundle | The app in a dev build or Expo Go, with <DevtoolsProvider> mounted and enabled on. |
expo start running | On this computer. The tool talks to Metro on 127.0.0.1, ports 8081 to 8090 unless --port. |
Commands
| Command | What it prints |
|---|---|
devtools apps | One row per app and device on Metro: app, name, device, devtools, metro, note. |
devtools topics | One row per topic in the picked app: topic, rows (off when the app turned it off), actions (comma-separated, - for none), about. |
devtools <topic> [flags] | The topic's rows, newest first, with the query flags. |
devtools get <topic> <id> | One row as indented JSON. |
devtools <topic> <action> [args] [flags] | Changes something in the app. See Actions. |
devtools <command> --help | The help for that command. devtools, devtools help and devtools --help print the main help. devtools network --help adds the network actions, and devtools network <action> --help prints only them. |
apps and topics are probes: the AI Usage tab
doesn't list them. In apps, the devtools column is the package version in the app, - when the
app doesn't have it, and ? when it can't be checked without --force.
Flags
A flag is --name value or --name=value. A value can start with a dash, as in --sort -ms. -h
is the only short flag.
Query flags
The same on every topic. get takes --full, --json and --out.
| Flag | Value | Default | What it does | Example |
|---|---|---|---|---|
--grep | text, or /regex/flags | none | Rows holding it in any value, bodies included. Text ignores case. Keys are not searched. | --grep timeout |
--where | a clause, repeatable | none | Rows where the clause holds. Every --where must hold. | --where 'code>=400' |
--since | a time | none | Rows at or after it. | --since 10m |
--until | a time | none | Rows at or before it. | --until 2026-11-03T10:00:00Z |
--limit | whole number from 1 | 50 | Rows to print, or groups with --group-by. | --limit 20 |
--after | a row id | none | Only rows newer than it, oldest first. | --after fetch-1762172310455-212 |
--before | a row id | none | Only rows older than it. | --before console-1762172290871-80 |
--sort | field, or -field | newest first | Sorts by that field, - for descending. Rows without a value go last. No cursors. | --sort -ms |
--fields | comma list of fields | the topic's columns | The columns to print. | --fields id,code,url |
--count | none | off | Prints only how many rows match. | --count |
--group-by | field | none | Prints how many rows have each value, most first. | --group-by code |
--full | none | off | Strings whole. Without it, a string over 2,048 characters is cut. | --full |
--json | none | off | JSON lines, one object per row, with no # lines. get prints one line. | --json |
--out | file path | none | Writes the reply whole (as with --full) to the file, and prints one line saying so. With get, the file holds the JSON alone. | --out response.json |
App flags
Needed only when more than one app is running, or for an app on another port.
| Flag | Value | Default | What it does | Example |
|---|---|---|---|---|
--app | Expo slug | none | The app with this slug, whole, any case. Without an Expo manifest, the app id. | --app my-app |
--device | text | none | The app on a device whose name holds this text, any case. | --device pixel |
--port | whole number | 8081 to 8090 | Look on this Metro port only. | --port 8082 |
--force | none | off | Attach to an app that can't share its debugger and has no withDevtools to ask through. It closes a DevTools window open on the app. | --force |
--help, -h | none | off | Print the help for the command instead of running it. | network --help |
apps takes --port, --force and --json. topics takes the app flags and --json. An action
takes the app flags and its action flags.
Where clauses
<field><op><value>, or <field>?.
| Op | Holds when the value | Example |
|---|---|---|
= | equals it, ignoring case | method=get |
!= | does not equal it. A row with no value holds too | level!=log |
> >= < <= | compares as a number. The value must be a number | ms>1000 |
~ | contains it, ignoring case, or matches a /regex/flags | url~/v1/ |
!~ | does not contain it or match it. A row with no value holds too | url!~analytics |
? | has a value: not empty, not missing (<field>?, no value after it) | error? |
A plain value with commas is any of them: method=get,post. A /regex/ is never split on commas.
An object value is matched as its JSON.
Times
--since and --until take a number and a unit, ms, s, m, h or d (30s, 1.5h), counted
back from now on the device's clock, or any date the JavaScript Date reads, such as an ISO time. A
row with no time, such as every storage row, is left out by either.
Topics
id and t are on every topic: id is what get, --after and --before take, and t is the
time --since reads, printed as an ISO time. Fields in bold are the columns shown by default.
A --fields name that doesn't exist answers with the topic's fields.
network
HTTP requests, WebSockets and server-sent event streams since launch, the last 1,000. get adds a
socket's frames as messages and a stream's events as events. A request's initiator lists each
frame as a file with its line and column, and keeps frames from libraries only with --full.
--where state=error lists every request that failed: a 4xx or 5xx answer, a request that got no
answer, and a socket that failed. --where 'code>=400' lists only the 4xx and 5xx answers. A socket
has no code; how it closed is in close.
| Field | Value |
|---|---|
id | The row's id |
t | When it started |
kind | http, ws or sse |
method | The HTTP method |
code | The HTTP status. Empty for a socket |
close | A socket's close code, 1000 for a normal close. Empty for HTTP |
state | pending, success or error (a 4xx or 5xx answer, or no answer); a socket's connecting, open, closing, closed or error |
ms | How long it took |
url | The URL |
error | Why it failed |
type | The response's MIME type (HTTP) |
size | The response body's size in bytes (HTTP) |
msgs | How many frames or events a socket or stream has |
source | The name of the WebView that made it. Empty for the app's own |
request | The request body (HTTP) |
response | The response body (HTTP) |
console
Console lines since launch, the last 500. A line repeated in a row is one row with a count.
| Field | Value |
|---|---|
id | The row's id |
t | When it was logged |
level | log, info, warn, error, debug, input and result (the > prompt) or crash |
text | The whole line as text |
count | How many times it repeated |
source | native for the app, or the name of the WebView that logged it |
crash | The crash report's id, when the line is a crash |
performance
Long tasks, slow interactions, marks and measures, newest first. Kept only while the Performance tab
records, and it starts paused. Memory and frame-rate samples are not rows. An empty list while the
tab is paused ends in # paused: the Performance tab records only after record is pressed.
| Field | Value |
|---|---|
id | The row's id |
t | When it happened |
kind | longtask, interaction, mark or measure |
name | Its name |
ms | How long it took |
handler | How long an interaction's handlers ran |
detail | The detail a mark or measure was given |
storage
Every key of every registered store, by store then key. Rows have no time. Each query lists every
store's keys again, so keys the app wrote since show up, and keeps the values already read. A list
then reads the values on its page, and --grep or a --where, --sort or --group-by on value,
type, size or error reads every key once. A value read earlier is not read again.
| Field | Value |
|---|---|
id | <store>/<key> |
t | Always empty |
store | The store's id |
key | The key |
type | json-object, json-array, string, number, boolean, buffer, empty, absent, or unread before its value is read |
size | The value's size in UTF-8 bytes |
value | The value as text, after redaction |
error | Why reading it failed |
query
The TanStack Query cache: queries and mutations, the one updated last first. off with no
QueryClient in config.query.client.
| Field | Value |
|---|---|
id | The query hash, or mutation-<n> |
t | When it last updated, or when the mutation was submitted |
type | query or mutation |
key | A mutation's key |
status | Its status |
observers | How many observers a query has |
variables | A mutation's variables |
data | Its data |
error | Its error |
navigation
The moves the navigator made, the last 200. off with no Expo Router or React Navigation found.
| Field | Value |
|---|---|
id | The row's id |
t | When it moved |
action | The action's type |
from | The route it left |
to | The route it landed on |
path | The destination's URL path |
params | The destination's params |
container | The navigation container that moved |
noop | True when the action changed nothing |
redux
Dispatched actions, the last 200, each with what it changed. Never the whole state. off with no
store using devtoolsReduxEnhancer().
| Field | Value |
|---|---|
id | The row's id |
t | When it was dispatched |
type | The action's type |
action | The whole action |
changed | The state paths it changed, comma-separated |
changes | How many paths it changed |
get adds each change: its path, whether it was added, removed or changed, and the value before and after.
crash
Captured crashes, newest first. get adds the stack, the breadcrumbs and the device. off when
crash.enabled is false.
| Field | Value |
|---|---|
id | The row's id |
t | When it happened |
kind | js-fatal, js-error, unhandled-rejection, react-render or native-exception |
name | The error's name |
message | The error's message |
route | The screen on top when it happened |
previous | True for a crash read back from the previous launch |
seen | True once the report was shown |
ai-usage
The queries and actions agents made, the last 100, each with the reply it got. A query of
ai-usage keeps no reply. --where 'action?' lists the actions alone.
| Field | Value |
|---|---|
id | q<n> |
t | When the app answered |
command | The query or action as you would type it after devtools. A masked header shows as [redacted], a body over 200 characters as its start and length |
topic | The topic it asked about |
action | The action's name. Empty for a query |
matched | Rows it matched |
total | Rows the topic held |
ms | How long the app took |
size | The whole reply's length in characters |
error | The message of an error: reply |
cut | True when only the first 64 K characters of the reply were kept |
reply | The reply as the agent got it |
Actions
devtools <topic> <action> [args] [flags] changes something in the app. Only network has actions;
devtools topics lists each topic's actions. Query flags (--where, --json, --out and the rest)
don't go with an action, and action flags don't go with a query: either is an error before the tool
looks for an app. Every action, refused ones too, is listed in the
AI Usage tab, marked as an action.
Action flags
| Flag | Value | Taken by | What it does |
|---|---|---|---|
--status | whole number, 100 to 599 | override | The mocked status. Default 200. |
--body | text | override, send, replay | The body. Default empty for override. Not for a GET or HEAD request. |
--body-file | file path | override, send, replay | Read the body from this file on the computer, as UTF-8 text. Not with --body. |
--content-type | MIME type | override | The mocked content-type. Default application/json. |
--header | 'Name: value', repeatable | send, replay | A request header. In replay it replaces a header of the same name, any case. |
--yes | none | send, replay | Needed for any method but GET, HEAD and OPTIONS. The app checks it, so a call through the window relay needs it too. |
--all | none | unoverride | Remove every override instead of one. |
network actions
Overrides match the exact URL the app requests, scheme and query included, as the Network tab's
own overrides do. There are no patterns or prefixes: copy the URL from a row. They are the same
overrides the tab makes, so they show in its Overrides view and in React Native DevTools. They apply
to fetch and XMLHttpRequest, and last until the app reloads or they are removed.
| Action | Args | Flags | Reply |
|---|---|---|---|
override | <url> | --status, --body or --body-file, --content-type | # network · override on <url>: <status>, <type>, <n> chars. The app's requests to that exact URL get it, without reaching the network |
block | <url> | none | # network · blocking <url>: the app's requests to that exact URL fail as if offline |
unoverride | <url>, or none | --all (with no <url>) | # network · removed the override on <url>, or # network · removed <n> overrides |
overrides | none | none | # network · <n> overrides, then the columns url action status type size (- for a block) |
send | <METHOD> <url> | --header, --body or --body-file, --yes | The new row, as below |
replay | <id> | --header, --body or --body-file, --yes | The new row, as below |
send sends the request from inside the app, through the same function as the Network tab's
Sandbox, and so through the app's own fetch. Overrides and network conditions apply to it, and it
is a row in the log like any other request. The method is case-insensitive.
replay takes a logged HTTP request and sends it again as the Sandbox would: the same method, URL,
headers and body, with --header and --body on top. The app's own headers go with it, including
ones the tool shows you only as [redacted]. A header that network.redactHeaders removed before
the request was logged goes as the text [redacted]; pass it again with --header.
Both answer with the new row, in the network columns plus error:
# network · my-app · iPhone 16 Pro · sent POST https://api.example.com/v1/notes as fetch-1762172310455-215. devtools get network fetch-1762172310455-215 shows it whole
id method code state ms url error
fetch-1762172310455-215 POST 201 success 312 https://api.example.com/v1/notes -replay starts replayed <id> as <new id>. A request that failed (blocked, offline, no answer) is
still a row, with state error, and exit code 0. When the request is not in the log (recording
is paused, or network.redact dropped it) the reply is
# network · sent <METHOD> <url>: <status> in <ms> ms. Not in the log: recording is paused, or the app's network redact config dropped it,
and its body is not shown.
While the request is in flight the app answers # wait: sending <METHOD> <url> as <id>, and the
tool asks again every 200 ms within the 5 seconds. A request that takes longer carries on
in the app. Running the command again would send it again, so the error points at ai-usage
instead, where the action is listed once it ends.
Output
Tab-separated, with a header row. In a cell, - is an empty value, a tab or line break is written as
\t, \n or \r, t is an ISO time, and a string over 2,048 characters ends in
... [cut at 2048 of <n> chars, --full for all].
| Line | When |
|---|---|
# <topic> · <app> · <device> · <n> of <total> match | First line of a list. Ends in · showing <n> when --limit cut rows off. |
# <topic> · <app> · <device> · <n> of <total> match · <k> values of <field> | First line of --group-by, above <field> and count columns. |
<n> | The whole reply to --count. |
# next: --after <id> | Last lines of an unsorted list: the cursor for the next poll. A row still in flight holds it back. |
# more: --before <id> | When the limit cut older rows off: the cursor for the next page. |
# <topic> is off: <reason> | The whole reply for a topic the app turned off. Exit code 0. |
# <topic> <id> | First line of get, above the indented JSON. |
# <topic>: wrote <n> chars to <file> | The whole reply with --out. With get it starts # <topic> <id>, and the file holds the JSON alone. |
# paused: the Performance tab records only after record is pressed | Last line of an empty performance list while the tab is paused. |
# topics · <app> · <device> | First line of topics. |
# <topic> · <app> · <device> · <what it did> | First line of an action's reply. See Actions. |
# ?: checking would close a DevTools window open on the app. --force checks anyway. | Under apps, when a row shows ?. |
# wait: <what> | Never printed. The app says it is still reading, or an action is still sending; the tool asks again every 200 ms. |
--json prints none of the # lines, so it has no cursors. Each row is one JSON object with the
chosen fields; --group-by gives { "<field>": value, "count": n }. Strings are still cut unless
--full. With no rows it prints nothing.
Errors
The app's own errors are one error: ... line on stdout. The tool's own errors are one
error: ... line on stderr. Both exit with the code shown.
From the app
| Message | Code |
|---|---|
unknown topic "<name>". Topics: <list> | 1 |
"<text>" isn't a topic. Pass the topic and each flag as separate arguments: devtools <text> | 1 |
unknown field "<name>" in <topic>. Fields: <list> | 1 |
no <topic> row "<id>", it may have aged out of the buffer | 1 |
bad --limit <n>: use a whole number from 1 | 1 |
bad --where "<clause>": use <field><op><value> with = != > >= < <= ~ !~, or <field>? | 1 |
bad --where "<clause>": unknown op "<op>". Ops: = != > >= < <= ~ !~ | 1 |
bad --where "<clause>": <op> needs a number | 1 |
bad regex <pattern>: <why> | 1 |
bad --since "<value>": use 30s, 10m, 2h, 1d or an ISO time (and --until) | 1 |
From the app, for an action
| Message | Code |
|---|---|
<topic> has no action "<name>". Actions: <list> | 1 |
<topic> has no actions, only queries | 1 |
<topic> is off: <reason> | 1 |
usage: devtools <topic> <action> <args>, such as usage: devtools network unoverride <url> | --all | 1 |
<topic> <action> takes no --<flag>. usage: devtools <topic> <action> <args> | 1 |
bad --<flag>. usage: ..., for a value of the wrong kind (only through the window relay) | 1 |
"<url>" is not a whole URL. Use the exact URL the app requests, https:// and query included | 1 |
bad --status <value>: use 100 to 599 | 1 |
no override on <url>. devtools network overrides lists them | 1 |
bad method "<method>": use GET, POST, ... | 1 |
<METHOD> can change data on the server, so it needs --yes. GET, HEAD and OPTIONS go without | 1 |
a GET request has no body, so drop --body (and HEAD) | 1 |
bad --header: use 'Name: value' | 1 |
no network row "<id>", it may have aged out of the buffer | 1 |
<id> is a WebSocket. replay sends HTTP requests only | 1 |
<METHOD> <url> failed: <why>, only when the request is also not in the log | 1 |
From the tool
| Message | Code |
|---|---|
devtools needs Node 22 or newer, and this is Node <version>. | 1 |
unknown flag <flag>. See devtools --help | 1 |
--<flag> needs a value | 1 |
bad --limit <value>: use a whole number from 1 (and --port) | 1 |
usage: devtools get <topic> <id>. See devtools get --help | 1 |
usage: devtools apps. See devtools apps --help (and topics) | 1 |
--status, --body, --body-file, --content-type, --header, --yes and --all go with an action, such as devtools network override <url>. See devtools network --help | 1 |
query flags such as --where, --json and --out don't go with an action, and <topic> <action> is one. See devtools <topic> --help | 1 |
pass --body or --body-file, not both | 1 |
can't read --body-file <path>: <why> | 1 |
no reply from <app> on <device> in 5 s. Its JS may be paused at a breakpoint: resume it, then run the command again. | 1 |
still <what> after 5 s. The app keeps going, so run the same command again to pick up where it stopped. | 1 |
still <what> after 5 s. It carries on in the app, and devtools ai-usage --where 'action?' shows how it ended. (an action) | 1 |
could not reach Metro on port <port> | 1 |
could not attach to <socket url> | 1 |
the debugger socket closed (<code>: <reason>) | 1 |
the app threw: <message> | 1 |
no DevTools window is open on this page, or another error from the window relay | 1 |
no app found on Metro ports 8081 to 8090. Open the app in a dev build or Expo Go with `expo start` running, or pass --port. (port <n> with --port) | 2 |
no app matches <flags>. Running:, then a --app <slug> --device '<device>' line per app | 2 |
<n> apps match. Pick one:, then a --app <slug> --device '<device>' line per app | 2 |
<app> on <device> can't share its debugger (React Native below 0.86, or Expo started headless), and metro.config.js has no withDevtools to ask through its DevTools window, so attaching could close one open on it. Pass --force to attach anyway. | 2 |
<app> on <device> has no devtools agent. It needs @axonpack/expo-devtools with <DevtoolsProvider> mounted, in a dev build or Expo Go. | 2 |
In apps, an app that couldn't be asked shows why in its note column instead, such as
no reply in 5 s: JS may be paused at a breakpoint.
Exit codes
| Code | Meaning |
|---|---|
0 | It answered, including a topic that is off, a query that matched nothing, and a sent request that failed. |
1 | An error: a bad command or flag, an app's error: reply, Node older than 22, a socket error, or no answer in time. |
2 | No app to ask: none running, none or several matching, no devtools in it, or it needs --force. |
Timing
| Limit | Value |
|---|---|
| Each command | 5 seconds for the app to answer, waits included. |
| Asking again | Every 200 ms while the app answers # wait:. |
| Metro reads | 2 seconds for each read of /status, /json/list and the manifest. |
What it reads
Only what the stores keep, after every redaction in the config. The authorization, cookie and
set-cookie headers and a page's document.cookie are always [redacted], in every output, and
filters run on the masked row. An action's reply goes through the same masking. The only changes the
tool can make are the actions. See
Redaction and
Older React Native.