| name | expo-devtools-cli |
| description | Building Expo DevTools Plugins with CLI Interfaces for interacting with running Expo apps using agents. |
Building Expo DevTools Plugins with CLI Interfaces
Build CLI tools that communicate with running Expo apps via the DevTools plugin system.
Architecture Overview
┌─────────────────┐ WebSocket ┌─────────────────┐
│ CLI Client │◄──────────────────►│ Expo Dev Server │
│ (Bun + Stricli)│ │ (Metro) │
└─────────────────┘ └────────┬────────┘
│
┌────────▼────────┐
│ React Native │
│ App + Hook │
└─────────────────┘
Preferred Tech Stack
| Component | Technology | Why |
|---|
| Runtime | Bun | Fast startup, native TypeScript, built-in WebSocket |
| CLI Framework | @stricli/core | Type-safe, lazy loading, tree-shakeable |
| App Hook | expo/devtools | useDevToolsPluginClient for app-side connection |
| Protocol | JSON over WebSocket | Simple, debuggable with standard tools |
Project Structure
cli/
├── index.ts # Entry point with shebang
├── app.ts # Stricli app definition with routes
├── client.ts # WebSocket client for devtools
├── types.ts # Shared TypeScript types
├── formatters.ts # Output formatting (table, JSON)
└── commands/
├── query.ts # Read commands
├── write.ts # Write commands
└── status.ts # Status/health commands
src/devtools/
└── useMyPluginDevTools.ts # App-side message handler hook
Step 1: Configure the Module
Add devtools config to expo-module.config.json:
{
"name": "MyModule",
"platforms": ["ios", "android"],
"devtools": {
"name": "My Plugin",
"id": "my-plugin"
}
}
Step 2: Create the App-Side Hook
import { useEffect } from "react";
import { useDevToolsPluginClient } from "expo/devtools";
interface PluginMessage {
id: string;
type: string;
payload: Record<string, unknown>;
}
export function useMyPluginDevTools() {
const client = useDevToolsPluginClient("my-plugin");
useEffect(() => {
if (!client) return;
const handleMessage = (data: PluginMessage) => {
const { id, type, payload } = data;
const sendResult = (result: unknown) => {
client.sendMessage("result", { id, type: "result", data: result });
};
const sendError = (error: Error) => {
client.(, {
id,
: ,
: error.,
});
};
( () => {
{
() {
:
data = (payload. );
(data);
;
:
( ());
}
} (error) {
(error );
}
})();
};
subscription = client.(
,
{
(msg );
}
);
{
subscription?.?.();
};
}, [client]);
}
Step 3: Create the CLI Client
const DEFAULT_PORT = 8081;
const REQUEST_TIMEOUT = 30000;
const PROTOCOL_VERSION = 1;
export class PluginClient {
private ws: WebSocket | null = null;
private pending = new Map<string, { resolve: Function; reject: Function }>();
private connected = false;
private browserClientId = Date.now().toString();
private pluginName = "my-plugin";
async connect(port = DEFAULT_PORT): Promise<void> {
if (this.connected) return;
return new Promise((resolve, reject) => {
const url = ;
. = (url);
timeout = ( {
( ());
}, );
..(, {
(timeout);
. = ;
.();
();
});
..(, {
(timeout);
( ());
});
..(, {
. = ;
});
..(, {
.(event.);
});
});
}
(): {
handshake = {
: ,
: .,
: ,
: .,
: ,
};
.?.(.(handshake));
}
(: | ): {
( data === ) {
{
parsed = .(data);
(parsed.) ;
(parsed.) {
.(parsed);
}
} {
}
}
}
(: { : ; : }): {
{ messageKey, payload } = msg;
(messageKey. !== .) ;
(messageKey. === || messageKey. === ) {
response = payload {
: ;
?: ;
?: ;
};
pending = ..(response.);
(!pending) ;
..(response.);
(messageKey. === || response.) {
pending.( (response. ?? ));
} {
pending.(response.);
}
}
}
send<T>(: , : ): <T> {
(!. || !.) {
();
}
id = crypto.();
( {
..(id, { resolve, reject });
msg = {
: { : ., : },
: { id, , payload },
};
.!.(.(msg));
( {
(..(id)) {
..(id);
( ());
}
}, );
});
}
(): <> {
.?.();
. = ;
. = ;
}
}
Step 4: Create the CLI Entry Point
#!/usr/bin/env bun
import { run } from "@stricli/core";
import { app } from "./app";
await run(app, process.argv.slice(2), { process });
import { buildApplication, buildRouteMap } from "@stricli/core";
const routes = buildRouteMap({
routes: {
status: () => import("./commands/status").then((m) => m.default),
query: () => import("./commands/query").then((m) => m.default),
},
});
export const app = buildApplication(routes, {
name: "my-cli",
versionInfo: { currentVersion: "1.0.0" },
});
Step 5: Configure package.json
{
"bin": {
"my-cli": "cli/index.ts"
},
"scripts": {
"cli": "bun cli/index.ts"
},
"dependencies": {
"@stricli/core": "^1.1.0"
}
}
Footguns and Solutions
1. Binary vs JSON Messages
Problem: Messages sent as ArrayBuffer are silently ignored.
const encoder = new TextEncoder();
this.ws.send(encoder.encode(JSON.stringify(msg)).buffer);
this.ws.send(JSON.stringify(msg));
Debugging: Use websocat to test the WebSocket:
websocat -v ws://localhost:8081/expo-dev-plugins/broadcast
2. Wrong WebSocket Endpoint
Problem: Using /message or other endpoints won't work.
const url = `ws://localhost:${port}/message`;
const url = `ws://localhost:${port}/expo-dev-plugins/broadcast`;
Debugging: Use curl to verify WebSocket upgrade:
curl -v -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: test" -H "Sec-WebSocket-Version: 13" \
http://localhost:8081/expo-dev-plugins/broadcast
3. Missing Handshake Fields
Problem: Connection appears to work but messages aren't routed.
const handshake = { pluginName: "my-plugin" };
const handshake = {
protocolVersion: 1,
pluginName: "my-plugin",
method: "handshake",
browserClientId: "unique-id",
__isHandshakeMessages: true,
};
4. Protocol Version Mismatch
Problem: terminateBrowserClient messages with warning about incompatible clients.
protocolVersion: 2;
protocolVersion: 1;
5. Plugin Name Mismatch
Problem: Messages sent but never received by app.
The pluginName must match exactly across:
expo-module.config.json → devtools.id
- App hook →
useDevToolsPluginClient("my-plugin")
- CLI client →
this.pluginName = "my-plugin"
6. Hook Not Setting Up Listener
Problem: Hook logs "connected" but messages timeout.
Check that useDevToolsPluginClient is imported from the correct package:
import { useDevToolsPluginClient } from "expo/devtools";
import { useDevToolsPluginClient } from "@expo/devtools-plugin-client";
7. Message Listener Method Name
Problem: App receives connection but not messages.
The addMessageListener method name must match the messageKey.method from CLI:
const msg = {
messageKey: { pluginName: "my-plugin", method: "message" },
payload: { id, type, payload },
};
client.addMessageListener("message", handler);
Debugging Techniques
1. Monitor WebSocket Traffic
websocat --no-close -v ws://localhost:8081/expo-dev-plugins/broadcast
echo '{"protocolVersion":1,"pluginName":"my-plugin","method":"handshake","browserClientId":"test","__isHandshakeMessages":true}' | \
websocat ws://localhost:8081/expo-dev-plugins/broadcast
2. Check App Console Logs
bunx xcobra expo console --json | grep -i "my-plugin\|devtools"
3. Verify Hook is Running
Add temporary logging to the hook:
useEffect(() => {
console.log("[DevTools] client:", client ? "connected" : "null");
if (!client) return;
console.log("[DevTools] Setting up listener");
}, [client]);
4. Test Connection Independently
const ws = new WebSocket("ws://localhost:8081/expo-dev-plugins/broadcast");
ws.onopen = () => {
console.log("Connected");
ws.send(
JSON.stringify({
protocolVersion: 1,
pluginName: "my-plugin",
method: "handshake",
browserClientId: "test",
__isHandshakeMessages: true,
})
);
};
ws.onmessage = (e) => console.log("Received:", e.data);
Testing Workflow
- Start the app:
yarn expo run:ios or have simulator running with Expo Go
- Verify Metro is running: Check
http://localhost:8081 responds
- Test CLI connection:
bun cli/index.ts status
- Check for errors: Monitor both CLI output and app console
Reference Implementation
See the HealthKit CLI in this repo:
cli/ - Full CLI implementation
src/dev-tools/useHealthKitDevTools.ts - App-side hook
example/App.tsx - Hook usage in app