agent-bridge
Drives running Expo apps via the Expo dev-tools socket, enabling agents to inspect screens, interact with UI, seed data, toggle flags, navigate, fake realtime messages, reload, and restore app state.
Provides Redux adapter tools for reading and manipulating Redux store state in a running app, with optional redaction of sensitive state paths.
Supports faking realtime messages in socket.io-powered apps so agents can simulate live updates during app flows.
@avasapp/agent-bridge
Let coding agents drive any running Expo or React Native app directly. Seed data, flip flags, fake realtime messages, navigate and check the screen in milliseconds, without tapping through the app. It works alongside device tools such as agent-device and Argent: they open the app and handle native UI, and agent-bridge handles everything inside the app. Use it from a shell, from flows or as an MCP server.

The same 19 checks on the example app, at real speed: 22.4 s with agent-device alone, 1.4 s with agent-bridge. With Argent instead of agent-device it is 17.1 s against 1.4 s while screen recording (Argent alone takes 11.6 s without the recorder). Most calls take a few milliseconds (benchmarks).
Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Nothing ships to users: every entry point compiles to an empty stub in release builds, and agent-bridge assert-absent checks a bundle in CI (security).
Install
npx @avasapp/agent-bridge initRun it in the app. It installs the package, writes a dev-only src/dev/agent-bridge.tsx wired for the libraries it finds, adds a smoke flow and the agent skill, and prints the two lines to paste into your root component. Agents run it as init --json; see the CLI.
Manual setup
bun add -d @avasapp/agent-bridge # or npm / yarn / pnpmThe package ships an agent skill that tells your agent when and how to drive the app. Add it to Claude Code, Cursor, Codex and other agents:
npx skills add avas-app/agent-bridgeIn Claude Code you can install it as a plugin instead:
/plugin marketplace add avas-app/agent-bridge
/plugin install agent-bridge@agent-bridgeRelated MCP server: React Native Debug MCP
In the app
Mount the hook in a file that only runs in development. You choose every tool; adapters for common libraries are one import away.
import { cdpTransport, useAgentBridge } from '@avasapp/agent-bridge'
import { expoTransport } from '@avasapp/agent-bridge/expo'
import { queryTools } from '@avasapp/agent-bridge/tanstack-query'
import { storeTools } from '@avasapp/agent-bridge/zustand'
import { mmkvTools } from '@avasapp/agent-bridge/react-native-mmkv'
// import { asyncStorageTools } from '@avasapp/agent-bridge/async-storage'
// import { secureStoreTools } from '@avasapp/agent-bridge/secure-store'
// import { reduxTools } from '@avasapp/agent-bridge/redux'
// import { jotaiTools } from '@avasapp/agent-bridge/jotai'
import { routerTools } from '@avasapp/agent-bridge/expo-router'
import { networkTools } from '@avasapp/agent-bridge/network'
import { router, useNavigationContainerRef } from 'expo-router'
export function AgentBridge() {
const queryClient = useQueryClient()
useAgentBridge({
name: 'my-app',
transports: [expoTransport(), cdpTransport()],
tools: {
...queryTools(queryClient),
...storeTools(
{ settings: useSettingsStore, auth: useAuthStore },
// Masks these paths in every store.* output; `store.set` and
// `store.call` never echo the whole store. An action's return value
// is matched by store path only if it is a piece of the state itself,
// not a copy; use a hook function for those.
{ redact: { auth: ['accessToken', 'refreshToken', 'user.phone'] } },
),
...mmkvTools({ storage }),
// Redux and Jotai state often holds tokens: add `redact` before wiring them.
// ...reduxTools(store, { redact: { auth: ['token'] } }),
// ...jotaiTools(getDefaultStore(), { user: userAtom }),
// AsyncStorage and SecureStore often hold tokens: wire them only if you accept that.
// ...asyncStorageTools(AsyncStorage),
// ...secureStoreTools(SecureStore, ['authToken'], { keychainService: 'auth' }),
...routerTools(router, { navigation: useNavigationContainerRef() }),
...networkTools(),
// Your own tools: any function, JSON in and out.
'auth.signIn': (session) => signInWith(session),
},
})
return null
}Every app also gets screen.snapshot (one short line per element), screen.press (taps, long presses, Switch toggles), screen.fill, screen.setValue (Switch, Slider, Picker, SegmentedControl), screen.scroll, screen.waitFor, screen.findText, app.reload, and bridge.restore to undo everything the agent changed, even across a reload in a session. See built-in tools and restore, including how to give agents your own hooks and register their undo.
Most flows need the app in a known state first, such as a user signed in locally with every request mocked. Define that once as a scenario.
From the agent
npx @avasapp/agent-bridge session start # hold one connection; call/tools/run reuse it
npx @avasapp/agent-bridge tools
npx @avasapp/agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
npx @avasapp/agent-bridge call query.pin '[["features"], {"beta": false}]'
npx @avasapp/agent-bridge call screen.press '"add-plant"'
npx @avasapp/agent-bridge call screen.waitFor '"Name is required"'
npx @avasapp/agent-bridge run flows/add-plant.flow.mjs --strict # fail if the app logged an error
npx @avasapp/agent-bridge run flows/ --bail # every *.flow.mjs in order, restore after each
npx @avasapp/agent-bridge session stop # runs bridge.restore, then disconnectsAgents should run ./node_modules/.bin/agent-bridge rather than npx, which is slower and can print npm warnings into their output. See the CLI for flows, batch mode, the REPL, sessions and the connect() client. Agents that prefer MCP tools to a shell can run agent-bridge mcp; see the MCP server.
No device tool is needed. Pair one (such as agent-device or Argent) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow. Argent's tap, swipe, screenshot and UI tree tools work next to the bridge as they are. Its debugger tools do too on Expo apps (the bridge uses the dev-tools socket) and on React Native 0.85 and later, which let several debuggers attach. Before 0.85, Metro's debugger socket takes one client at a time, so a bare React Native app on --transport cdp and a tool that holds the debugger open (Argent's debugger tools, React Native DevTools) can take it from each other.
The screen tools measure with Fabric, so they need React Native's New Architecture; on the old one the tools that need positions fail with an error that says so (snapshot {all: true} and findText still return text, without rects).
Docs
Scenarios: signed-in state, strict network, fixtures, gates
Adapters: TanStack Query, Zustand, Redux, Jotai, MMKV, AsyncStorage, SecureStore, Expo Router, React Navigation, network
Realtime: Ably, socket.io, any other library
Device events: deep links, app state, offline, push notifications, the clock
Contributing
See CONTRIBUTING.md.
License
MIT © Avas Enterprises
Available Tools
19 toolsagent_bridge_statusagent-bridge statusA
Whether this server is connected to the app, which app and session, how many tools it has, and why the last connect failed. Works with no app connected.
Inputs: no named parameters.
app_reloadapp.reloadA
Reload the app's JS (Expo's reloadAppAsync, else DevSettings.reload). Pending restores (store, query, mocks, pins, storage) are lost, and the reply names them: restore first. Returns before it reloads; a session waits for the new bridge and reports what was lost.
Inputs: args, full.
app_restoreapp.restoreA
Undo what the app's own tools changed: runs the undos they registered with onRestore, newest first, once. Returns how many ran.
Inputs: args, full.
bridge_logsbridge.logsB
Errors and warnings the app logged, newest last. Options: {level: "error"|"warn"|"all", limit, clear}.
Inputs: args, full.
bridge_pendingbridge.pendingA
What bridge.restore would undo right now, per area (store, query, net, app, ...): the value is true, or detail such as a store's snapshot against its current value. Empty when nothing is pending. Changes nothing.
Inputs: args, full.
bridge_restorebridge.restoreA
Undo what the agent changed: runs every *.restore tool in name order, scenario.restore last, and returns each result or error.
Inputs: args, full.
bridge_toolsbridge.toolsC
Every tool this app exposes.
Inputs: args, full.
scenario_applyscenario.applyB
Apply a scenario: [name, options?]. Options must fit its schema (scenario.list). Returns what it returns. Applying an active one undoes it first. bridge.restore undoes it.
Inputs: args, full.
scenario_listscenario.listC
Setups the app defines, e.g. a signed-in user: name, description, options (a JSON Schema), active.
Inputs: args, full.
scenario_restorescenario.restoreA
Undo every active scenario, newest first. bridge.restore runs this after every other restorer.
Inputs: args, full.
screen_capturescreen.captureA
Screenshot the app as a PNG. Returns {path} of a file on the device. {base64:true} returns {base64} instead when small enough (else {path}). Only works if the app already has react-native-view-shot; otherwise errors and you use agent-device, simctl or adb.
Inputs: args, full.
screen_fillscreen.fillA
Type into an input, then wait for the render. target is a string (testID, label, placeholder or text) or an object that must match every key it sets: {testID, label, placeholder, text, at:[x,y], index}. Unknown keys are errors. {at:[x,y]} hits the smallest element covering that point, for icon-only buttons with no label. Several matches: add an index, either in the target or as a trailing {index:n} argument. {submit:true} also submits. "" clears it. {type:true} types one character at a time, waiting for a render after each (for debounced or per-character inputs; "\n" is the Enter key). An input with no value prop gets its native text set; if that cannot be done the reply has a warning.
Inputs: args, full.
screen_findTextscreen.findTextA
Find text on screen (substring, or exact with {exact:true}) and whether each match is on screen. Searches the same joined text screen.snapshot shows (nested Text concatenated) and accessibility labels; {labels:false} searches text only. Each match says which field matched; a miss lists near misses. Ignores screens the user can't reach (unfocused tabs and stack screens, under a modal); hidden counts matches there.
Inputs: args, full.
screen_pressscreen.pressA
Press a button, then wait for the render. target is a string (testID, label, placeholder or text) or an object that must match every key it sets: {testID, label, placeholder, text, at:[x,y], index}. Unknown keys are errors. {at:[x,y]} hits the smallest element covering that point, for icon-only buttons with no label. Several matches: add an index, either in the target or as a trailing {index:n} argument. Text with no handler of its own presses the nearest ancestor that has one, and the reply says via:"ancestor". Disabled elements are refused; {force:true} presses anyway, to see what the app does on tap. {scroll:true} scrolls an off-screen target into view first. With no onPress it sends the "activate" accessibility action, as a screen reader's double tap does (swipe-to-confirm buttons, sliders), and the reply says via:"accessibilityAction:activate". {action:"increment"} (or any name in the element's snapshot actions) sends that action instead. A Switch (native switch host or role switch, with onValueChange) is toggled with onValueChange(!value), not the row around it; reply via:"onValueChange". {long:true} long presses: onPressIn, onLongPress, onPressOut (never onPress); an error when there is no onLongPress, on the element or an ancestor. {scroll:true} also pages the largest vertical FlatList/SectionList/FlashList for a row it has not rendered yet (and scrolls back if it finds none; onEndReached may fire). A tap inside a react-native-gesture-handler GestureDetector cannot be pressed from here (its callbacks run on the UI thread): the error says so.
Inputs: args, full.
screen_refreshscreen.refreshA
Pull to refresh: call the onRefresh of the RefreshControl of the scrollable holding a target (or the largest one with a RefreshControl on screen), then wait for the render. It does not wait for the data: follow with screen.waitFor.
Inputs: args, full.
screen_scrollscreen.scrollA
Scroll the nearest ScrollView/FlatList/FlashList, then wait for the render. Takes a target (or {to: target}) to bring into the middle of its scrollable, {toEnd:true}, {toStart:true}, or {by: points} (negative scrolls back); the last argument may be {within: target, index} to name the scrollable (a target inside it, or its testID). Without a target it scrolls the largest scrollable on screen. Only rendered elements can be targeted: rows a FlatList has not rendered yet need {toEnd:true} or {by} first. Returns the offset and, for a target, whether it is now on screen. target is a string (testID, label, placeholder or text) or an object that must match every key it sets: {testID, label, placeholder, text, at:[x,y], index}. Unknown keys are errors. {at:[x,y]} hits the smallest element covering that point, for icon-only buttons with no label. Several matches: add an index, either in the target or as a trailing {index:n} argument.
Inputs: args, full.
screen_setValuescreen.setValueA
Set a Switch, Slider, Picker or SegmentedControl: calls its onValueChange (and a Slider's onSlidingComplete) with the value, waits for the render and reads the value back. A Switch takes true or false, a Slider a number within minimumValue and maximumValue (0 and 1 by default) and on its step (onSlidingStart, onValueChange, onSlidingComplete), a Picker one of its Picker.Item values, a SegmentedControl one of its values (a wrong type or value is an error that lists the options). The reply has value as the app now shows it, and a warning when that is not what was set (a controlled value the app refused). Disabled controls are refused; {force:true} sets anyway. {scroll:true} scrolls or pages the target into view first (pages the largest vertical list; onEndReached may fire). target is a string (testID, label, placeholder or text) or an object that must match every key it sets: {testID, label, placeholder, text, at:[x,y], index}. Unknown keys are errors. {at:[x,y]} hits the smallest element covering that point, for icon-only buttons with no label. Several matches: add an index, either in the target or as a trailing {index:n} argument.
Inputs: args, full.
screen_snapshotscreen.snapshotA
Buttons, inputs, text, and testID or labelled views on screen, with rects and checked/selected/expanded state (a Switch's value is checked). actions lists the accessibility actions an element handles; one with "activate" and no onPress (a swipe-to-confirm) is a button screen.press can press. Only the focused screen: unfocused tabs and stack screens, content under an open modal, display:none and accessibility-hidden subtrees are left out, and hidden counts the elements skipped. {all:true} lists everything, marking off-screen (onScreen:false) and hidden (hidden:true) ones. A WebView registered with the react-native-webview adapter shows as one webview element with its name: use webview.* inside it. Options: {format:"json"|"text"} (the runtime default is JSON with rects; the CLI and the MCP server return the text form unless you pass {format:"json"}). Text is one line per element: kind (or role: switch, checkbox, tab), #testID, text, label, value, state, then readonly, actions=…, offscreen, hidden. Lines that repeat get index=N (the {index} of the target that names them); a line with no testID, text, label or placeholder gets @x,y, the point for {at:[x,y]}. JSON has rects, onScreen:false and hidden:true. {kinds:["button","input"]} keeps only those kinds (kinds are button, input, text, view, webview; a switch or tab is a button); {within: target} keeps only elements under that target in the tree (a target as for screen.press; with several matches pass {testID, index} inside it).
Inputs: args, full.
screen_waitForscreen.waitForA
Wait until a target is on screen, or gone with {gone:true}. Matches testID, label, placeholder, then the same joined text screen.snapshot shows. {scroll:true} also accepts a match rendered off screen and scrolls it into view, (it does not page lists for rows they have not rendered: wait for the list, then press/fill/setValue with {scroll:true}; not with gone). Default timeout 5000 ms; a timeout lists near misses.
Inputs: args, full.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 19 tools
The screen_* tools are clearly separated by action (press, fill, setValue, scroll, refresh, findText, snapshot, waitFor, capture), and the scenario_* and bridge_* groups mostly target distinct operations. The main ambiguity is the restore trio — app_restore, bridge_restore, and scenario_restore — plus bridge_pending, which require careful reading to choose correctly.
There are clear domain prefixes (screen_, bridge_, scenario_, app_), which makes the set broadly readable. However, suffixes mix snake_case and camelCase (screen_findText, screen_setValue, screen_waitFor vs. screen_press, screen_snapshot), and agent_bridge_status breaks the bridge_ prefix pattern.
At 19 tools, the set is slightly heavy but reasonable for a full app bridge covering diagnostics, scenarios, restoration, and screen automation. Each tool has a recognizable role, though the three restore variants add some surface area that could be consolidated.
The surface covers screen inspection, interaction, waiting, scrolling, capture, scenario lifecycle, bridge diagnostics, logs, reload, and restoration. Minor gaps remain: no gesture/swipe tool for gesture-handler cases, no direct network/mock inspection despite mentions of net and mocks, and no app launch/terminate operation.
Maintenance
Related MCP Connectors
Drive real devices from your AI Coding tool. Embed a client SDK (Unity, Godot, Flutter, iOS/macOS, Android, React Native, Web) in your app, then capture screenshots, traverse the UI tree, inject taps and key events, and run automated test tasks on the physical device over a secure relay.
Open a React Native app in a simulated phone: read the screen, tap, type, set device state.
Human-input bridge for AI agents with voice-first answer links, MCP tools, and HTTP APIs.
Email inboxes, webhook URLs, mock APIs and 170+ tools for coding agents over one MCP connection.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables automation and monitoring of React Native apps by providing tools to tap, swipe, screenshot, inspect component state, profile renders, and mock network requests via MCP.8 npm7MIT
- AlicenseBqualityDmaintenanceEnables AI agents to inspect, log, and control React Native apps on Android emulators or iOS simulators, including UI tree, tap, scroll, and hot reload.2252 npm5MIT
- AlicenseBqualityDmaintenanceAn MCP server that gives AI agents hands-on control of a live React Native app, enabling UI taps, screenshots, UI/component trees, native logs, and JS/React profiling to debug, verify fixes, and diagnose performance issues.1610 npmMIT
- AlicenseBqualityBmaintenanceConnects AI agents to a running React Native app so they can see the screen, tap and type, and inspect logs, network traffic, and state without any SDK or code changes. Composite tools let an agent run a whole interaction flow in a single call instead of one round trip per tap.356 npm2MIT